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
+5
View File
@@ -91,6 +91,11 @@
# (default: enabled).
# AGENTHUB_PRICING_SYNC=0
# The status bar's Usage panel reads Antigravity's quota by running
# `agy --print /usage --output-format json` (no model call), which may let agy
# update itself. 0 never runs it, and Antigravity shows no quota (default: on).
# AGENTHUB_AGY_USAGE=0
# Heavy read-only aggregates (GET /api/workflows) run in a worker thread on
# their own connection so they never stall other requests. 0 runs them inline
# on the event loop instead (default: worker). A job past the timeout answers
+6 -4
View File
File diff suppressed because one or more lines are too long
+4
View File
@@ -48,6 +48,10 @@ open it in the browser and you are done.
Something wrong? Send `/agenthub:agenthub-doctor` to check and fix it.
To update to a new version: `/agenthub:agenthub-update`.
Not using it? Quit so nothing runs in the background: **Settings → System → Quit**,
or `agenthub stop` — opening Claude Code no longer starts it; `agenthub start` does.
AgentHub's icon beside the clock (top bar / menu bar / notification area) offers
**Open**, **Quit** and **Start AgentHub** too; hide it from its menu or in **Settings → System**.
<a id="install-terminal"></a>
+4
View File
@@ -48,6 +48,10 @@ trên trình duyệt là xong.
Gặp lỗi thì gửi `/agenthub:agenthub-doctor` để tự kiểm tra và sửa.
Cập nhật bản mới: `/agenthub:agenthub-update`.
Không dùng thì tắt hẳn cho khỏi chạy ngầm: **Settings → Hệ thống → Thoát**, hoặc
`agenthub stop` — mở Claude Code sẽ không tự bật lại; bật lại bằng `agenthub start`.
Icon AgentHub cạnh đồng hồ (thanh trên cùng / menu bar / khay hệ thống) cũng có
**Open**, **Quit** và **Start AgentHub**; ẩn nó trong menu của icon hoặc ở **Settings → Hệ thống**.
<a id="install-terminal"></a>
+67 -4
View File
@@ -40,7 +40,7 @@
const path = require("node:path");
const fs = require("node:fs");
const { spawn } = require("node:child_process");
const { spawn, spawnSync } = require("node:child_process");
// Resolve the repo root relative to this script's REAL location so the CLI
// works both from a checkout (./bin/agenthub.js) and through the global symlink
@@ -1397,14 +1397,36 @@ async function cmdReinstallHooks() {
* built client) and wait until /api/health answers. No-ops with a pointer to
* the live URL when a server is already up. The child is fully detached with
* its output appended to data/agenthub-server.log, so closing this terminal does
* not stop the dashboard; stop it later with `kill <pid>` (the PID is
* printed and registered in ~/.claude/.agenthub.json).
* not stop the dashboard; stop it later with `agenthub stop`.
*/
async function cmdStart(flags) {
// A Quit (`agenthub stop`) held the plugin bootstrap off; starting ends it.
require(path.join(REPO_ROOT, "server", "lib", "quit.js")).clearQuit();
if (await serverIsUp()) {
console.log(`${c.green("●")} Dashboard already running at ${c.bold(baseUrl())}`);
return;
}
// A plugin install starts the way a Claude Code session does: its bootstrap
// knows the runtime dir, the UI bundle and the autostart unit.
if (/[\\/]\.claude[\\/]plugins[\\/]cache[\\/]/.test(REPO_ROOT)) {
spawnSync(
process.execPath,
[path.join(REPO_ROOT, "scripts", "plugin-bootstrap.js"), "--worker"],
{
stdio: "inherit",
}
);
const deadline = Date.now() + 30_000;
while (Date.now() < deadline) {
if (await serverIsUp()) {
console.log(`${c.green("●")} Dashboard ${c.bold("up")} at ${c.bold(baseUrl())}`);
return;
}
await new Promise((r) => setTimeout(r, 500));
}
console.error(c.red("✖ Server did not become healthy within 30 s — run /agenthub-doctor"));
process.exit(1);
}
const clientDist = path.join(REPO_ROOT, "client", "dist", "index.html");
if (!fs.existsSync(clientDist)) {
console.error(c.red("✖ client/dist is missing — the production server needs a built client."));
@@ -1447,7 +1469,7 @@ async function cmdStart(flags) {
console.log(
`${c.green("●")} Dashboard ${c.bold("up")} at ${c.bold(baseUrl())} ${c.dim(`(pid ${child.pid})`)}`
);
console.log(c.dim(` Stop it with: kill ${child.pid}`));
console.log(c.dim(" Stop it with: agenthub stop"));
return;
}
tick++;
@@ -1463,6 +1485,33 @@ async function cmdStart(flags) {
process.exit(1);
}
/**
* Quit AgentHub (issue #65): every lane pane is killed, the server stops, and
* the plugin bootstrap starts none until `agenthub start` — or a boot/login
* through an enabled autostart unit (`server/lib/quit.js`).
*/
async function cmdStop() {
if (!(await serverIsUp())) {
require(path.join(REPO_ROOT, "server", "lib", "quit.js")).markQuit();
console.log(
`${c.dim("○")} Dashboard is not running — it stays stopped until ${c.bold("agenthub start")}`
);
return;
}
const { killed } = await post("/api/settings/quit");
const deadline = Date.now() + 15_000;
while ((await serverIsUp()) && Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 250));
}
if (await serverIsUp()) {
console.error(c.red(`✖ The dashboard at ${baseUrl()} is still answering after 15 s`));
process.exit(1);
}
console.log(
`${c.green("✔")} AgentHub stopped${killed ? ` — ${killed} terminal session(s) closed` : ""}. Start it again with: ${c.bold("agenthub start")}`
);
}
/**
* Installs/removes a systemd `--user` service (Linux) or a LaunchAgent
* (macOS) so the dashboard survives reboots without anything (Claude Code
@@ -2731,6 +2780,11 @@ const COMMAND_GROUPS = [
[
["status", "", "Up/down indicator for the dashboard server"],
["start", "[--port N]", "Start the server in the background and wait for healthy"],
[
"stop",
"",
"Quit: close every workspace terminal and stop the server until `agenthub start`",
],
[
"service",
"install|uninstall|status [--port N]",
@@ -2948,6 +3002,11 @@ const COMMAND_GROUPS = [
"",
"Run the read-only MCP server on stdio (claude mcp add agenthub -- agenthub mcp)",
],
[
"tray",
"",
"Show AgentHub's icon in the OS status area now (the server keeps it up; Settings → System hides it)",
],
["statusline", "", "Render the Claude Code statusline from the JSON on stdin"],
[
"statusline install|uninstall",
@@ -4000,6 +4059,8 @@ async function runCommand(argv) {
return cmdStatus();
case "start":
return cmdStart(flags);
case "stop":
return cmdStop();
case "service":
return cmdService(positional[0], flags);
case "server":
@@ -4135,6 +4196,8 @@ async function runCommand(argv) {
return;
}
return require(path.join(REPO_ROOT, "statusline", "statusline.js")).main();
case "tray":
return require(path.join(REPO_ROOT, "scripts", "tray.js")).main();
case "open":
return cmdOpen();
case "version":
+3 -3
View File
@@ -52,7 +52,7 @@ The client is a single-page application (SPA) built with modern web technologies
- **React 18.3** - Component-based UI with hooks and concurrent features
- **TypeScript 5.7** - Full type safety across components, utilities, and API contracts
- **Vite 6.1** - Lightning-fast HMR during development, optimized production builds
- **Tailwind CSS 3.4** - Utility-first CSS framework; colors are CSS-variable-backed tokens (`darkMode: "class"`) so a Dark/Light/System switch (Settings → Appearance; `useTheme` hook — the sidebar has no theme or language controls, the language lives in Settings → Appearance) re-themes the whole app by flipping one class. A **named theme** per mode is one more attribute: `data-theme` on `<html>` (absent for `default`) selects a CSS-variable block in `index.css` — `.dark[data-theme="darcula|apple-dark|orca-dark"]` and `[data-mode="light"][data-theme="apple-light|intellij-light|canvas|orca-light"]` — that overrides only the tokens it changes. `--tint-base` is `var(--surface-3)` and light `--syn-plain` is `var(--fg-primary)`, so a theme never restates them. A new theme is a block there plus an id in `THEMES` (`hooks/useTheme.ts`) and a label in `components/settings/AppearanceSettings.tsx`; its Settings preview needs no hex, because the swatch re-scopes the same variables on its own element. Storage: `theme` = `dark|light|system`, `agenthub.theme.dark` / `agenthub.theme.light` = theme ids (unknown ⇒ `default`); state is read through `useSyncExternalStore`, never cached in the module, so every consumer and every tab agree. An inline `<script>` at the top of `client/index.html` applies the same keys before first paint (no flash of the stock dark palette); it does not validate ids — the hook does, on mount
- **Tailwind CSS 3.4** - Utility-first CSS framework; colors are CSS-variable-backed tokens (`darkMode: "class"`) so a Dark/Light/System switch (Settings → Appearance; `useTheme` hook — the sidebar has no theme or language controls, the language lives in Settings → Appearance) re-themes the whole app by flipping one class. A **named theme** per mode is one more attribute: `data-theme` on `<html>` (absent for `default`) selects a CSS-variable block in `index.css` — `.dark[data-theme="darcula|apple-dark|orca-dark"]` and `[data-mode="light"][data-theme="apple-light|intellij-light|canvas|orca-light"]` — that overrides only the tokens it changes. `--tint-base` is `var(--surface-3)` and light `--syn-plain` is `var(--fg-primary)`, so a theme never restates them. A new theme is a block there plus an id in `THEMES` (`hooks/useTheme.ts`) and a label in `components/settings/AppearanceSettings.tsx`; its Settings preview needs no hex, because the swatch re-scopes the same variables on its own element. Storage: `theme` = `dark|light|system`, `agenthub.theme.dark` / `agenthub.theme.light` = theme ids (unknown ⇒ `default`); state is read through `useSyncExternalStore`, never cached in the module, so every consumer and every tab agree. An inline `<script>` at the top of `client/index.html` applies the same keys before first paint (no flash of the stock dark palette); it does not validate ids — the hook does, on mount. The workspace terminal's font is the same kind of per-browser preference: `agenthub.terminal.font` = `jetbrains|maple|system` (`lib/terminalFont.ts`, default `jetbrains`), picked in Settings → Appearance and read when a terminal opens
**The surface ramp means elevation, and it runs the same direction in both themes: a higher index is LIGHTER in dark and LIGHTER in light.** So in light mode the page is the *darkest* step (`surface-0`, `#eceef2`) and a `.card` is brighter than the page it sits on (`surface-3`, `#fdfdfe`), which is also what makes every `hover:bg-surface-N` lift rather than dim. Light mode originally used Radix slate in its native direction — index up meant darker — which inverted the whole language: a near-white page (the glare complaint) under grey cards that read as recessed. Never "fix" a light-mode colour by swapping which step a component uses; fix the value of the step.
@@ -199,7 +199,7 @@ client/
│ │ │ ├── ClaudeAgentHubSettings.tsx # Claude Code → Overview: CLAUDE_HOME, hook status, pricing
│ │ │ ├── HomeDirSetting.tsx # CLAUDE_HOME / CODEX_HOME editor (env-owned warning)
│ │ │ ├── CodexHooksSettings.tsx # Codex → Overview → Codex hooks card
│ │ │ ├── ReportSettings.tsx # Settings → Reports: scheduled report definitions, Run now, run history + downloads
│ │ │ ├── ReportSettings.tsx # Settings → Snapshots & reports: scheduled report definitions, Run now, run history + downloads
│ │ │ └── AntigravityHooksSettings.tsx # Antigravity → Antigravity hooks card
│ │ └── workflows/ # Dashboard → Analytics → Workflow panels: own-pipeline stats + D3 analytics (11 files)
│ │
@@ -259,7 +259,7 @@ graph TB
Detail[SessionDetail<br/>agent hierarchy + timeline]
Feed[ActivityFeed<br/>streaming event log]
Workflows[Workflows<br/>Analytics → Workflow sub-tab:<br/>lane pipelines + D3 analytics]
Settings[SettingsHub /settings/:section<br/>General · Appearance · Notifications<br/>Agents: AI tools, Claude Code, Codex, each catalog tool<br/>Routing: AI Proxy · Cost: Pricing · Git: credentials, worktree suggestions]
Settings[SettingsHub /settings/:section<br/>General: Appearance · Notifications · System<br/>Agents: AI tools, Claude Code, Codex, each catalog tool, AI Proxy<br/>Data: Database · Pricing · Snapshots & reports · Git (credentials, worktree suggestions)]
App --> Layout
Layout --> Dashboard & Sessions & Detail & Feed & Settings
+10
View File
@@ -14,6 +14,7 @@
"@codemirror/view": "^6.43.13",
"@fontsource/inter": "^5.2.8",
"@fontsource/jetbrains-mono": "^5.2.8",
"@fontsource/maple-mono": "^5.3.0",
"@xterm/addon-canvas": "^0.7.0",
"@xterm/addon-clipboard": "^0.1.0",
"@xterm/addon-fit": "^0.10.0",
@@ -1066,6 +1067,15 @@
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/maple-mono": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/maple-mono/-/maple-mono-5.3.0.tgz",
"integrity": "sha512-8N3FVDWphO/971tZIig9D558j9jf09fWYIXFjgv7DKJEvDjtgfATx1ScWHdTCsf+tufFS5M1bAa4AumVBKPhSQ==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+1
View File
@@ -17,6 +17,7 @@
"@codemirror/view": "^6.43.13",
"@fontsource/inter": "^5.2.8",
"@fontsource/jetbrains-mono": "^5.2.8",
"@fontsource/maple-mono": "^5.3.0",
"@xterm/addon-canvas": "^0.7.0",
"@xterm/addon-clipboard": "^0.1.0",
"@xterm/addon-fit": "^0.10.0",
+9
View File
@@ -87,6 +87,7 @@ const SessionDetail = lazy(() =>
const SnapshotView = lazy(() =>
import("./pages/SnapshotView").then((m) => ({ default: m.SnapshotView }))
);
const Users = lazy(() => import("./pages/Users").then((m) => ({ default: m.Users })));
function PageFallback() {
const { t } = useTranslation();
@@ -209,6 +210,14 @@ export default function App() {
</Page>
}
/>
<Route
path="users"
element={
<Page>
<Users />
</Page>
}
/>
<Route
path="settings/*"
element={
+67 -39
View File
@@ -1,14 +1,16 @@
/**
* @file PetSprite.tsx
* @description The mascot wearing a custom pet (`/api/pets`). A Codex pet is
* a 1536×1872 spritesheet — 8 columns × 9 rows of 192×208 frames, one row per
* animation state (idle, run right, run left, waving, jumping, failed,
* waiting, running, review) — played one row at a time by Sagi's mood; an
* a spritesheet of 192×208 frames, 8 columns wide: 9 rows (v1, 1536×1872),
* one per animation state (idle, run right, run left, waving, jumping, failed,
* waiting, running, review), or 11 (v2, 1536×2288), whose rows 9–10 are 16
* gaze poses. The sprite plays the row of Sagi's mood at Codex's own frame
* timings; a moment (waving, jumping, failed, review) plays three times and
* settles into idle, a condition loops. An `action` (dragged left/right,
* hovered) plays the run-left/run-right or waving row over the mood's, like
* Codex's own pet, and a v2 pet at idle holds the gaze pose `gaze` names. An
* uploaded image is shown still and bobs with the same `data-mood` CSS as
* Sagi. An `action` (dragged left/right, hovered) plays the run-left/run-right
* or waving row over the mood's, like Codex's own pet. Each row steps at its
* own pace and holds its last frame longer, so a loop breathes instead of
* spinning. Reduced motion holds the row's first frame. A sprite that fails to
* Sagi. Reduced motion holds the row's first frame. A sprite that fails to
* load (deleted from another browser) calls `onBroken`, so the mascot falls
* back to Sagi instead of an empty box.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -19,9 +21,29 @@ import { api } from "../../lib/api";
import type { Mood } from "./brain";
const CELL = { w: 192, h: 208 };
const SHEET = { cols: 8, rows: 9 };
/** Frames in each row of the Codex atlas. */
const FRAMES = [6, 8, 8, 4, 5, 8, 6, 6, 6];
const COLS = 8;
/** `n` frames of `step` ms, the last one `last` ms. */
const frames = (n: number, step: number, last: number) =>
Array.from({ length: n }, (_, i) => (i === n - 1 ? last : step));
/** Per row, each frame's ms: Codex's own table (codex-rs/tui/src/pets/model.rs).
* Idle is the pace the pets are drawn at; Codex plays it 6× slower, which is
* how a sleeping pet breathes here. */
const DURATIONS = [
[280, 110, 110, 140, 140, 320], // idle
frames(8, 120, 220), // run right
frames(8, 120, 220), // run left
frames(4, 140, 280), // waving
frames(5, 140, 280), // jumping
frames(8, 140, 240), // failed
frames(6, 150, 260), // waiting
frames(6, 120, 220), // running (working)
frames(6, 150, 280), // review
];
const SLEEP_SLOWDOWN = 6;
/** Rows that play three times and settle into idle; the others loop. */
const MOMENTS = new Set([3, 4, 5, 8]);
/** v2 only: 16 gaze poses, 22.5° apart clockwise from straight up. */
const GAZE_ROW = 9;
/** Which row plays for each mood. */
const ROW: Record<Mood, number> = {
idle: 0,
@@ -36,25 +58,13 @@ const ROW: Record<Mood, number> = {
/** What the user is doing to the pet, which outranks its mood. */
export type PetAction = "run-left" | "run-right" | "wave";
const ACTION_ROW: Record<PetAction, number> = { "run-right": 1, "run-left": 2, wave: 3 };
/** Per row: ms per frame, and ms the last frame is held before the loop restarts. */
const PACE: [number, number][] = [
[220, 1400], // idle: a breath, then a pause
[120, 120], // run right — keeps up with the drag
[120, 120], // run left
[180, 400], // waving
[160, 700], // jumping
[200, 1200], // failed
[220, 900], // waiting
[170, 400], // running (working)
[220, 900], // review
];
const SLEEP_PACE: [number, number] = [450, 2000];
export function PetSprite({
id,
kind,
mood,
action,
gaze,
size,
reducedMotion,
onBroken,
@@ -63,6 +73,8 @@ export function PetSprite({
kind: "codex" | "image";
mood: Mood;
action?: PetAction | null;
/** Gaze pose 0–15 (0 = up, clockwise), or none. Only a v2 sheet has them. */
gaze?: number | null;
size: number;
reducedMotion: boolean;
onBroken: () => void;
@@ -70,24 +82,30 @@ export function PetSprite({
const url = api.pets.spriteUrl(id);
const row = action ? ACTION_ROW[action] : (ROW[mood] ?? 0);
const sleeping = !action && mood === "sleeping";
const [frame, setFrame] = useState(0);
// The cell on show, tagged with the row it was played for: a cell left over
// from the previous row (say column 7 of a run) is never drawn on the next.
const [cell, setCell] = useState({ of: row, row, col: 0 });
const [rows, setRows] = useState(9);
useEffect(() => {
setFrame(0);
setCell({ of: row, row, col: 0 });
if (kind !== "codex" || reducedMotion) return;
const count = FRAMES[row] ?? 1;
const [step, hold] = sleeping ? SLEEP_PACE : (PACE[row] ?? [200, 200]);
let f = 0;
const play = (r: number, slow = 1) =>
(DURATIONS[r] ?? [200]).map((ms, col) => ({ of: row, row: r, col, ms: ms * slow }));
const own = play(row, sleeping ? SLEEP_SLOWDOWN : 1);
let list = MOMENTS.has(row) ? [...own, ...own, ...own] : own;
let i = 0;
let timer = 0;
// `i` always indexes `list`: it wraps at its end.
const tick = () => {
timer = window.setTimeout(
() => {
f = (f + 1) % count;
setFrame(f);
tick();
},
f === count - 1 ? hold : step
);
timer = window.setTimeout(() => {
if (++i === list.length) {
i = 0;
if (MOMENTS.has(row)) list = play(0);
}
setCell(list[i]!);
tick();
}, list[i]!.ms);
};
tick();
return () => window.clearTimeout(timer);
@@ -96,10 +114,13 @@ export function PetSprite({
// A background image reports no error of its own: probe it once.
useEffect(() => {
if (kind !== "codex") return;
// It also tells a v2 sheet (11 rows) from a v1 (9).
const probe = new Image();
probe.onload = () => setRows(Math.round(probe.naturalHeight / CELL.h) || 9);
probe.onerror = onBroken;
probe.src = url;
return () => {
probe.onload = null;
probe.onerror = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -120,11 +141,18 @@ export function PetSprite({
/>
);
}
const pose = gaze != null && rows > GAZE_ROW && !action && mood === "idle" ? gaze : null;
const shown =
pose != null
? { row: GAZE_ROW + Math.floor(pose / COLS), col: pose % COLS }
: cell.of === row
? cell
: { row, col: 0 };
const scale = size / CELL.h;
return (
<div
data-testid="pet-sprite"
data-row={row}
data-row={shown.row}
aria-hidden
style={{
width: Math.round(CELL.w * scale),
@@ -132,8 +160,8 @@ export function PetSprite({
margin: "0 auto",
backgroundImage: `url("${url}")`,
backgroundRepeat: "no-repeat",
backgroundSize: `${SHEET.cols * CELL.w * scale}px ${SHEET.rows * CELL.h * scale}px`,
backgroundPosition: `${-frame * CELL.w * scale}px ${-row * CELL.h * scale}px`,
backgroundSize: `${COLS * CELL.w * scale}px ${rows * CELL.h * scale}px`,
backgroundPosition: `${-shown.col * CELL.w * scale}px ${-shown.row * CELL.h * scale}px`,
}}
/>
);
+30 -1
View File
@@ -8,7 +8,8 @@
* Owns the open/closed panel state, the ⌘B / Esc shortcuts, reduced-motion
* detection, and route navigation. Reactive personality + status/Ask come
* from useSagiBrain; the avatar is draggable (AssistiveTouch-style) via
* useSagiPosition, and the bubble/panel render in a self-clamping flyout so
* useSagiPosition (a v2 Codex pet also looks toward the pointer), and the
* bubble/panel render in a self-clamping flyout so
* they never spill off any screen edge regardless of where the mascot is docked.
*
* The "do the job" path: an unmatched Ask query starts a claude session in
@@ -244,6 +245,32 @@ function SagiBody() {
return () => window.removeEventListener("keydown", onKey);
}, []);
// A v2 pet at idle looks toward the pointer: one of 16 poses by the
// pointer's angle around it (0 = up, clockwise), dropped once it rests.
// ponytail: rest after 2 s is a guess; Codex Desktop's own rule is not public.
const btn = useRef<HTMLButtonElement>(null);
const [gaze, setGaze] = useState<number | null>(null);
const wearsSheet = "kind" in pet && pet.kind === "codex";
useEffect(() => {
if (!wearsSheet || reducedMotion) return;
let rest = 0;
const onMove = (e: PointerEvent) => {
const r = btn.current?.getBoundingClientRect();
if (!r) return;
const deg =
(Math.atan2(e.clientX - (r.left + r.width / 2), r.top + r.height / 2 - e.clientY) * 180) /
Math.PI;
setGaze(Math.round((deg + 360) / 22.5) % 16);
window.clearTimeout(rest);
rest = window.setTimeout(() => setGaze(null), 2000);
};
window.addEventListener("pointermove", onMove);
return () => {
window.removeEventListener("pointermove", onMove);
window.clearTimeout(rest);
};
}, [wearsSheet, reducedMotion]);
const onNavigate = useCallback(
(route: string) => {
navigate(route);
@@ -334,6 +361,7 @@ function SagiBody() {
)}
<button
ref={btn}
className="sagi-avatar-btn"
data-dragging={place.dragging ? "1" : "0"}
style={{ left: place.left, top: place.top, width: size, height: size }}
@@ -368,6 +396,7 @@ function SagiBody() {
mood={brain.mood}
// Dragged → runs that way; hovered → waves (Codex's pet does both).
action={place.moving ? `run-${place.moving}` : hover ? "wave" : null}
gaze={gaze}
size={size}
reducedMotion={reducedMotion}
// Deleted elsewhere, or unreadable: back to Sagi.
@@ -93,9 +93,9 @@ describe("PetSprite", () => {
// 104 px tall = half a 208 px cell: row 7 (running) starts 7 × 104 px down.
expect(sprite.dataset.row).toBe("7");
expect(sprite.style.backgroundPosition).toBe("0px -728px");
act(() => vi.advanceTimersByTime(130));
expect(sprite.style.backgroundPosition).toBe("0px -728px"); // slower than 125 ms
act(() => vi.advanceTimersByTime(50));
act(() => vi.advanceTimersByTime(110));
expect(sprite.style.backgroundPosition).toBe("0px -728px"); // Codex: 120 ms a frame
act(() => vi.advanceTimersByTime(10));
expect(sprite.style.backgroundPosition).toBe("-96px -728px");
rerender(
<PetSprite
@@ -129,7 +129,7 @@ describe("PetSprite", () => {
expect(screen.getByTestId("pet-sprite").dataset.row).toBe("7");
});
it("holds a loop's last frame before starting over", () => {
it("plays idle at the drawn pace, the last frame barely longer than the rest", () => {
render(
<PetSprite
id="cp-goku"
@@ -141,14 +141,67 @@ describe("PetSprite", () => {
/>
);
const sprite = screen.getByTestId("pet-sprite");
act(() => vi.advanceTimersByTime(5 * 220)); // idle: 6 frames → on the last one
act(() => vi.advanceTimersByTime(280 + 110 + 110 + 140 + 140)); // → the 6th, last frame
expect(sprite.style.backgroundPosition).toBe("-960px 0px");
act(() => vi.advanceTimersByTime(1000));
act(() => vi.advanceTimersByTime(310));
expect(sprite.style.backgroundPosition).toBe("-960px 0px");
act(() => vi.advanceTimersByTime(400));
act(() => vi.advanceTimersByTime(10)); // 320 ms, not a long hold
expect(sprite.style.backgroundPosition).toBe("0px 0px");
});
it("plays a moment three times, then settles into idle; a condition keeps looping", () => {
const props = { id: "cp-goku", kind: "codex" as const, size: 208, reducedMotion: false };
const { rerender } = render(<PetSprite {...props} mood="happy" onBroken={() => {}} />);
const sprite = screen.getByTestId("pet-sprite");
expect(sprite.dataset.row).toBe("4"); // jumping: 5 frames, 4 × 140 + 280 ms
act(() => vi.advanceTimersByTime(3 * 840 - 1));
expect(sprite.dataset.row).toBe("4");
act(() => vi.advanceTimersByTime(1));
expect(sprite.dataset.row).toBe("0");
rerender(<PetSprite {...props} mood="thinking" onBroken={() => {}} />);
act(() => vi.advanceTimersByTime(10_000));
expect(sprite.dataset.row).toBe("7");
});
it("looks toward the pointer on a v2 sheet, only while idle", () => {
// A v2 atlas is 11 rows tall; the sprite learns that from the loaded image.
vi.stubGlobal(
"Image",
class {
naturalHeight = 2288;
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
set src(_: string) {
this.onload?.();
}
}
);
const props = { id: "cp-goku", kind: "codex" as const, size: 208, reducedMotion: false };
const { rerender } = render(<PetSprite {...props} mood="idle" gaze={12} onBroken={() => {}} />);
const sprite = screen.getByTestId("pet-sprite");
// Pose 12 (270°, left) = row 10, column 4.
expect(sprite.style.backgroundPosition).toBe("-768px -2080px");
expect(sprite.style.backgroundSize).toBe("1536px 2288px");
rerender(<PetSprite {...props} mood="thinking" gaze={12} onBroken={() => {}} />);
expect(sprite.dataset.row).toBe("7");
vi.unstubAllGlobals();
});
it("ignores the pointer on a v1 sheet, which has no gaze rows", () => {
render(
<PetSprite
id="cp-goku"
kind="codex"
mood="idle"
gaze={4}
size={208}
reducedMotion={false}
onBroken={() => {}}
/>
);
expect(screen.getByTestId("pet-sprite").style.backgroundPosition).toBe("0px 0px");
});
it("holds the first frame under reduced motion", () => {
render(
<PetSprite
+8 -1
View File
@@ -70,6 +70,7 @@ import {
ChevronDown,
FolderTree,
LogOut,
UsersRound,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { api } from "../lib/api";
@@ -464,7 +465,13 @@ export function Sidebar({
user knows there's more to reach without inspecting the scrollbar. */}
<div className="flex-1 min-h-0 relative flex">
<nav ref={navRef} className="flex-1 overflow-y-auto overflow-x-hidden px-2 py-2 space-y-px">
{NAV_KEYS.map(({ to, icon: Icon, key }) => {
{[
...NAV_KEYS,
// Server mode: the Users page, for an admin or analyst.
...(auth.state.mode === "server" && auth.state.user && auth.state.user.role !== "member"
? [{ to: "/users", icon: UsersRound, key: "nav:users" }]
: []),
].map(({ to, icon: Icon, key }) => {
const label = t(key);
return (
<NavLink
+25 -3
View File
@@ -35,7 +35,15 @@ import { LIVE_RUNS_EVENT, selectedFrom } from "./WorkspaceTree";
const COST_POLL_MS = 30_000;
const QUOTA_POLL_MS = 5 * 60_000;
const QUOTA_TOOL_LABEL: Record<ToolQuota["id"], string> = { claude: "Claude", codex: "Codex" };
const QUOTA_TOOL_LABEL: Record<ToolQuota["id"], string> = {
claude: "Claude",
codex: "Codex",
antigravity: "Antigravity",
cursor: "Cursor",
grok: "Grok",
kimi: "Kimi",
opencode: "OpenCode Go",
};
function localDate(d = new Date()): string {
const pad = (n: number) => String(n).padStart(2, "0");
@@ -123,6 +131,7 @@ const QUOTA_ERROR_KEY: Record<string, string> = {
ELIVEEXPIRED: "statusBar.quotaExpired",
EPROFILEEXPIRED: "statusBar.quotaProfileExpired",
EWRONGACCOUNT: "statusBar.quotaWrongAccount",
ETOKENEXPIRED: "statusBar.quotaTokenExpired",
};
/** One account: its name, sign-in line, then a row per window with its reset. */
@@ -133,6 +142,14 @@ function QuotaAccount({ q, many }: { q: ToolQuota; many: boolean }) {
<div data-testid="status-bar-quota-account" className="min-w-0">
<div className="flex items-center gap-1.5">
<span className="truncate font-medium">{q.label || q.account}</span>
{q.plan && (
<span
data-testid="status-bar-quota-plan"
className="shrink-0 rounded bg-surface-4 px-1 text-[10px] capitalize leading-4 text-fg-secondary"
>
{q.plan}
</span>
)}
{many && q.active !== false && (
<span className="shrink-0 rounded bg-accent-solid/15 px-1 text-[10px] leading-4 text-accent">
{t("statusBar.quotaActive")}
@@ -146,11 +163,16 @@ function QuotaAccount({ q, many }: { q: ToolQuota; many: boolean }) {
</div>
) : (
<div className="mt-1 grid grid-cols-[2.75rem_1fr_2.25rem_auto] items-center gap-x-2 gap-y-0.5 tabular-nums">
{q.windows.map((w) => {
{q.windows.map((w, i) => {
const p = Math.round(w.usedPercent ?? 0);
const resets = untilText(w.resetsAt);
return (
<Fragment key={w.label}>
<Fragment key={`${w.group ?? ""}-${w.label}`}>
{w.group && w.group !== q.windows[i - 1]?.group && (
<span className="col-span-4 mt-0.5 truncate text-[10px] text-fg-muted">
{w.group}
</span>
)}
<span className="text-fg-muted">
{t(`statusBar.quotaWindow.${w.label}`, { defaultValue: w.label })}
</span>
@@ -24,6 +24,7 @@ function today() {
vi.mock("../../lib/api", () => ({
api: {
aiTools: { iconUrl: (id: string) => `/api/ai-tools/${id}/icon` },
sessions: {
// 3 active sessions, 1 of them waiting on the user.
list: vi.fn().mockImplementation(async (p: { awaiting?: boolean }) => ({
@@ -76,6 +77,7 @@ vi.mock("../../lib/api", () => ({
active: true,
label: "Work",
account: "me@example.com",
plan: "max",
expiresAt: new Date(Date.now() + 3 * 3600_000 + 30_000).toISOString(),
windows: [
{
@@ -102,6 +104,22 @@ vi.mock("../../lib/api", () => ({
windows: [],
error: "EUPSTREAM",
},
{
id: "antigravity",
active: true,
windows: [
{ group: "Gemini Models", label: "5h", usedPercent: 0, resetsAt: null },
{ group: "Gemini Models", label: "wk", usedPercent: 25, resetsAt: null },
{ group: "Claude and GPT models", label: "5h", usedPercent: 50, resetsAt: null },
],
},
{
id: "cursor",
active: true,
account: "c@example.com",
plan: "pro",
windows: [{ label: "mo", usedPercent: 40, resetsAt: null }],
},
],
checkedAt: "",
}),
@@ -214,8 +232,8 @@ describe("StatusBar", () => {
expect(screen.getByText("95% used")).toHaveClass("text-status-danger");
await user.hover(chips);
const panel = screen.getByTestId("status-bar-quota-panel");
const [work, personal, codex] = screen.getAllByTestId("status-bar-quota-account");
expect(work).toHaveTextContent("Workin use");
const [work, personal, codex, agy, cursor] = screen.getAllByTestId("status-bar-quota-account");
expect(work).toHaveTextContent("Workmaxin use");
expect(work).toHaveTextContent("me@example.com · Sign-in expires in 3h 0m");
expect(work).toHaveTextContent("5h95%Resets in 41m");
expect(work).toHaveTextContent("Week32%");
@@ -225,6 +243,11 @@ describe("StatusBar", () => {
expect(codex).toHaveTextContent("dev@example.com");
expect(codex).toHaveTextContent("Sign-in expired");
expect(panel).toHaveTextContent("Could not read the quota");
expect(agy).toHaveTextContent("Gemini Models5h0%Week25%Claude and GPT models5h50%");
expect(chips).toHaveTextContent("Antigravity50% used");
expect(cursor).toHaveTextContent("c@example.compro");
expect(cursor).toHaveTextContent("Month40%");
expect(chips).toHaveTextContent("Cursor40% used");
// Moving onto the panel keeps it open, so it can be read; leaving it closes it.
await user.unhover(chips);
await user.hover(panel);
@@ -307,6 +307,23 @@ describe("WorkspaceTree", () => {
expect(api.lanes.action).not.toHaveBeenCalled();
});
it("lists live sessions above ended ones, even an ended one active more recently", async () => {
vi.mocked(api.sessions.list).mockImplementation(async () => ({
sessions: [session("s-old", "Old one"), session("s-live", "Live one")],
total: 2,
limit: 20,
offset: 0,
}));
const user = userEvent.setup();
renderTree();
await user.click(await screen.findByTestId("workspace-toggle-1"));
await waitFor(() =>
expect(screen.getByTestId("workspace-session-s-live")).toHaveAttribute("data-live", "true")
);
const ids = screen.getAllByTestId(/^workspace-session-s-/).map((el) => el.dataset.testid);
expect(ids).toEqual(["workspace-session-s-live", "workspace-session-s-old"]);
});
it("shows a live tab whose session has no row yet as a New session, and a shell as Terminal", async () => {
vi.mocked(api.run.list).mockImplementation(
async () =>
+186 -17
View File
@@ -13,34 +13,54 @@
* text), its edit in a stronger blue — the same scheme as the read-only diff in
* LaneDiffView. Syntax colours come from the dashboard's own per-line
* tokenizer (`lib/highlight.ts`), like every other code view here.
* Given `onBlock`, each changed block also gets the read-only diff's Undo /
* Keep bar under its last line (Undo asks twice) in place of its Revert; the
* panel turns it off while the editor holds unsaved text, because git acts on
* the file on disk.
* Given `onBlock`, each changed block also gets the read-only diff's bar under
* its last line — Undo (asks twice) and the block's commit checkbox
* (`blockIncluded`) — in place of its Revert; the panel turns it off while
* the editor holds unsaved text, because git acts on the file on disk.
* Given `blame`, a gutter left of the line numbers names each line's last
* commit (Annotate); a click hands its sha to `onBlameClick`. `navRef` gets
* a function that moves the cursor to the next / previous changed block.
* Given `onAddToChat`, a selection in the editable file gets Cursor's Add to
* Chat chip above it, which hands the panel the selection's 1-based lines.
* Lazy-loaded by LaneChangesPanel, which keys it by file and shape:
* CodeMirror stays out of the main bundle, and one mount is one view.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { EditorState, RangeSetBuilder, StateEffect, StateField, Text } from "@codemirror/state";
import {
Compartment,
EditorSelection,
EditorState,
RangeSetBuilder,
StateEffect,
StateField,
Text,
} from "@codemirror/state";
import {
Decoration,
EditorView,
ViewPlugin,
WidgetType,
drawSelection,
gutter,
GutterMarker,
highlightActiveLine,
highlightActiveLineGutter,
keymap,
lineNumbers,
showTooltip,
type DecorationSet,
type Tooltip,
type ViewUpdate,
} from "@codemirror/view";
import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands";
import { unifiedMergeView, type Chunk } from "@codemirror/merge";
import { chunksOf, splitDiff } from "./splitDiff";
import { highlight, tokenClass } from "../../lib/highlight";
import type { LaneBlame } from "../../lib/types";
type Blame = Extract<LaneBlame, { available: true }>;
/**
* True when a letter or digit of a line (`text`, starting at document offset
@@ -76,7 +96,7 @@ function decorate(view: EditorView, lang: string): DecorationSet {
const doc = view.state.doc;
let last = 0;
for (const { from, to } of view.visibleRanges) {
for (let pos = from; pos <= to; ) {
for (let pos = from; pos <= to;) {
const line = doc.lineAt(pos);
pos = line.to + 1;
if (line.number <= last) continue;
@@ -151,7 +171,52 @@ const diffColours = (lang: string) =>
/** A changed block by line (1-based; a count of 0 is a pure insertion or
* removal): what the panel matches against git's own change blocks. */
export type EditorBlock = { oldFrom: number; oldCount: number; newFrom: number; newCount: number };
export type BlockAction = "keep" | "undo";
export type BlockAction = "include" | "undo";
/** One Annotate cell: the line's commit date and author, its subject on hover. */
class BlameMarker extends GutterMarker {
constructor(
readonly text: string,
readonly title: string
) {
super();
}
override eq(other: BlameMarker) {
return other.text === this.text && other.title === this.title;
}
override toDOM() {
const el = document.createElement("span");
el.textContent = this.text;
el.title = this.title;
return el;
}
}
/** The Annotate gutter for the editable file's lines (the B side). ponytail:
* lines are matched by number, so unsaved edits above a line shift its
* annotation until the file is saved and re-annotated. */
function blameGutter(blame: Blame, notCommitted: string, onClick: (sha: string) => void) {
const shaAt = (state: EditorState, pos: number) => blame.lines[state.doc.lineAt(pos).number - 1];
return gutter({
class: "cm-ah-blame",
lineMarker(view, line) {
const sha = shaAt(view.state, line.from);
if (sha === undefined) return null;
const c = sha ? blame.commits[sha] : undefined;
return c
? new BlameMarker(`${c.date.slice(0, 10)} ${c.author}`, `${c.short} ${c.summary}`)
: new BlameMarker(notCommitted, notCommitted);
},
initialSpacer: () => new BlameMarker("0000-00-00 Author Name", ""),
domEventHandlers: {
mousedown(view, line) {
const sha = shaAt(view.state, line.from);
if (sha) onClick(sha);
return !!sha;
},
},
});
}
/** A chunk side's first line and line count. `to` is the start of the line
* after the chunk (or the end + 1), so its last line holds `to - 1`. */
@@ -220,6 +285,26 @@ function blockBars(orig: Text, build: (b: EditorBlock) => HTMLElement) {
});
}
/** Add to Chat above a non-empty selection; `build` makes its DOM from the
* selection's first and last 1-based line. A selection ending at a line's
* start (a whole-line drag) does not take that line. */
function chatChip(build: (start: number, end: number) => HTMLElement) {
return StateField.define<Tooltip | null>({
create: () => null,
update(tip, tr) {
if (!tr.docChanged && !tr.selection) return tip;
const { from, to, empty } = tr.state.selection.main;
if (empty) return null;
const doc = tr.state.doc;
const start = doc.lineAt(from).number;
const last = doc.lineAt(to);
const end = to === last.from && last.number > start ? last.number - 1 : last.number;
return { pos: from, above: true, create: () => ({ dom: build(start, end) }) };
},
provide: (f) => showTooltip.from(f),
});
}
const CHIP = "flex shrink-0 items-center gap-0.5 rounded border px-1 leading-[14px] cursor-pointer";
const UNDO =
"border-border-light text-fg-secondary hover:border-status-danger/60 hover:text-status-danger";
@@ -234,6 +319,8 @@ const BLUE_EDIT = "rgb(var(--diff-mod) / 0.45)";
const M = "&.cm-merge-b.cm-merge-b";
const A = "&.cm-merge-a.cm-merge-a";
const theme = EditorView.theme({
// The chip draws its own box; drop its wrapper's default tooltip one.
".cm-tooltip.cm-ah-chat": { border: "none", background: "transparent" },
"&": {
height: "100%",
fontSize: "11px",
@@ -278,6 +365,13 @@ const theme = EditorView.theme({
background: "rgb(var(--surface-2))",
},
".cm-chunkButtons": { zIndex: "1" },
".cm-ah-blame .cm-gutterElement": {
color: "rgb(var(--fg-muted))",
cursor: "pointer",
fontSize: "10px",
paddingRight: "6px",
whiteSpace: "nowrap",
},
});
/** The Revert button on a changed block (unified); @codemirror/merge wires
@@ -304,6 +398,11 @@ export default function DiffEditor({
onChange,
onSave,
onBlock,
blockIncluded,
blame,
onBlameClick,
navRef,
onAddToChat,
}: {
value: string;
/** The text the diff is taken against; read once, at mount. */
@@ -318,14 +417,24 @@ export default function DiffEditor({
revertLabel: string;
onChange: (text: string) => void;
onSave: () => void;
/** Keep / Undo one changed block in git; absent = no bars. */
/** Undo one changed block in git, or flip its commit checkbox; absent = no bars. */
onBlock?: (action: BlockAction, block: EditorBlock) => void;
/** Whether a block is ticked for the commit (its checkbox's state). */
blockIncluded?: (block: EditorBlock) => boolean;
/** Annotate: each line's last commit; absent = no gutter. */
blame?: Blame;
onBlameClick?: (sha: string) => void;
/** Set to "go to the next (1) / previous (-1) changed block". */
navRef?: { current: ((dir: 1 | -1) => void) | null };
/** Add the selected lines (1-based, inclusive) to the chat; absent = no chip. */
onAddToChat?: (start: number, end: number) => void;
}) {
const { t } = useTranslation("lanes");
const host = useRef<HTMLDivElement>(null);
const view = useRef<EditorView | null>(null);
const cb = useRef({ onChange, onSave, onBlock, t });
cb.current = { onChange, onSave, onBlock, t };
const cb = useRef({ onChange, onSave, onBlock, blockIncluded, onBlameClick, onAddToChat, t });
cb.current = { onChange, onSave, onBlock, blockIncluded, onBlameClick, onAddToChat, t };
const blameSlot = useRef(new Compartment()).current;
/** The diff view's review bar, as DOM: pinned to the visible right edge,
* zero height so it never pushes lines (or a split view's alignment). */
@@ -349,11 +458,13 @@ export default function DiffEditor({
return b;
};
const undo = chip(tr("changes.undoHunkShort"), tr("changes.undoHunk"), UNDO);
const keep = chip(
tr("changes.stageHunkShort"),
tr("changes.stageHunk"),
"border-status-success bg-status-success/25 text-status-success hover:bg-status-success/35"
);
// The block's commit checkbox, a real one so it reads like the diff's.
const include = bar.appendChild(document.createElement("input"));
include.type = "checkbox";
include.checked = cb.current.blockIncluded?.(block) ?? true;
include.title = tr("changes.includeBlock");
include.setAttribute("aria-label", tr("changes.includeBlock"));
include.className = "h-3 w-3 cursor-pointer";
// Two clicks, like every undo git cannot give back; disarms after 3 s.
let timer = 0;
const arm = (on: boolean) => {
@@ -374,7 +485,7 @@ export default function DiffEditor({
arm(true);
timer = window.setTimeout(() => arm(false), 3000);
};
keep.onclick = () => cb.current.onBlock?.("keep", block);
include.onchange = () => cb.current.onBlock?.("include", block);
return wrap;
};
@@ -405,6 +516,28 @@ export default function DiffEditor({
if (u.docChanged) cb.current.onChange(u.state.doc.toString());
}),
blockBars(Text.of(original.split("\n")), buildBar),
blameSlot.of([]),
...(onAddToChat
? [
chatChip((start, end) => {
const b = document.createElement("button");
b.type = "button";
b.dataset.testid = "lane-editor-add-to-chat";
b.textContent = cb.current.t("changes.addToChat");
b.className =
"rounded border border-border-light bg-surface-1 px-1.5 py-0.5 font-sans text-[11px] text-fg-primary shadow-card hover:bg-surface-2";
// Keep the editor's focus (and so its selection) through the click.
b.onmousedown = (e) => e.preventDefault();
b.onclick = () => cb.current.onAddToChat?.(start, end);
// CodeMirror makes the returned element the `.cm-tooltip`, whose
// themed rule above would strip the button's own border and fill.
const wrap = document.createElement("div");
wrap.className = "cm-ah-chat";
wrap.append(b);
return wrap;
}),
]
: []),
];
let v: EditorView;
let destroy: () => void;
@@ -446,11 +579,47 @@ export default function DiffEditor({
}
v.focus();
view.current = v;
return destroy;
if (navRef) {
// The B side's chunks, unified or split: the cursor goes to the start
// of the next / previous one and it scrolls into view.
navRef.current = (dir) => {
const ed = view.current;
const chunks = ed ? (chunksOf(ed.state)?.chunks ?? []) : [];
if (!ed || !chunks.length) return;
const at = ed.state.selection.main.head;
const next =
dir === 1
? chunks.find((c) => c.fromB > at)
: [...chunks].reverse().find((c) => c.fromB < ed.state.doc.lineAt(at).from);
if (!next) return;
ed.dispatch({
selection: EditorSelection.cursor(next.fromB),
effects: EditorView.scrollIntoView(next.fromB, { y: "center" }),
});
ed.focus();
};
}
return () => {
if (navRef) navRef.current = null;
destroy();
};
// One mount per view: the parent keys this component by file and shape.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Annotate on / off without remounting the view.
useEffect(() => {
view.current?.dispatch({
effects: blameSlot.reconfigure(
blame
? blameGutter(blame, cb.current.t("changes.notCommitted"), (sha) =>
cb.current.onBlameClick?.(sha)
)
: []
),
});
}, [blame, blameSlot]);
const bars = !!onBlock;
useEffect(() => {
view.current?.dispatch({ effects: barsOn.of(bars) });
File diff suppressed because it is too large Load Diff
+8 -6
View File
@@ -247,7 +247,7 @@ export function analyseHunk(hunk: LaneDiffHunk): Analysed {
if (hit) return hit;
const lines = hunk.lines;
const emph = new Map<number, [number, number]>();
for (let i = 0; i < lines.length; ) {
for (let i = 0; i < lines.length;) {
if (lines[i]!.type === "ctx") {
i += 1;
continue;
@@ -440,7 +440,8 @@ const startAtRow = (hunk: LaneDiffHunk, i: number) =>
* block's last line. A context rather than a prop because lines render from
* four places; `changes` is what the bar's "n of N" and its arrows walk. */
const HunkActions = createContext<{
actions: (change: LaneDiffChange) => ReactNode;
/** null = no bar; the anchors still mark each block for navigation. */
actions: ((change: LaneDiffChange) => ReactNode) | null;
changes: LaneDiffChange[];
go: (index: number) => void;
} | null>(null);
@@ -457,7 +458,7 @@ function useAnchor(change?: LaneDiffChange) {
function HunkBar({ change }: { change: LaneDiffChange }) {
const { t } = useTranslation(["lanes"]);
const ctx = useContext(HunkActions);
if (!ctx) return null;
if (!ctx?.actions) return null;
const index = ctx.changes.indexOf(change);
const total = ctx.changes.length;
const nav =
@@ -700,8 +701,7 @@ export function clampScroll(v: number, scrollSize: number, clientSize: number) {
}
type Axis =
| ["scrollLeft", "scrollWidth", "clientWidth"]
| ["scrollTop", "scrollHeight", "clientHeight"];
["scrollLeft", "scrollWidth", "clientWidth"] | ["scrollTop", "scrollHeight", "clientHeight"];
const AXES: Axis[] = [
["scrollLeft", "scrollWidth", "clientWidth"],
["scrollTop", "scrollHeight", "clientHeight"],
@@ -931,7 +931,9 @@ export function LaneDiffView({
const at = items.findIndex((it) => it.hunk === target.hunk && it.i === target.firstLine);
if (at >= 0 && layout === "unified") el.scrollTop = at * ROW_PX;
};
const review = hunkActions && hunks ? { actions: hunkActions, changes, go } : null;
// Provided without actions too: every block's first row carries its
// `data-change-index`, which the panel's F7 / Shift+F7 walk in any range.
const review = hunks ? { actions: hunkActions ?? null, changes, go } : null;
if (file.binary)
return <p className="px-2 py-1 text-[10px] text-fg-muted">{t("changes.binary")}</p>;
@@ -1,7 +1,8 @@
/**
* @file WorkspaceSessionList — the sessions under an expanded workspace in the
* sidebar: the ones the workspace OWNS and has not archived
* (`GET /api/sessions?lane=<id>&archived=0`, newest first) — never the other
* (`GET /api/sessions?lane=<id>&archived=0`, newest first, the ones with a live
* pane above the ended ones) — never the other
* sessions that ran in its folder. A row's dot
* shows its state (`sessionDot`: live pane, needs you, error) and its right
* edge the AI tool the session ran on (`sessions.tool`); double-click
@@ -277,10 +278,12 @@ export function WorkspaceSessionList({
const more = own ? Math.max(0, own.total - own.sessions.length) : 0;
const q = query.trim().toLowerCase();
const shown = (sessions ?? []).filter(
(s) => !q || (s.name ?? "").toLowerCase().includes(q) || s.id.startsWith(q)
);
const runFor = (s: Session) => liveRunFor(lane, liveRuns, s.id);
// Live sessions first, ended ones under them — each group stays newest
// first (the sort is stable over the server's order).
const shown = (sessions ?? [])
.filter((s) => !q || (s.name ?? "").toLowerCase().includes(q) || s.id.startsWith(q))
.sort((a, b) => Number(!!runFor(b)) - Number(!!runFor(a)));
// A live tab whose session has no row yet: shown as a New session at the
// top, like Claude Desktop, until its first hook brings the real row. Not
// during a search — it has no name to match.
@@ -1,11 +1,13 @@
/**
* @file Tests for the Changes panel's CodeMirror editor: which changed lines
* count as edited in place (blue) rather than added or removed whole, and the
* editor's own diff classes on a real mount, and the per-block Undo / Keep bar.
* editor's own diff classes on a real mount, the per-block Undo / Keep bar,
* and the Add to Chat chip on a selection.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi } from "vitest";
import { fireEvent, render, waitFor } from "@testing-library/react";
import { EditorView } from "@codemirror/view";
import DiffEditor, { keepsText } from "../DiffEditor";
// jsdom lays nothing out; CodeMirror's measuring asks a Range for its boxes.
@@ -166,7 +168,39 @@ describe("DiffEditor", () => {
expect(buttons.filter((b) => !b.hidden).map((b) => b.textContent)).toEqual(["Revert"]);
});
it("puts an Undo / Keep bar under each block, by line, in place of Revert", async () => {
it("annotates each line in a gutter, and hands a click its commit", async () => {
const onBlameClick = vi.fn();
const props = {
value: "a\nnew",
original: "a",
lang: "plain",
label: "Edit",
revertLabel: "Revert",
onChange: vi.fn(),
onSave: vi.fn(),
onBlameClick,
};
const sha = "f".repeat(40);
const blame = {
available: true as const,
path: "x",
commits: {
[sha]: { short: "fffffff", author: "Vi", date: "2026-10-01T00:00:00Z", summary: "s" },
},
lines: [sha, null],
};
const { container, rerender } = render(<DiffEditor {...props} />);
expect(container.querySelector(".cm-ah-blame")).toBeNull();
rerender(<DiffEditor {...props} blame={blame} />);
await waitFor(() => expect(container.querySelector(".cm-ah-blame")).not.toBeNull());
// The first element is CodeMirror's invisible width spacer.
const cells = [...container.querySelectorAll(".cm-ah-blame .cm-gutterElement span")].slice(1);
expect(cells.map((c) => c.textContent)).toEqual(["2026-10-01 Vi", "Not committed yet"]);
fireEvent.mouseDown(cells[0]!.parentElement!);
expect(onBlameClick).toHaveBeenCalledWith(sha);
});
it("puts an Undo + commit-checkbox bar under each block, by line, in place of Revert", async () => {
const onBlock = vi.fn();
const props = {
value: "a\nB\nc\nnew\ne",
@@ -177,7 +211,9 @@ describe("DiffEditor", () => {
onChange: vi.fn(),
onSave: vi.fn(),
};
const { container, rerender } = render(<DiffEditor {...props} onBlock={onBlock} />);
const { container, rerender } = render(
<DiffEditor {...props} onBlock={onBlock} blockIncluded={(b) => b.oldFrom === 2} />
);
await waitFor(() =>
expect(container.querySelectorAll('[data-testid="lane-editor-block-bar"]')).toHaveLength(2)
);
@@ -188,8 +224,10 @@ describe("DiffEditor", () => {
const [first, second] = [
...container.querySelectorAll<HTMLElement>('[data-testid="lane-editor-block-bar"]'),
];
fireEvent.click(second!.querySelectorAll("button")[1]!);
expect(onBlock).toHaveBeenLastCalledWith("keep", {
const boxes = [first, second].map((b) => b!.querySelector<HTMLInputElement>("input")!);
expect(boxes.map((b) => b.checked)).toEqual([true, false]);
fireEvent.click(boxes[1]!);
expect(onBlock).toHaveBeenLastCalledWith("include", {
oldFrom: 4,
oldCount: 1,
newFrom: 4,
@@ -216,4 +254,40 @@ describe("DiffEditor", () => {
"false"
);
});
it("offers Add to Chat on a selection, with its 1-based line range", async () => {
const onAddToChat = vi.fn();
const { container } = render(
<DiffEditor
value={"a\nb\nc\nd"}
original={"a\nb\nc\nd"}
lang="plain"
label="Edit"
revertLabel="Revert"
onChange={vi.fn()}
onSave={vi.fn()}
onAddToChat={onAddToChat}
/>
);
const view = EditorView.findFromDOM(container.querySelector<HTMLElement>(".cm-editor")!)!;
expect(container.querySelector('[data-testid="lane-editor-add-to-chat"]')).toBeNull();
// b..c, ending at the start of d: d is not taken.
view.dispatch({ selection: { anchor: 2, head: 6 } });
const chip = await waitFor(() => {
const b = container.querySelector<HTMLButtonElement>(
'[data-testid="lane-editor-add-to-chat"]'
);
expect(b).not.toBeNull();
return b!;
});
// The chip keeps its own box: the tooltip theme strips the WRAPPER's, so
// the button must not be the `.cm-tooltip` itself.
expect(chip.classList.contains("cm-tooltip")).toBe(false);
fireEvent.click(chip);
expect(onAddToChat).toHaveBeenCalledWith(2, 3);
view.dispatch({ selection: { anchor: 6 } });
await waitFor(() =>
expect(container.querySelector('[data-testid="lane-editor-add-to-chat"]')).toBeNull()
);
});
});
@@ -6,7 +6,11 @@
* once, the range toggle changes which comparison is requested, History opens
* one commit, and Files quick-opens a whole file. Also pins the per-file git
* writes — including discard needing two clicks before the server hears
* `confirm` — and the degrade for a cwd git cannot read.
* `confirm` — the JetBrains commit (ticked files, Changes ticked and
* Unversioned Files not by default, Commit and Push, Add to VCS / .gitignore,
* a change block's own checkbox making a hunk-level commit), a file's
* history, Annotate, next/previous file, and the degrade for a cwd git
* cannot read.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -70,6 +74,7 @@ vi.mock("../../../lib/api", () => ({
gitLs: vi.fn(),
gitTree: vi.fn(),
gitTreeAll: vi.fn(),
gitBlame: vi.fn(),
},
},
}));
@@ -125,6 +130,11 @@ async function toDiff(user: ReturnType<typeof userEvent.setup>) {
await user.click(await screen.findByRole("button", { name: "Changes", pressed: true }));
}
/** The overview row's checkbox for one path. */
function tickOf(path: string) {
return screen.getByRole("checkbox", { name: `Include ${path} in the commit` });
}
/** The overview row's discard button for one path. */
function discardOf(path: string) {
return within(screen.getByTitle(path).closest("li")!).getByRole("button", {
@@ -133,7 +143,6 @@ function discardOf(path: string) {
}
beforeEach(() => {
window.localStorage.removeItem("agenthub.changes.neverStage.7");
vi.mocked(api.lanes.git).mockReset().mockResolvedValue({ available: false });
vi.mocked(api.lanes.changes).mockReset();
vi.mocked(api.lanes.changes).mockResolvedValue(changes());
@@ -280,60 +289,36 @@ describe("LaneChangesPanel", () => {
).toBeInTheDocument();
});
it("stages one file and asks the server for that path alone", async () => {
it("adds an unversioned file to VCS, for that path alone, and ticks it", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
// Both rows offer it (one modified, one untracked); the first is the
// modified file the assertion below names.
const [stage] = screen.getAllByRole("button", { name: "Stage this file" });
await user.click(stage!);
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage", {
paths: ["server/lib/git-ops.js"],
});
});
it("keeps a never-stage file out of Stage all, without telling git", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
const [keep] = screen.getAllByRole("button", { name: /Never stage/ });
await user.click(keep!); // the first row: server/lib/git-ops.js
expect(api.lanes.gitAction).not.toHaveBeenCalled();
expect(JSON.parse(window.localStorage.getItem("agenthub.changes.neverStage.7")!)).toEqual([
"server/lib/git-ops.js",
]);
await user.click(screen.getByRole("button", { name: "Stage all (1)" }));
// Only the untracked row offers it: a tracked file is already in VCS.
const [add, ...more] = screen.getAllByRole("button", { name: "Add to VCS" });
expect(more).toHaveLength(0);
expect(tickOf("notes.txt")).not.toBeChecked();
await user.click(add!);
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage", { paths: ["notes.txt"] });
// And back: the row's button now offers the way out.
await user.click(screen.getByRole("button", { name: "Include in Stage all again" }));
expect(JSON.parse(window.localStorage.getItem("agenthub.changes.neverStage.7")!)).toEqual([]);
await waitFor(() => expect(tickOf("notes.txt")).toBeChecked());
});
it("draws a never-stage file orange in the Files tree", async () => {
window.localStorage.setItem("agenthub.changes.neverStage.7", JSON.stringify(["notes.txt"]));
vi.mocked(api.lanes.gitTree).mockResolvedValue({
available: true,
path: "",
entries: [
{ name: "notes.txt", path: "notes.txt", dir: false, status: "??" },
{ name: "a.txt", path: "a.txt", dir: false, status: " M" },
],
truncated: false,
});
it("a group's box unticks, then ticks, every file in it, without telling git", async () => {
const user = userEvent.setup();
render(<Tabbed laneId={7} />);
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "Files" }));
const row = async (name: string) => (await screen.findByText(name)).closest("button")!;
expect(await row("notes.txt")).toHaveAttribute("data-tree-kept", "true");
expect(await row("notes.txt")).toHaveClass("text-kept");
expect(await row("a.txt")).not.toHaveAttribute("data-tree-kept");
expect(await row("a.txt")).toHaveClass("text-status-warning");
await user.click(tickOf("docs/API.md"));
const group = screen.getByRole("checkbox", { name: "Tick every file in Changes" });
expect(group).toBePartiallyChecked();
await user.click(group); // some ticked -> untick all
expect(tickOf("server/lib/git-ops.js")).not.toBeChecked();
await user.click(group); // -> tick all
expect(tickOf("docs/API.md")).toBeChecked();
expect(tickOf("server/lib/git-ops.js")).toBeChecked();
expect(api.lanes.gitAction).not.toHaveBeenCalled();
});
it("lists changes as a folder tree: a folder folds, and stages every file in it", async () => {
it("lists changes as a folder tree: a folder folds, and its box unticks every file in it", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
const list = await screen.findByTestId("lane-changes-overview");
@@ -341,16 +326,15 @@ describe("LaneChangesPanel", () => {
const folder = within(list).getByTitle("server/lib");
expect(folder).toHaveTextContent("1 file");
expect(within(list).getByTitle("server/lib/git-ops.js")).toHaveTextContent("git-ops.js");
const box = within(folder.parentElement!).getByRole("checkbox", {
name: "Tick every file in server/lib",
});
expect(box).toBeChecked();
await user.click(box);
expect(tickOf("server/lib/git-ops.js")).not.toBeChecked();
await user.click(folder);
expect(within(list).queryByTitle("server/lib/git-ops.js")).toBeNull();
await user.click(
within(folder.parentElement!).getByRole("button", {
name: "Stage every file in this folder",
})
);
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage", {
paths: ["server/lib/git-ops.js"],
});
expect(api.lanes.gitAction).not.toHaveBeenCalled();
});
it("heads the panel with the lane's git line, and a stage re-reads it", async () => {
@@ -373,7 +357,7 @@ describe("LaneChangesPanel", () => {
expect(screen.getByTestId("lane-git-bar")).toBeInTheDocument();
expect(api.lanes.git).toHaveBeenCalledTimes(1);
const [stage] = screen.getAllByRole("button", { name: "Stage this file" });
const [stage] = screen.getAllByRole("button", { name: "Add to VCS" });
await user.click(stage!);
// The header's counts moved with the index: waiting out its 30s poll
// would leave them wrong beside the list that just changed.
@@ -420,43 +404,61 @@ describe("LaneChangesPanel", () => {
await waitFor(() => expect(api.lanes.gitLog).toHaveBeenCalledTimes(1));
});
it("stages every file the index lacks in one call, from above the list", async () => {
it("commits exactly the ticked files: Changes ticked, Unversioned Files not, by default", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
const list = await screen.findByTestId("lane-changes-overview");
const actions = screen.getByTestId("lane-changes-actions");
// Commit box and Stage all come BEFORE the file list.
// The commit box comes BEFORE the file list.
expect(actions.compareDocumentPosition(list) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(screen.getByTestId("lane-changes-commit")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Stage all (2)" }));
// The already-staged docs/API.md is not sent again.
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage", {
await user.click(tickOf("notes.txt"));
await user.click(tickOf("docs/API.md"));
await user.type(screen.getByLabelText("Commit message"), "fix: picked");
await user.click(screen.getByRole("button", { name: "Commit (2)" }));
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "commit", {
message: "fix: picked",
paths: ["server/lib/git-ops.js", "notes.txt"],
});
});
it("Commit and Push commits the ticked files, then pushes", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-commit");
await user.type(screen.getByLabelText("Commit message"), "feat: ship");
await user.click(screen.getByRole("button", { name: "Commit and Push" }));
await waitFor(() => expect(api.lanes.gitAction).toHaveBeenCalledTimes(2));
expect(vi.mocked(api.lanes.gitAction).mock.calls).toEqual([
[7, "commit", { message: "feat: ship", paths: ["server/lib/git-ops.js", "docs/API.md"] }],
[7, "push", {}],
]);
});
it("fills the commit box with the AI-suggested message", async () => {
vi.mocked(api.lanes.suggestCommitMessage).mockResolvedValue({ message: "docs: update API" });
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByTestId("lane-changes-suggest-message"));
expect(api.lanes.suggestCommitMessage).toHaveBeenCalledWith(7);
expect(api.lanes.suggestCommitMessage).toHaveBeenCalledWith(7, [
"server/lib/git-ops.js",
"docs/API.md",
]);
expect(await screen.findByDisplayValue("docs: update API")).toBeInTheDocument();
});
it("lists staged files apart from the changes the next commit leaves behind", async () => {
it("lists tracked changes apart from Unversioned Files, staged or not", async () => {
render(<LaneChangesPanel laneId={7} />);
const list = await screen.findByTestId("lane-changes-overview");
const staged = list.querySelector('[data-group="staged"]') as HTMLElement;
const unstaged = list.querySelector('[data-group="unstaged"]') as HTMLElement;
expect(within(staged).getByTitle("docs/API.md")).toBeInTheDocument();
expect(within(staged).queryByText("notes.txt")).toBeNull();
expect(within(unstaged).getByTitle("server/lib/git-ops.js")).toBeInTheDocument();
expect(within(unstaged).getByText("notes.txt")).toBeInTheDocument();
expect(within(unstaged).queryByTitle("docs/API.md")).toBeNull();
expect(screen.getByText("Staged Changes")).toBeInTheDocument();
const tracked = list.querySelector('[data-group="changes"]') as HTMLElement;
const unversioned = list.querySelector('[data-group="unversioned"]') as HTMLElement;
expect(within(tracked).getByTitle("docs/API.md")).toBeInTheDocument();
expect(within(tracked).getByTitle("server/lib/git-ops.js")).toBeInTheDocument();
expect(within(tracked).queryByText("notes.txt")).toBeNull();
expect(within(unversioned).getByText("notes.txt")).toBeInTheDocument();
expect(screen.getByText("Unversioned Files")).toBeInTheDocument();
});
it("reads a whole markdown file rendered after Preview, and back as source after Raw", async () => {
@@ -627,21 +629,21 @@ describe("LaneChangesPanel", () => {
});
});
it("right-click on a changed file offers stage, a two-click discard and copy path", async () => {
it("right-click on a changed file offers Commit File, a two-click discard, copy path and patch", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
const list = await screen.findByTestId("lane-changes-overview");
const row = within(list).getByTitle("server/lib/git-ops.js");
const right = () => user.pointer({ keys: "[MouseRight]", target: row });
const menu = () => within(screen.getByTestId("lane-changes-file-menu"));
// Commit File: that file alone is ticked, and the message box has focus.
await right();
await user.click(
within(screen.getByTestId("lane-changes-file-menu")).getByText("Stage this file")
);
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage", {
paths: ["server/lib/git-ops.js"],
});
vi.mocked(api.lanes.gitAction).mockClear();
await user.click(menu().getByText("Commit File…"));
expect(tickOf("server/lib/git-ops.js")).toBeChecked();
expect(tickOf("docs/API.md")).not.toBeChecked();
expect(screen.getByLabelText("Commit message")).toHaveFocus();
expect(screen.getByRole("button", { name: "Commit (1)" })).toBeInTheDocument();
await right();
await user.click(screen.getByTestId("lane-changes-menu-discard"));
@@ -654,10 +656,123 @@ describe("LaneChangesPanel", () => {
});
await right();
await user.click(
within(screen.getByTestId("lane-changes-file-menu")).getByText("Copy relative path")
);
await user.click(menu().getByText("Copy relative path"));
expect(await navigator.clipboard.readText()).toBe("server/lib/git-ops.js");
// Copy as Patch: that file's diff, read for its path, as a unified patch.
vi.mocked(api.lanes.changes).mockResolvedValueOnce(
changes({
files: [{ ...changes().files![0]!, hunks: [HUNK] } as never],
})
);
await right();
await user.click(menu().getByText("Copy as Patch to Clipboard"));
await waitFor(async () =>
expect(await navigator.clipboard.readText()).toBe(
[
"diff --git a/server/lib/git-ops.js b/server/lib/git-ops.js",
"--- a/server/lib/git-ops.js",
"+++ b/server/lib/git-ops.js",
"@@ -1,2 +1,3 @@",
" keep",
"-old line",
"+new line",
"",
].join("\n")
)
);
expect(api.lanes.changes).toHaveBeenCalledWith(7, {
range: "work",
path: "server/lib/git-ops.js",
context: 3,
});
});
it("right-click on an unversioned file offers Add to VCS and Add to .gitignore", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
const list = await screen.findByTestId("lane-changes-overview");
const row = within(list).getByTitle("notes.txt");
const menu = () => within(screen.getByTestId("lane-changes-file-menu"));
await user.pointer({ keys: "[MouseRight]", target: row });
await user.click(menu().getByText("Add to .gitignore"));
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "ignore", { paths: ["notes.txt"] });
await user.pointer({ keys: "[MouseRight]", target: row });
expect(menu().getByText("Add to VCS")).toBeInTheDocument();
// A tracked file is already in VCS, and git would not ignore it anyway.
await user.keyboard("{Escape}");
await user.pointer({
keys: "[MouseRight]",
target: within(list).getByTitle("docs/API.md"),
});
expect(menu().queryByText("Add to .gitignore")).toBeNull();
expect(menu().queryByText("Add to VCS")).toBeNull();
});
it("Show History lists one file's commits and opens the file at a commit", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
const list = await screen.findByTestId("lane-changes-overview");
await user.pointer({
keys: "[MouseRight]",
target: within(list).getByTitle("server/lib/git-ops.js"),
});
await user.click(
within(screen.getByTestId("lane-changes-file-menu")).getByText("Show History")
);
await waitFor(() =>
expect(api.lanes.gitLog).toHaveBeenLastCalledWith(7, {
limit: 50,
file: "server/lib/git-ops.js",
})
);
expect(screen.getByTestId("lane-changes-history-file")).toHaveTextContent(
"server/lib/git-ops.js"
);
await user.click(await screen.findByText("feat: the commit"));
await waitFor(() =>
expect(api.lanes.changes).toHaveBeenLastCalledWith(7, {
range: "commit",
sha: "a".repeat(40),
path: "server/lib/git-ops.js",
context: 3,
})
);
});
it("walks to the next file from an open one, in list order", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
const list = await screen.findByTestId("lane-changes-overview");
await user.click(within(list).getByTitle("server/lib/git-ops.js"));
await user.click(await screen.findByRole("button", { name: "Next file" }));
await waitFor(() =>
expect(api.lanes.changes).toHaveBeenLastCalledWith(7, {
range: "work",
path: "notes.txt",
context: 3,
})
);
expect(screen.getByRole("button", { name: "Previous file" })).toBeEnabled();
});
it("annotates the open file with who last changed each line", async () => {
vi.mocked(api.lanes.gitBlame).mockResolvedValue({
available: true,
path: "server/lib/git-ops.js",
commits: {},
lines: [],
});
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
const list = await screen.findByTestId("lane-changes-overview");
await user.click(within(list).getByTitle("server/lib/git-ops.js"));
await user.click(await screen.findByRole("button", { name: "Annotate" }));
expect(api.lanes.gitBlame).toHaveBeenCalledWith(7, "server/lib/git-ops.js");
expect(screen.getByRole("button", { name: "Annotate" })).toHaveAttribute(
"aria-pressed",
"true"
);
});
it("disarms a pending discard when the lane changes, so one click cannot discard in another lane", async () => {
@@ -851,7 +966,10 @@ describe("LaneChangesPanel", () => {
await user.click(screen.getByTestId("lane-changes-amend"));
await user.click(screen.getByRole("button", { name: "Amend" }));
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "amend", { sha: "aaaaaaa" })
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "amend", {
sha: "aaaaaaa",
paths: ["server/lib/git-ops.js", "docs/API.md"],
})
);
});
@@ -938,7 +1056,7 @@ describe("LaneChangesPanel", () => {
expect(screen.getByText("src")).toBeInTheDocument();
});
it("highlights modified and untracked files in the Files tree, and folders holding them", async () => {
it("colours the Files tree like JetBrains: modified blue, unversioned orange, added green", async () => {
vi.mocked(api.lanes.gitTree).mockResolvedValue({
available: true,
path: "",
@@ -948,6 +1066,8 @@ describe("LaneChangesPanel", () => {
{ name: "a.txt", path: "a.txt", dir: false, status: " M" },
{ name: "new.txt", path: "new.txt", dir: false, status: "??" },
{ name: "keep.txt", path: "keep.txt", dir: false },
{ name: "add.txt", path: "add.txt", dir: false, status: "A " },
{ name: "gone.txt", path: "gone.txt", dir: false, status: " D" },
],
truncated: false,
});
@@ -958,9 +1078,12 @@ describe("LaneChangesPanel", () => {
const row = async (name: string) => (await screen.findByText(name)).closest("button")!;
expect(await row("a.txt")).toHaveAttribute("data-tree-status", "M");
expect(await row("a.txt")).toHaveClass("text-status-warning");
expect(await row("a.txt")).toHaveClass("text-diff-mod");
expect(await row("new.txt")).toHaveAttribute("data-tree-status", "U");
expect(await row("new.txt")).toHaveClass("text-status-success");
expect(await row("new.txt")).toHaveClass("text-kept");
expect(await row("add.txt")).toHaveClass("text-status-success");
expect(await row("gone.txt")).toHaveClass("text-fg-muted");
expect(await row("src")).toHaveClass("text-diff-mod");
expect(await row("src")).toHaveAttribute("data-tree-status", "dir");
expect(await row("keep.txt")).not.toHaveAttribute("data-tree-status");
expect(await row("docs")).not.toHaveAttribute("data-tree-status");
@@ -1069,18 +1192,6 @@ describe("LaneChangesPanel", () => {
expect(screen.getByTestId("lane-file-editor")).toHaveValue("xy");
});
it("unstages every staged file in one call, in the same pinned bar as Stage all and commit", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
const row = screen.getByTestId("lane-changes-actions");
expect(row).toContainElement(screen.getByTestId("lane-changes-commit"));
expect(row).toContainElement(screen.getByRole("button", { name: "Stage all (2)" }));
await user.click(screen.getByRole("button", { name: "Unstage all (1)" }));
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "unstage", { paths: ["docs/API.md"] });
});
it("switches the diff to side-by-side", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
@@ -1157,22 +1268,14 @@ describe("LaneChangesPanel", () => {
);
});
it("commits the index from the overview, never the whole tree", async () => {
it("cannot commit with nothing ticked", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
// The box exists only because a file is staged — with a clean index there
// is nothing this button could mean.
const box = await screen.findByTestId("lane-changes-commit");
await user.type(screen.getByLabelText("Commit message"), "fix: the staged half");
await user.click(screen.getByRole("button", { name: "Commit 1 staged" }));
expect(box).toBeInTheDocument();
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "commit", {
message: "fix: the staged half",
only: "staged",
});
await screen.findByTestId("lane-changes-commit");
await user.type(screen.getByLabelText("Commit message"), "fix: nothing");
await user.click(screen.getByRole("checkbox", { name: "Tick every file in Changes" }));
expect(screen.getByRole("button", { name: "Commit (0)" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Commit and Push" })).toBeDisabled();
});
it("drops a half-typed commit message on a lane switch", async () => {
@@ -1185,24 +1288,10 @@ describe("LaneChangesPanel", () => {
expect(screen.getByLabelText("Commit message")).toHaveValue("");
});
it("hides the commit box when nothing is staged", async () => {
vi.mocked(api.lanes.changes).mockResolvedValue(
changes({
files: [
{
path: "server/lib/git-ops.js",
status: " M",
untracked: false,
unstaged: true,
added: 1,
deleted: 0,
binary: false,
},
],
})
);
it("hides the commit box when there is nothing to commit", async () => {
vi.mocked(api.lanes.changes).mockResolvedValue(changes({ files: [] }));
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await screen.findByText("Nothing uncommitted.");
expect(screen.queryByTestId("lane-changes-commit")).toBeNull();
});
@@ -1292,22 +1381,7 @@ describe("LaneChangesPanel", () => {
await screen.findByTestId("lane-changes-review");
};
it("stages one hunk by its @@ line, at the context the diff was read with", async () => {
const user = userEvent.setup();
await openReview(user);
// Two hunks on the tracked file; the untracked one has none to act on.
const stage = screen.getAllByRole("button", { name: "Stage this hunk" });
expect(stage).toHaveLength(2);
await user.click(stage[1]!);
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage-hunk", {
path: "server/lib/git-ops.js",
hunk: "@@ -9,2 +10,3 @@",
block: 0,
context: 3,
});
});
it("gives each +/- block inside ONE hunk its own bar and acts on that block only (#50)", async () => {
it("gives each +/- block inside ONE hunk its own bar and checkbox (#50)", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
@@ -1350,13 +1424,35 @@ describe("LaneChangesPanel", () => {
expect.stringContaining("1 of 2"),
expect.stringContaining("2 of 2"),
]);
await user.click(screen.getAllByRole("button", { name: "Stage this hunk" })[1]!);
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage-hunk", {
path: "server/lib/git-ops.js",
hunk: "@@ -1,4 +1,5 @@",
block: 1,
context: 3,
});
const boxes = screen.getAllByRole("checkbox", { name: "Include this change in the commit" });
expect(boxes).toHaveLength(2);
await user.click(boxes[1]!);
expect(boxes[0]).toBeChecked();
expect(boxes[1]).not.toBeChecked();
expect(tickOf("server/lib/git-ops.js")).toBePartiallyChecked();
expect(api.lanes.gitAction).not.toHaveBeenCalled();
});
it("commits only the ticked change blocks of a partly ticked file (hunks)", async () => {
const user = userEvent.setup();
await openReview(user);
// The second hunk's only block unticked: the file is partial.
await user.click(
screen.getAllByRole("checkbox", { name: "Include this change in the commit" })[1]!
);
expect(tickOf("server/lib/git-ops.js")).toBePartiallyChecked();
await user.click(screen.getByRole("button", { name: "Changes" }));
await screen.findByTestId("lane-changes-commit");
await user.type(screen.getByLabelText("Commit message"), "fix: half");
await user.click(screen.getByRole("button", { name: "Commit (1)" }));
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "commit", {
message: "fix: half",
paths: [],
hunks: { "server/lib/git-ops.js": [{ hunk: "@@ -1,2 +1,3 @@", blocks: [0] }] },
context: 3,
})
);
});
it("hangs one review bar under each actionable hunk, counting them", async () => {
@@ -1398,14 +1494,18 @@ describe("LaneChangesPanel", () => {
});
});
it("stages a whole file from its review header without folding it", async () => {
it("unticks a whole file from its review header without folding it", async () => {
const user = userEvent.setup();
await openReview(user);
const [stageFile] = screen.getAllByRole("button", { name: "Stage this file" });
await user.click(stageFile!);
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage", {
paths: ["server/lib/git-ops.js"],
});
expect(screen.queryByRole("button", { name: "Stage this file" })).toBeNull();
await user.click(tickOf("server/lib/git-ops.js"));
expect(tickOf("server/lib/git-ops.js")).not.toBeChecked();
// Every block of it follows the file.
for (const box of screen.getAllByRole("checkbox", {
name: "Include this change in the commit",
}))
expect(box).not.toBeChecked();
expect(api.lanes.gitAction).not.toHaveBeenCalled();
expect(
screen.getByRole("button", { name: "Collapse server/lib/git-ops.js" })
).toBeInTheDocument();
@@ -1615,7 +1715,7 @@ describe("LaneChangesPanel", () => {
);
const { rerender } = render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
const [stage] = screen.getAllByRole("button", { name: "Stage this file" });
const [stage] = screen.getAllByRole("button", { name: "Add to VCS" });
await user.click(stage!);
rerender(<LaneChangesPanel laneId={8} />);
@@ -1732,6 +1832,8 @@ describe("a merge in progress (issue #45)", () => {
// Not offered as an ordinary stage: that would mark it resolved blind.
expect(screen.getAllByText("a.txt")).toHaveLength(1);
expect(screen.getByRole("button", { name: "Complete merge" })).toBeDisabled();
// A partial commit cannot finish a merge: Complete is the way out.
expect(screen.queryByTestId("lane-changes-commit")).toBeNull();
await user.click(within(group).getByRole("button", { name: "Take theirs" }));
await waitFor(() =>
+23 -1
View File
@@ -9,7 +9,8 @@
* header: drag it to move the pane (drop on another pane's edge to split
* there, on its middle to swap), pick another live session for it, split it
* right or down, or take the pane OUT of this tab (its session keeps running
* in a tab of its own — so its icon is not the tab's stop X). The grid shows
* in a tab of its own — so its icon is not the tab's stop X), or stop its
* session there (X, `onStop`) without taking it out first. The grid shows
* one tab's panes; `onChange` applies through `updateActive`, so a session
* dropped or swapped in from another tab leaves that tab. A header names the pane's session and
* shows its state dot, as its tab does.
@@ -31,6 +32,7 @@ import {
Minimize2,
Rows2,
SquareTerminal,
X,
} from "lucide-react";
import type { RunHandle } from "../../lib/api";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
@@ -309,8 +311,11 @@ export function PaneGrid({
toolLabel,
onNewSplit,
stateOf,
onStop,
}: {
layout: PaneLayout;
/** Stops the session in `slot` (the header's X). */
onStop?: (slot: number) => void;
/** A live AI run's state for its header dot (none for a shell). */
stateOf?: (r: RunHandle) => RunState | null;
/** Every slot to keep mounted; those not in the layout are hidden. */
@@ -514,6 +519,18 @@ export function PaneGrid({
>
<Minimize2 className="h-3.5 w-3.5" />
</button>
{run && onStop && (
<button
type="button"
data-testid={`pane-stop-${slot}`}
title={t("panes.stop")}
aria-label={t("panes.stop")}
onClick={() => onStop(slot)}
className="flex h-6 w-6 items-center justify-center rounded text-fg-muted hover:bg-surface-4 hover:text-status-danger"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
</div>
)}
@@ -557,6 +574,11 @@ export function PaneGrid({
<MenuItem onSelect={pickPane((slot) => onChange((l) => closeInLayout(l, slot)))}>
{t("panes.closeShort")}
</MenuItem>
{onStop && runOf(paneCtx.menu.item) && (
<MenuItem testId="pane-menu-stop" onSelect={pickPane(onStop)}>
{t("panes.stop")}
</MenuItem>
)}
</ContextMenu>
)}
{dividers.map((d) => (
+10 -2
View File
@@ -46,6 +46,7 @@ import { useTheme } from "../../hooks/useTheme";
import { terminalThemeFor } from "../../lib/terminalTheme";
import { api } from "../../lib/api";
import { notePaneFocus, registerPane } from "../../lib/panePaste";
import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont";
type DropState = "hint" | "uploading" | "failed";
@@ -217,7 +218,9 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
// under the canvas renderer (which only stops a glyph from shoving its
// *neighbours*, not from overflowing its own cell). JetBrains Mono is
// already bundled for the rest of the UI; main.tsx additionally imports
// its `vietnamese` subset so this covers the diacritics too.
// its `vietnamese` subset so this covers the diacritics too. Settings →
// Appearance may swap it for the rounded Maple Mono (also complete) or
// the browser's own `monospace` (lib/terminalFont.ts).
// NO `convertEol`. It rewrites every bare LF as CR+LF, i.e. it forces the
// cursor to column 0 on a line feed — and xterm's own docs say not to use
// it on a PTY, where termios already does that translation for the streams
@@ -229,7 +232,7 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
const term = new Terminal({
fontSize: 13,
cursorBlink: true,
fontFamily: '"JetBrains Mono", monospace',
fontFamily: TERMINAL_FONTS[readTerminalFont()],
theme: paletteRef.current,
screenReaderMode: readFlag(SCREEN_READER_KEY),
});
@@ -510,6 +513,11 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
),
[runId]
);
// The Workspace's focused pane takes those pastes even when the focus moved
// without a click inside the terminal (a tab, Alt+1–4, the sidebar).
useEffect(() => {
if (autoFocus) notePaneFocus(runId);
}, [autoFocus, runId]);
const runFind = (backwards = false) => {
const q = find ?? "";
@@ -11,7 +11,7 @@
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest";
import { render, cleanup, act, fireEvent, screen, waitFor } from "@testing-library/react";
import { TerminalView, loadXterm } from "../TerminalView";
import { pasteToRun } from "../../../lib/panePaste";
import { pasteToLane, pasteToRun } from "../../../lib/panePaste";
const writeMock = vi.fn();
const onDataHandlers: Array<(d: string) => void> = [];
@@ -311,6 +311,25 @@ describe("TerminalView", () => {
}
});
it("takes the workspace's paste once it is the focused pane, clicked into or not", () => {
const width = vi.spyOn(HTMLElement.prototype, "offsetWidth", "get").mockReturnValue(800);
try {
const both = (focus: number) => (
<>
<TerminalView runId="agenthub-lane-9" wsBaseUrl="ws://x" autoFocus={focus === 0} />
<TerminalView runId="agenthub-lane-9-1" wsBaseUrl="ws://x" autoFocus={focus === 1} />
</>
);
const { rerender } = render(both(0));
expect(pasteToLane(9, "a")).toBe("agenthub-lane-9");
// Alt+2, a tab click: the Workspace moves the focus, the DOM may not.
rerender(both(1));
expect(pasteToLane(9, "a")).toBe("agenthub-lane-9-1");
} finally {
width.mockRestore();
}
});
it("takes the keyboard as soon as it mounts", () => {
// The terminal IS the page here — without this the first thing typed after
// opening a lane went nowhere until the user clicked the pane.
@@ -1,6 +1,7 @@
/**
* @file AppearanceSettings.tsx
* @description Settings → Appearance (`/settings/appearance`): the interface language, the
* terminal font (`lib/terminalFont.ts`, with a preview line), the
* Dark / Light / System mode switch and one named-theme picker per mode, and the
* corner pet (`PetSettings`, deep-linked as `#pet`) — the old General and Pet
* pages redirect here. Theme state is in `hooks/useTheme.ts`, palettes in
@@ -11,6 +12,7 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Monitor, Moon, RotateCcw, Sun } from "lucide-react";
import { SettingsRow, SettingsSection, SettingsSegmented } from "./SettingsRow";
@@ -18,6 +20,12 @@ import { PetSettings } from "./PetSettings";
import { THEMES, useTheme, type Theme, type ThemeMode } from "../../hooks/useTheme";
import { confirmDialog } from "../../lib/confirm";
import { toast } from "../../lib/toast";
import {
TERMINAL_FONTS,
readTerminalFont,
writeTerminalFont,
type TerminalFont,
} from "../../lib/terminalFont";
const THEME_LABELS: Record<string, string> = {
darcula: "Darcula",
@@ -65,6 +73,11 @@ function Swatch({ mode, name }: { mode: Theme; name: string }) {
export function AppearanceSettings() {
const { t, i18n } = useTranslation("settings");
const { mode, theme, themeFor, setMode, setThemeFor } = useTheme();
const [termFont, setTermFont] = useState(readTerminalFont);
const pickTermFont = (font: TerminalFont) => {
writeTerminalFont(font);
setTermFont(font);
};
const reset = async () => {
const ok = await confirmDialog({
@@ -102,6 +115,37 @@ export function AppearanceSettings() {
/>
</SettingsSection>
<SettingsSection
id="terminal-font"
title={t("terminalFont.title")}
description={t("terminalFont.description")}
>
<SettingsRow
label={t("terminalFont.label")}
description={termFont === "system" ? t("terminalFont.systemNote") : undefined}
control={
<SettingsSegmented
ariaLabel={t("terminalFont.title")}
value={termFont}
onChange={pickTermFont}
options={(Object.keys(TERMINAL_FONTS) as TerminalFont[]).map((f) => ({
value: f,
label: t(`terminalFont.names.${f}`),
}))}
/>
}
/>
<p
data-testid="terminal-font-preview"
aria-label={t("terminalFont.preview")}
className="mt-2 rounded-md border border-border bg-surface-0 px-3 py-2 text-[13px] text-fg-primary"
// No ligatures: xterm draws one glyph per cell, so the terminal has none.
style={{ fontFamily: TERMINAL_FONTS[termFont], fontVariantLigatures: "none" }}
>
❯ Tiếng Việt: ấ ế ố ồ ủ ữ — const x = {"{ ok: true }"}; 0O il1|
</p>
</SettingsSection>
<section id="theme" className="card p-5 space-y-5 scroll-mt-24">
<h3 className="text-sm font-semibold text-fg-primary">{t("appearance.theme")}</h3>
<SettingsRow
@@ -3,15 +3,20 @@
* whether the dashboard starts at boot/login through the per-user service
* (systemd `--user` unit, or a LaunchAgent on macOS), over
* `GET/PUT /api/settings/autostart`. Off by default; a change never starts or
* stops the running server.
* stops the running server. Beside it, **Quit AgentHub** (issue #65, 9router's
* tray Quit): `POST /api/settings/quit` closes every workspace terminal and
* stops the server, which stays stopped until `agenthub start` — or the next
* boot/login while the switch is on. Between them, the status-area icon switch
* (`GET/PUT /api/settings/tray`, on by default — server/lib/tray.js).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { api, type AutostartStatus } from "../../lib/api";
import { api, type AutostartStatus, type TrayStatus } from "../../lib/api";
import { toast } from "../../lib/toast";
import { SettingsSection, SettingsSwitchRow } from "./SettingsRow";
import { confirmDialog } from "../../lib/confirm";
import { SettingsRow, SettingsSection, SettingsSwitchRow } from "./SettingsRow";
export function AutostartSettings() {
const { t } = useTranslation("settings");
@@ -36,6 +41,39 @@ export function AutostartSettings() {
}
};
const [tray, setTray] = useState<TrayStatus | null>(null);
useEffect(() => {
api.settings.tray
.get()
.then(setTray)
.catch(() => {});
}, []);
const changeTray = async (enabled: boolean) => {
try {
setTray(await api.settings.tray.set(enabled));
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
};
const [quitting, setQuitting] = useState(false);
const quit = async () => {
const ok = await confirmDialog({
title: t("quit.confirmTitle"),
message: t(status?.enabled ? "quit.confirmMessageAutostart" : "quit.confirmMessage"),
confirmLabel: t("quit.button"),
});
if (!ok) return;
setQuitting(true);
try {
await api.settings.quit();
toast.success(t("quit.done"));
} catch (err) {
setQuitting(false);
toast.error(err instanceof Error ? err.message : String(err));
}
};
const note = !status
? undefined
: !status.available
@@ -53,6 +91,28 @@ export function AutostartSettings() {
disabled={!status?.available || status.foreign || saving}
onChange={(v) => void change(v)}
/>
<SettingsSwitchRow
label={t("tray.label")}
description={tray && !tray.available ? t("tray.unavailable") : t("tray.desc")}
checked={!!tray?.available && tray.enabled}
disabled={!tray?.available}
onChange={(v) => void changeTray(v)}
/>
<SettingsRow
label={t("quit.label")}
description={t("quit.desc")}
control={
<button
type="button"
data-testid="quit-agenthub"
disabled={quitting}
onClick={() => void quit()}
className="btn-ghost text-xs text-status-danger disabled:opacity-40"
>
{t("quit.button")}
</button>
}
/>
</SettingsSection>
);
}
@@ -3,8 +3,9 @@
* @description The dashboard's own settings that belong to Claude Code, shown
* under Settings → Claude Code → Overview: the CLAUDE_HOME the server reads
* transcripts and config from, the AgentHub hooks installed into Claude Code
* with a Reinstall button, and Model Pricing for Claude models (the shared
* `PricingSettings` table, with Reset Defaults). Changing CLAUDE_HOME asks first.
* with a Reinstall button (named as the plugin's when the enabled `agenthub`
* plugin provides them), and a link to the Claude table on the Pricing page.
* Changing CLAUDE_HOME asks first.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -14,12 +15,17 @@ import { RefreshCw, Plug, AlertTriangle, RotateCcw, CheckCircle, XCircle } from
import { api } from "../../lib/api";
import { Skeleton } from "../Skeleton";
import { HomeDirSetting } from "./HomeDirSetting";
import { PricingSettings } from "./PricingSettings";
import { PricingLink } from "./PricingSettings";
type HooksInfo = { installed: boolean; path: string; hooks: Record<string, boolean> };
type HooksInfo = {
installed: boolean;
path: string;
hooks: Record<string, boolean>;
source?: "plugin";
};
/**
* Claude Code's AgentHub settings: pricing, hook status and CLAUDE_HOME.
* Claude Code's AgentHub settings: CLAUDE_HOME, hook status and the pricing link.
* @param onHomeChanged - called after CLAUDE_HOME was saved (the page re-reads
* the config it lists, which lives under that home).
* @returns The three sections, each with its own id.
@@ -205,13 +211,17 @@ export function ClaudeAgentHubSettings({ onHomeChanged }: { onHomeChanged?: () =
</div>
))}
</div>
<p className="text-[11px] text-fg-muted font-mono truncate">{hooks.path}</p>
{hooks.source === "plugin" ? (
<p className="text-[11px] text-fg-muted">{t("hooks.plugin")}</p>
) : (
<p className="text-[11px] text-fg-muted font-mono truncate">{hooks.path}</p>
)}
</>
)}
</div>
</section>
<PricingSettings vendor="claude" allowReset />
<PricingLink vendor="claude" />
</div>
);
}
@@ -19,7 +19,18 @@
import { Checkbox } from "../Checkbox";
import { useEffect, useState, useCallback, useRef, Fragment } from "react";
import { useTranslation } from "react-i18next";
import { DollarSign, Plus, Pencil, Trash2, Check, X, RotateCcw, Info } from "lucide-react";
import { Link } from "react-router-dom";
import {
ArrowRight,
DollarSign,
Plus,
Pencil,
Trash2,
Check,
X,
RotateCcw,
Info,
} from "lucide-react";
import { api } from "../../lib/api";
import { eventBus } from "../../lib/eventBus";
import { isRemoteDataRefreshMessage } from "../../lib/remoteDataEvents";
@@ -191,6 +202,29 @@ function PricingInfoTooltip({ vendor }: { vendor: PricingVendor }) {
export type PricingVendor = "claude" | "openai" | "google";
/**
* A tool page's pointer to its vendor's table on the one Pricing page — the
* table itself lives only there, so a price is never shown (or edited) twice.
*/
export function PricingLink({ vendor }: { vendor: PricingVendor }) {
const { t } = useTranslation("settings");
return (
<div className="card flex items-center justify-between gap-4 p-5">
<div className="min-w-0">
<h3 className="flex items-center gap-2 text-sm font-semibold text-fg-primary">
<DollarSign className="h-4 w-4 text-fg-muted" />
{t("pricingLink.title")}
</h3>
<p className="mt-0.5 text-xs text-fg-muted">{t("pricingLink.description")}</p>
</div>
<Link to={`/settings/pricing#pricing-${vendor}`} className="btn-ghost shrink-0 text-xs">
{t("pricingLink.open")}
<ArrowRight className="h-3.5 w-3.5" />
</Link>
</div>
);
}
// Every mounted table's `load`: a change in one reloads them all, and loads
// fired together share one request.
const tables = new Set<() => void>();
@@ -1,108 +0,0 @@
/**
* @file UsersSettings.tsx
* @description Settings → Users, server mode only and only for an admin or
* analyst (docs/SERVER-MODE.md): every account with its role, state,
* workspaces, sessions and the tokens/$ it used in the last 7 and 30 days
* (`GET /api/users`), and the audit log of staff reads of members' sessions
* (`GET /api/users/audit`). Read-only — accounts are managed with
* `agenthub users …` on the host.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { api, type ServerUserUsage, type AuditEntry } from "../../lib/api";
import { fmt, fmtCost, formatDateTime } from "../../lib/format";
import { SettingsSection } from "./SettingsRow";
export function UsersSettings() {
const { t } = useTranslation("settings");
const [users, setUsers] = useState<ServerUserUsage[] | null>(null);
const [audit, setAudit] = useState<AuditEntry[] | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
api.users
.list()
.then((r) => setUsers(r.users))
.catch((e: Error) => setError(e.message));
api.users
.audit()
.then((r) => setAudit(r.entries))
.catch(() => setAudit([]));
}, []);
return (
<>
<SettingsSection title={t("users.title")} description={t("users.description")}>
{error && (
<p role="alert" className="py-2 text-xs text-status-danger">
{error}
</p>
)}
{users && (
<div className="overflow-x-auto">
<table className="w-full text-sm" data-testid="users-table">
<thead className="text-left text-[11px] uppercase text-fg-muted">
<tr>
<th className="py-2 pr-3 font-medium">{t("users.name")}</th>
<th className="py-2 pr-3 font-medium">{t("users.role")}</th>
<th className="py-2 pr-3 text-right font-medium">{t("users.workspaces")}</th>
<th className="py-2 pr-3 text-right font-medium">{t("users.sessions")}</th>
<th className="py-2 pr-3 text-right font-medium">{t("users.last7d")}</th>
<th className="py-2 text-right font-medium">{t("users.last30d")}</th>
</tr>
</thead>
<tbody className="divide-y divide-border/60">
{users.map((u) => (
<tr key={u.id} className={u.disabled ? "text-fg-muted" : "text-fg-secondary"}>
<td className="py-2 pr-3">
{u.username}
{u.disabled && (
<span className="ml-2 text-[11px]">({t("users.disabled")})</span>
)}
</td>
<td className="py-2 pr-3">{u.role}</td>
<td className="py-2 pr-3 text-right tabular-nums">{u.workspaces}</td>
<td className="py-2 pr-3 text-right tabular-nums">{u.sessions}</td>
<td
className="py-2 pr-3 text-right tabular-nums"
title={t("users.tokens", { n: fmt(u.last7d.tokens) })}
>
{fmtCost(u.last7d.cost)}
</td>
<td
className="py-2 text-right tabular-nums"
title={t("users.tokens", { n: fmt(u.last30d.tokens) })}
>
{fmtCost(u.last30d.cost)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<p className="pt-2 text-[11px] text-fg-muted">{t("users.manageHint")}</p>
</SettingsSection>
<SettingsSection title={t("users.auditTitle")} description={t("users.auditDescription")}>
{audit?.length === 0 && (
<p className="py-2 text-sm text-fg-muted">{t("users.auditEmpty")}</p>
)}
{audit && audit.length > 0 && (
<ul className="divide-y divide-border/60 text-xs" data-testid="audit-list">
{audit.map((a) => (
<li key={a.id} className="flex gap-3 py-1.5">
<span className="w-36 flex-shrink-0 text-fg-muted">{formatDateTime(a.at)}</span>
<span className="w-40 flex-shrink-0 text-fg-secondary">
{a.reader} → {a.owner}
</span>
<span className="min-w-0 truncate font-mono text-fg-muted">{a.path}</span>
</li>
))}
</ul>
)}
</SettingsSection>
</>
);
}
@@ -1,6 +1,6 @@
/**
* @file AppearanceSettings.test.tsx
* @description Tests for the Settings → Appearance section: the language switch, and the mode switch and the per-mode theme cards writing through useTheme to `<html>`.
* @description Tests for the Settings → Appearance section: the language switch, the terminal font picker, and the mode switch and the per-mode theme cards writing through useTheme to `<html>`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -73,8 +73,8 @@ describe("AppearanceSettings", () => {
it("the System mode is checked after choosing it", async () => {
const user = userEvent.setup();
render(<AppearanceSettings />);
await user.click(screen.getByRole("radio", { name: /System/ }));
expect(screen.getByRole("radio", { name: /System/ })).toHaveAttribute("aria-checked", "true");
await user.click(screen.getByRole("radio", { name: "System" }));
expect(screen.getByRole("radio", { name: "System" })).toHaveAttribute("aria-checked", "true");
expect(localStorage.getItem("theme")).toBe("system");
});
@@ -84,4 +84,21 @@ describe("AppearanceSettings", () => {
expect(container.querySelector('[data-mode="light"][data-theme="canvas"]')).not.toBeNull();
expect(container.querySelector('[data-mode="light"]:not([data-theme])')).not.toBeNull();
});
it("picks the terminal font, JetBrains Mono by default, and warns on System", async () => {
const user = userEvent.setup();
render(<AppearanceSettings />);
const group = screen.getByRole("radiogroup", { name: "Terminal font" });
expect(within(group).getByRole("radio", { name: "JetBrains Mono" })).toHaveAttribute(
"aria-checked",
"true"
);
await user.click(within(group).getByRole("radio", { name: "Maple Mono (rounded)" }));
expect(localStorage.getItem("agenthub.terminal.font")).toBe("maple");
expect(screen.getByTestId("terminal-font-preview").style.fontFamily).toContain("Maple Mono");
expect(screen.queryByText(/may lack some Vietnamese letters/)).toBeNull();
await user.click(within(group).getByRole("radio", { name: "System font" }));
expect(localStorage.getItem("agenthub.terminal.font")).toBe("system");
expect(screen.getByText(/may lack some Vietnamese letters/)).toBeInTheDocument();
});
});
@@ -0,0 +1,67 @@
/**
* @file Tests Settings → About's Quit AgentHub button (issue #65): it asks
* first, says when start-at-login will bring the dashboard back, and only a
* confirmed ask reaches POST /api/settings/quit. Plus the status-area icon
* switch, which goes straight to PUT /api/settings/tray.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { AutostartSettings } from "../AutostartSettings";
import { api } from "../../../lib/api";
import * as confirm from "../../../lib/confirm";
vi.mock("../../../lib/api", () => ({
api: {
settings: {
autostart: { get: vi.fn(), set: vi.fn() },
tray: { get: vi.fn(), set: vi.fn() },
quit: vi.fn(),
},
},
}));
const status = { available: true, kind: "systemd", enabled: true, foreign: false };
const trayOff = { available: true, enabled: false, running: false };
describe("Quit AgentHub", () => {
beforeEach(() => {
vi.mocked(api.settings.autostart.get).mockResolvedValue(status as never);
vi.mocked(api.settings.tray.get).mockResolvedValue(trayOff);
vi.mocked(api.settings.quit).mockReset().mockResolvedValue({ ok: true, killed: 0 });
});
it("does nothing when the ask is cancelled", async () => {
vi.spyOn(confirm, "confirmDialog").mockResolvedValue(false);
render(<AutostartSettings />);
fireEvent.click(screen.getByTestId("quit-agenthub"));
await waitFor(() => expect(confirm.confirmDialog).toHaveBeenCalled());
expect(api.settings.quit).not.toHaveBeenCalled();
});
it("quits once confirmed, warning that start-at-login brings it back", async () => {
const ask = vi.spyOn(confirm, "confirmDialog").mockResolvedValue(true);
render(<AutostartSettings />);
await waitFor(() => expect(api.settings.autostart.get).toHaveBeenCalled());
await screen.findByRole("switch", { checked: true });
fireEvent.click(screen.getByTestId("quit-agenthub"));
await waitFor(() => expect(api.settings.quit).toHaveBeenCalledTimes(1));
expect(ask.mock.calls[0]![0].message).toMatch(/next boot\/login/);
expect(screen.getByTestId("quit-agenthub")).toBeDisabled();
});
it("turns the status-area icon on from its switch", async () => {
vi.mocked(api.settings.tray.set).mockResolvedValue({
...trayOff,
enabled: true,
running: true,
});
render(<AutostartSettings />);
await waitFor(() => expect(api.settings.tray.get).toHaveBeenCalled());
const icon = screen.getAllByRole("switch")[1]!;
await waitFor(() => expect(icon).toBeEnabled());
fireEvent.click(icon);
await waitFor(() => expect(api.settings.tray.set).toHaveBeenCalledWith(true));
await waitFor(() => expect(icon).toBeChecked());
});
});
@@ -1,20 +1,24 @@
/**
* @file ClaudeAgentHubSettings.test.tsx
* @description Settings → Claude Code → AgentHub: the pricing table, the hook
* status with Reinstall, and CLAUDE_HOME — the three sections moved out of
* Settings → Dashboard (#40 part B) — still load and act as they did there.
* @description Settings → Claude Code → AgentHub: the hook status with
* Reinstall (named as the plugin's when the plugin provides the hooks),
* CLAUDE_HOME, and a link to the Claude table on the Pricing page — the
* table itself is shown only there.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { render, screen, waitFor, within } from "@testing-library/react";
import { render as rtlRender, screen, waitFor, within } from "@testing-library/react";
import type { ReactElement } from "react";
import { MemoryRouter } from "react-router-dom";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "i18next";
import { ClaudeAgentHubSettings } from "../ClaudeAgentHubSettings";
import { api } from "../../../lib/api";
import type { ModelPricing } from "../../../lib/types";
import { settleConfirm, subscribeConfirm } from "../../../lib/confirm";
const render = (ui: ReactElement) => rtlRender(<MemoryRouter>{ui}</MemoryRouter>);
/** Say yes to every confirm (a CLAUDE_HOME change now asks first). */
function confirmYes() {
return subscribeConfirm((p) => p && queueMicrotask(() => settleConfirm(true)));
@@ -36,24 +40,9 @@ vi.mock("../../../lib/api", async (importOriginal) => {
};
});
const RULE = {
model_pattern: "claude-opus-5-5%",
display_name: "Claude Opus 5.5",
input_per_mtok: 4,
output_per_mtok: 20,
cache_read_per_mtok: 0.2,
cache_write_per_mtok: 5,
cache_write_1h_per_mtok: 8,
fast_input_per_mtok: 8,
fast_output_per_mtok: 40,
updated_at: "2026-09-28 10:00:00",
} as ModelPricing;
beforeEach(() => {
i18n.changeLanguage("en");
vi.mocked(api.pricing.list)
.mockReset()
.mockResolvedValue({ pricing: [RULE] });
vi.mocked(api.pricing.list).mockReset().mockResolvedValue({ pricing: [] });
vi.mocked(api.settings.info)
.mockReset()
.mockResolvedValue({
@@ -76,10 +65,15 @@ beforeEach(() => {
});
describe("ClaudeAgentHubSettings", () => {
it("renders pricing, hook status and CLAUDE_HOME in their own sections", async () => {
it("renders hook status and CLAUDE_HOME, and links to the Claude prices", async () => {
const { container } = render(<ClaudeAgentHubSettings />);
expect(await screen.findByText("claude-opus-5-5")).toBeInTheDocument();
for (const id of ["pricing", "hooks", "claude-home"]) {
expect(await screen.findByRole("link", { name: /open pricing/i })).toHaveAttribute(
"href",
"/settings/pricing#pricing-claude"
);
expect(screen.queryByText("claude-opus-5-5")).toBeNull();
expect(api.pricing.list).not.toHaveBeenCalled();
for (const id of ["hooks", "claude-home"]) {
expect(container.querySelector(`section#${id}`)).not.toBeNull();
}
const hooks = container.querySelector("section#hooks") as HTMLElement;
@@ -121,14 +115,19 @@ describe("ClaudeAgentHubSettings", () => {
expect(vi.mocked(api.settings.info).mock.calls.length).toBeGreaterThan(reads);
});
it("resets pricing only on the second click", async () => {
const user = userEvent.setup();
vi.mocked(api.settings.resetPricing).mockResolvedValue({ ok: true, pricing: [RULE] });
it("names the plugin, not settings.json, when the plugin provides the hooks", async () => {
vi.mocked(api.settings.info).mockResolvedValue({
hooks: {
installed: true,
source: "plugin",
path: "/home/u/.claude/settings.json",
hooks: { UserPromptSubmit: true },
},
} as unknown as Awaited<ReturnType<typeof api.settings.info>>);
render(<ClaudeAgentHubSettings />);
await user.click(await screen.findByRole("button", { name: "Reset Defaults" }));
expect(api.settings.resetPricing).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: "Confirm Reset" }));
expect(api.settings.resetPricing).toHaveBeenCalledTimes(1);
expect(await screen.findByText("Reset to 1 default rules")).toBeInTheDocument();
const note = await screen.findByText(/provided by the agenthub plugin/i);
const hooks = note.closest("section#hooks") as HTMLElement;
expect(within(hooks).queryByText("/home/u/.claude/settings.json")).toBeNull();
expect(within(hooks).getByText("UserPromptSubmit")).toBeInTheDocument();
});
});
+1
View File
@@ -114,6 +114,7 @@ export const i18nReady = i18n
"alerts",
"splash",
"lanes",
"users",
],
defaultNS: "common",
interpolation: { escapeValue: false },
+32 -17
View File
@@ -58,11 +58,7 @@
"changes.collapseContext": "Show fewer surrounding lines",
"changes.collapseFile": "Collapse {{path}}",
"changes.commitPlaceholder": "Commit message",
"changes.commitStaged": "Commit {{count}} staged",
"changes.suggestMessage": "Suggest a commit message with AI (Claude, staged changes)",
"changes.neverStage": "Never stage — Stage all skips this file",
"changes.allowStage": "Include in Stage all again",
"changes.keptHint": "Never staged by Stage all (this browser)",
"changes.suggestMessage": "Suggest a commit message with AI (Claude, ticked files)",
"changes.conflicted": "conflict",
"changes.discard": "Discard this file's changes",
"changes.discardConfirm": "Click again to discard — this cannot be undone",
@@ -79,6 +75,7 @@
"changes.commentLine": "Comment on line {{n}} — pasted into this workspace's pane",
"changes.commentOn": "Comment on {{at}} — pasted into the pane you used last; nothing is sent until you press Enter there",
"changes.commentSend": "Paste into pane",
"changes.addToChat": "Add to Chat",
"changes.commentCancel": "Cancel",
"changes.commentNoPane": "No terminal of this workspace is on screen — open its session (or narrow the Changes panel) first",
"changes.layout.split": "Side-by-side",
@@ -105,13 +102,7 @@
"changes.size.full": "Full screen",
"changes.fileCount_one": "{{count}} file",
"changes.fileCount_other": "{{count}} files",
"changes.stageFolder": "Stage every file in this folder",
"changes.unstageFolder": "Unstage every file in this folder",
"changes.stageAll": "Stage all ({{count}})",
"changes.unstageAll": "Unstage all ({{count}})",
"changes.dropEdits": "Discard your unsaved edits to this file?",
"changes.stagedGroup": "Staged Changes",
"changes.unstagedGroup": "Changes",
"changes.stage": "Stage this file",
"changes.title": "Changes",
"changes.truncated": "Some changes were left out to keep this panel responsive.",
@@ -236,10 +227,8 @@
"tooltipStart": "Spawn a conversation-mode run with no initial prompt; driven from CLI or via message",
"worktrees.heading_one": "{{count}} worktree",
"worktrees.heading_other": "{{count}} worktrees",
"changes.stageHunk": "Stage this hunk",
"changes.undoHunk": "Undo this hunk",
"changes.undoHunkConfirm": "Click again to undo this hunk — this cannot be undone",
"changes.stageHunkShort": "Keep",
"changes.undoHunkShort": "Undo",
"changes.undoHunkShortConfirm": "Confirm undo",
"changes.edit": "Edit",
@@ -278,14 +267,14 @@
"gitBar.mergeConflicts": "Merge stopped on {{count}} conflicted file(s) — resolve them in the list below.",
"gitBar.merging": "A {{op}} is in progress: finish or abort it first",
"changes.conflictsGroup": "Merge conflicts",
"changes.mergeBanner": "A {{op}} is in progress \u2014 {{count}} file(s) still conflicted",
"changes.mergeReady": "A {{op}} is in progress \u2014 every conflict is resolved",
"changes.mergeBanner": "A {{op}} is in progress — {{count}} file(s) still conflicted",
"changes.mergeReady": "A {{op}} is in progress — every conflict is resolved",
"changes.mergeCommit": "Complete {{op}}",
"changes.mergeAbort": "Abort {{op}}",
"changes.mergeAbortConfirm": "Click again to abort the {{op}}",
"changes.discardShortConfirm": "Discard?",
"changes.amend": "Amend",
"changes.amendHint": "Fold the staged changes into the last commit (only before it is pushed). Leave the message empty to keep the old one.",
"changes.amendHint": "Fold the ticked files into the last commit (only before it is pushed). Nothing ticked rewords it; leave the message empty to keep the old one.",
"changes.amendPlaceholder": "New message (empty = keep the old one)",
"changes.historyOf": "History of branch",
"changes.cherryPick": "Cherry-pick onto {{branch}}",
@@ -435,6 +424,7 @@
"panes.splitNew.right": "Split right with a new terminal",
"panes.splitNew.bottom": "Split down with a new terminal",
"panes.closeShort": "Move to own tab",
"panes.stop": "Stop session",
"changes.copyPath": "Copy relative path",
"changes.viewCommit": "View commit",
"changes.copySha": "Copy commit SHA",
@@ -475,5 +465,30 @@
"runtime.listening": "listening",
"runtime.notListening": "not listening",
"replay.pasted": "That session was already open in a tab — your message is pasted there; press Enter to send it.",
"replay.notPasted": "That session is open in a tab, but your message could not be pasted there: {{text}}"
"replay.notPasted": "That session is open in a tab, but your message could not be pasted there: {{text}}",
"changes.changesGroup": "Changes",
"changes.unversionedGroup": "Unversioned Files",
"changes.commitTicked": "Commit ({{count}})",
"changes.commitAndPush": "Commit and Push",
"changes.amendAndPush": "Amend and Push",
"changes.pushFailed": "Committed, but the push failed: {{error}}",
"changes.tickFile": "Include {{path}} in the commit",
"changes.tickFolder": "Tick every file in {{path}}",
"changes.commitFile": "Commit File…",
"changes.jumpToSource": "Jump to Source",
"changes.copyPatch": "Copy as Patch to Clipboard",
"changes.noPatch": "This file has no text diff to copy",
"changes.addToVcs": "Add to VCS",
"changes.addToGitignore": "Add to .gitignore",
"changes.includeBlock": "Include this change in the commit",
"changes.prevDiff": "Previous difference (Shift+F7)",
"changes.nextDiff": "Next difference (F7)",
"changes.prevFile": "Previous file",
"changes.nextFile": "Next file",
"changes.showHistory": "Show History",
"changes.historyOfFile": "History of {{path}}",
"changes.historyWholeBranch": "Whole branch",
"changes.annotate": "Annotate",
"changes.noBlame": "Nothing to annotate: this file has no committed history yet",
"changes.notCommitted": "Not committed yet"
}
+3 -1
View File
@@ -81,6 +81,7 @@
"quotaFailed": "Could not read the quota",
"quotaWrongAccount": "This sign-in belongs to another Claude account: log in as this account and save it again",
"quotaActive": "in use",
"quotaTokenExpired": "Sign-in expired: use the tool once so it signs in again",
"quotaProfileExpired": "Sign-in expired: use Check quota in Claude Code → Profiles to refresh it",
"quotaSignInExpires": "Sign-in expires in {{time}}",
"quotaSignInExpired": "Sign-in expired",
@@ -94,5 +95,6 @@
"resizeSidebar": "Resize sidebar (double-click to reset)",
"closeMenu": "Close menu",
"sidebar": "Sidebar",
"now": "now"
"now": "now",
"users": "Users"
}
+70 -47
View File
@@ -137,7 +137,8 @@
"incomplete": "Hooks incomplete",
"reinstall": "Reinstall Hooks",
"success": "Hooks installed successfully",
"failed": "Hook installation failed"
"failed": "Hook installation failed",
"plugin": "Provided by the agenthub plugin (its plugin.json), not settings.json"
},
"lanesSection": {
"title": "Workspaces",
@@ -311,16 +312,51 @@
"unavailable": "Not available here: needs systemd --user on Linux, or a GUI login on macOS.",
"foreign": "A service named agenthub that AgentHub did not write already exists — AgentHub leaves it alone."
},
"tray": {
"label": "Show AgentHub icon in the status area",
"desc": "An AgentHub icon beside the clock (top bar, menu bar or notification area) to open the dashboard, quit it, and start it again after a quit.",
"unavailable": "Not available here: needs a graphical desktop session, and is off in server mode."
},
"quit": {
"label": "Quit AgentHub",
"desc": "Stop the dashboard so nothing runs in the background. Opening Claude Code no longer starts it; run `agenthub start` to bring it back.",
"button": "Quit",
"confirmTitle": "Quit AgentHub?",
"confirmMessage": "Every workspace terminal is closed — running agents stop — and the dashboard shuts down. It stays off until you run `agenthub start`.",
"confirmMessageAutostart": "Every workspace terminal is closed — running agents stop — and the dashboard shuts down. It stays off until you run `agenthub start`, or until the next boot/login (start automatically is on).",
"done": "AgentHub is stopping. Run `agenthub start` to start it again."
},
"about": {
"title": "About",
"description": "Server runtime information.",
"title": "System",
"release": "Release",
"uiBuild": "UI build v{{version}} (rebuild client to match)",
"uptime": "Uptime",
"nodejs": "Node.js",
"platform": "Platform",
"wsClients": "WS Clients",
"loadingInfo": "Loading server info..."
"wsClients": "Live connections",
"wsClientsDesc": "Browser tabs and tools receiving live updates",
"loadingInfo": "Loading server info...",
"server": "Server",
"cpu": "CPU",
"cpuValue": "{{count}} cores · load {{load}}",
"memory": "Process memory",
"memoryValue": "{{rss}} RSS · heap {{used}} of {{limit}}",
"systemMemory": "System memory",
"systemMemoryValue": "{{used}} used of {{total}}",
"install": "Installation",
"installKind": {
"plugin": "Claude Code plugin",
"checkout": "Git checkout"
},
"latest": "Latest release",
"upToDate": "Up to date",
"updateAvailable": "Update available",
"unknown": "Unknown",
"database": "Database",
"dbFile": "Database file",
"journal": "Journal mode",
"activity": "Events recorded",
"activityValue": "{{m5}} in 5 min · {{m15}} in 15 min · {{h1}} in 1 h"
},
"tables": {
"sessions": "sessions",
@@ -764,9 +800,7 @@
"group": {
"general": "General",
"agents": "Agents",
"routing": "Routing",
"git": "Git",
"cost": "Cost"
"data": "Data"
},
"section": {
"appearance": {
@@ -800,37 +834,25 @@
"title": "Pricing",
"description": "Model prices for every vendor, used to estimate cost. Rates per million tokens (USD); % is a wildcard in a pattern; a model's introductory rate is shown in violet."
},
"gitCredentials": {
"title": "Git credentials",
"description": "Logins AgentHub gives git for a dashboard fetch, pull or push."
},
"worktrees": {
"title": "Worktrees",
"description": "Which untracked git worktrees a workspace's Add folder suggests."
},
"data": {
"title": "Data",
"description": "Cost and database overview, import, remote sources, export, cleanup and the danger zone."
},
"about": {
"title": "About",
"description": "Server version, uptime and runtime."
},
"snapshots": {
"title": "Snapshots",
"description": "Frozen, read-only copies of sessions, each behind its own link."
"title": "Database",
"description": "Total cost, database overview, import, remote sources, export, cleanup and the danger zone."
},
"reports": {
"title": "Reports",
"description": "Analytics summaries written on a schedule, as HTML or JSON."
"title": "Snapshots & reports",
"description": "Read-only session snapshots behind their own links, and analytics summaries written on a schedule."
},
"users": {
"title": "Users",
"description": "Accounts, their usage and the audit log."
"system": {
"title": "System",
"description": "Version, install, start at login, server runtime and the database file."
},
"git": {
"title": "Git",
"description": "Logins AgentHub gives git for a dashboard fetch, pull or push, and which worktrees Add folder suggests."
}
},
"backPrevious": "Back",
"unknownSection": "There is no “{{section}}” settings page — showing General instead."
"unknownSection": "There is no “{{section}}” settings page — showing Appearance instead."
},
"search": {
"placeholder": "Search settings",
@@ -856,6 +878,18 @@
"vi": "Tiếng Việt"
}
},
"terminalFont": {
"title": "Terminal font",
"description": "The font of the workspace terminals. Takes effect the next time a terminal opens. Saved in this browser only.",
"label": "Font",
"names": {
"jetbrains": "JetBrains Mono",
"maple": "Maple Mono (rounded)",
"system": "System font"
},
"systemNote": "Your browser's fixed-width font. It may lack some Vietnamese letters (ấ, ế, ố…), which then draw out of line.",
"preview": "Preview"
},
"rowMenu": {
"copyHost": "Copy host",
"copyUrl": "Copy endpoint",
@@ -947,20 +981,9 @@
"imported": "Imported {{count}} preference(s). Reloading…",
"badFile": "That file is not an AgentHub preferences file."
},
"users": {
"title": "Users",
"description": "Every account on this server, with its workspaces, sessions and recent usage.",
"name": "User",
"role": "Role",
"workspaces": "Workspaces",
"sessions": "Sessions",
"last7d": "7 days",
"last30d": "30 days",
"disabled": "disabled",
"tokens": "{{n}} tokens",
"manageHint": "Add, disable or change a user on the host: agenthub users …",
"auditTitle": "Audit log",
"auditDescription": "Each time an admin or analyst opened a session a member owns.",
"auditEmpty": "No reads recorded yet."
"pricingLink": {
"title": "Model pricing",
"description": "Prices for every vendor's models live on one page.",
"open": "Open Pricing"
}
}
+73
View File
@@ -0,0 +1,73 @@
{
"title": "Users",
"description": "Every account on this server, with its workspaces, sessions and recent usage.",
"name": "User",
"role": "Role",
"workspaces": "Workspaces",
"sessions": "Sessions",
"last7d": "7 days",
"last30d": "30 days",
"disabled": "disabled",
"you": "you",
"tokens": "{{n}} tokens",
"actions": "Actions",
"roles": {
"admin": "Admin",
"analyst": "Analyst",
"member": "Member"
},
"roleHint": {
"admin": "Everything, including this server's configuration and every user's data",
"analyst": "Reads everything; no terminal, no changes",
"member": "Only their own workspaces"
},
"add": "Add user",
"username": "Username",
"usernameHint": "Letters, digits, '.', '_' or '-'",
"password": "Password",
"passwordHint": "At least 8 characters",
"newPassword": "New password",
"changeRole": "Change role",
"disable": "Disable",
"enable": "Enable",
"resetPassword": "Reset password",
"assignWorkspace": "Give a workspace",
"workspace": "Workspace",
"noOwner": "no owner",
"ownedBy": "owned by {{name}}",
"noWorkspaces": "There are no workspaces yet.",
"yourPassword": "Your password",
"yourPasswordHint": "Type your own password to confirm this change.",
"save": "Save",
"dialog": {
"add": "Add a user",
"role": "Change {{name}}'s role",
"disable": "Disable {{name}}?",
"disableBody": "{{name}} is signed out everywhere and cannot sign in until enabled again. Their workspaces and history stay.",
"enable": "Enable {{name}}?",
"password": "Reset {{name}}'s password",
"passwordBody": "{{name}} is signed out everywhere and signs in with the new password.",
"workspace": "Give {{name}} a workspace",
"workspaceBody": "The workspace's sessions and costs move to {{name}}. A member sees only their own workspaces."
},
"done": {
"created": "{{name}} added",
"role": "{{name}} is now {{role}}",
"disabled": "{{name}} disabled",
"enabled": "{{name}} enabled",
"password": "{{name}}'s password reset",
"workspace": "Workspace given to {{name}}"
},
"errors": {
"EREAUTH": "Your password is wrong.",
"ETOOMANY": "Too many wrong passwords. Try again in 15 minutes.",
"ESELF": "You cannot change your own role or disable yourself.",
"EEXISTS": "That username is taken.",
"EBADUSER": "Username: letters, digits, '.', '_' or '-' (up to 64).",
"EBADPASSWORD": "Password: at least 8 characters."
},
"readOnly": "Analysts can view accounts but not change them.",
"auditTitle": "Audit log",
"auditDescription": "Account changes made here, and each time an admin or analyst opened a session a member owns.",
"auditEmpty": "Nothing recorded yet."
}
+30 -15
View File
@@ -58,11 +58,7 @@
"changes.collapseContext": "Bớt dòng xung quanh",
"changes.collapseFile": "Thu gọn {{path}}",
"changes.commitPlaceholder": "Nội dung commit",
"changes.commitStaged": "Commit {{count}} file đã stage",
"changes.suggestMessage": "Gợi ý nội dung commit bằng AI (Claude, các thay đổi đã stage)",
"changes.neverStage": "Không bao giờ stage — Stage all bỏ qua file này",
"changes.allowStage": "Cho Stage all stage lại file này",
"changes.keptHint": "Stage all không bao giờ stage file này (trên trình duyệt này)",
"changes.suggestMessage": "Gợi ý nội dung commit bằng AI (Claude, các file đã tick)",
"changes.conflicted": "xung đột",
"changes.discard": "Bỏ thay đổi của file này",
"changes.discardConfirm": "Bấm lần nữa để bỏ — không thể hoàn tác",
@@ -79,6 +75,7 @@
"changes.commentLine": "Comment dòng {{n}} — dán vào pane của workspace này",
"changes.commentOn": "Comment {{at}} — dán vào pane bạn dùng gần nhất; chưa gửi gì cho tới khi bạn nhấn Enter ở đó",
"changes.commentSend": "Dán vào pane",
"changes.addToChat": "Thêm vào chat",
"changes.commentCancel": "Huỷ",
"changes.commentNoPane": "Chưa có terminal nào của workspace này đang hiện — hãy mở session (hoặc thu hẹp khung Changes) trước",
"changes.layout.split": "Hai cột",
@@ -105,13 +102,7 @@
"changes.size.full": "Toàn màn hình",
"changes.fileCount_one": "{{count}} tệp",
"changes.fileCount_other": "{{count}} tệp",
"changes.stageFolder": "Stage mọi tệp trong thư mục này",
"changes.unstageFolder": "Bỏ stage mọi tệp trong thư mục này",
"changes.stageAll": "Stage tất cả ({{count}})",
"changes.unstageAll": "Bỏ stage tất cả ({{count}})",
"changes.dropEdits": "Bỏ các chỉnh sửa chưa lưu của file này?",
"changes.stagedGroup": "Đã stage",
"changes.unstagedGroup": "Thay đổi",
"changes.stage": "Đưa file này vào index",
"changes.title": "Thay đổi",
"changes.truncated": "Một phần thay đổi đã bị lược bớt để bảng này còn mượt.",
@@ -236,10 +227,8 @@
"tooltipStart": "Tạo một lần chạy ở chế độ hội thoại mà không có lời nhắc ban đầu; được điều khiển từ CLI hoặc qua tin nhắn",
"worktrees.heading_one": "{{count}} worktree",
"worktrees.heading_other": "{{count}} worktree",
"changes.stageHunk": "Stage đoạn này",
"changes.undoHunk": "Hoàn tác đoạn này",
"changes.undoHunkConfirm": "Bấm lần nữa để hoàn tác đoạn này — không thể khôi phục",
"changes.stageHunkShort": "Giữ",
"changes.undoHunkShort": "Hoàn tác",
"changes.undoHunkShortConfirm": "Xác nhận",
"changes.edit": "Sửa",
@@ -285,7 +274,7 @@
"changes.mergeAbortConfirm": "Bấm lần nữa để huỷ {{op}}",
"changes.discardShortConfirm": "Xoá?",
"changes.amend": "Amend",
"changes.amendHint": "Gộp phần đã stage vào commit cuối (chỉ khi chưa push). Để trống message để giữ message cũ.",
"changes.amendHint": "Gộp các file đã tick vào commit cuối (chỉ khi chưa push). Không tick file nào thì chỉ sửa message; để trống message để giữ message cũ.",
"changes.amendPlaceholder": "Message mới (trống = giữ message cũ)",
"changes.historyOf": "Lịch sử của nhánh",
"changes.cherryPick": "Cherry-pick vào {{branch}}",
@@ -435,6 +424,7 @@
"panes.splitNew.right": "Chia sang phải với terminal mới",
"panes.splitNew.bottom": "Chia xuống dưới với terminal mới",
"panes.closeShort": "Tách ra tab riêng",
"panes.stop": "Dừng phiên",
"changes.copyPath": "Sao chép đường dẫn tương đối",
"changes.viewCommit": "Xem commit",
"changes.copySha": "Sao chép SHA của commit",
@@ -475,5 +465,30 @@
"runtime.listening": "đang lắng nghe",
"runtime.notListening": "không lắng nghe",
"replay.pasted": "Session đó đã mở trong một tab — tin nhắn đã được dán vào đó; nhấn Enter để gửi.",
"replay.notPasted": "Session đó đang mở trong một tab nhưng không dán được tin nhắn vào đó: {{text}}"
"replay.notPasted": "Session đó đang mở trong một tab nhưng không dán được tin nhắn vào đó: {{text}}",
"changes.changesGroup": "Thay đổi",
"changes.unversionedGroup": "File chưa theo dõi",
"changes.commitTicked": "Commit ({{count}})",
"changes.commitAndPush": "Commit và Push",
"changes.amendAndPush": "Amend và Push",
"changes.pushFailed": "Đã commit, nhưng push thất bại: {{error}}",
"changes.tickFile": "Đưa {{path}} vào commit",
"changes.tickFolder": "Tick mọi file trong {{path}}",
"changes.commitFile": "Commit file…",
"changes.jumpToSource": "Mở mã nguồn",
"changes.copyPatch": "Sao chép dạng patch",
"changes.noPatch": "File này không có diff dạng văn bản để sao chép",
"changes.addToVcs": "Thêm vào VCS",
"changes.addToGitignore": "Thêm vào .gitignore",
"changes.includeBlock": "Đưa thay đổi này vào commit",
"changes.prevDiff": "Khác biệt trước (Shift+F7)",
"changes.nextDiff": "Khác biệt tiếp theo (F7)",
"changes.prevFile": "File trước",
"changes.nextFile": "File tiếp theo",
"changes.showHistory": "Xem lịch sử",
"changes.historyOfFile": "Lịch sử của {{path}}",
"changes.historyWholeBranch": "Cả nhánh",
"changes.annotate": "Annotate",
"changes.noBlame": "Không có gì để annotate: file này chưa có lịch sử commit",
"changes.notCommitted": "Chưa commit"
}
+3 -1
View File
@@ -81,6 +81,7 @@
"quotaFailed": "Không đọc được quota",
"quotaWrongAccount": "Lượt đăng nhập này thuộc tài khoản Claude khác: đăng nhập lại đúng tài khoản này rồi lưu lại",
"quotaActive": "đang dùng",
"quotaTokenExpired": "Đăng nhập đã hết hạn: dùng công cụ một lần để nó đăng nhập lại",
"quotaProfileExpired": "Đăng nhập đã hết hạn: bấm Kiểm tra quota trong Claude Code → Profiles để làm mới",
"quotaSignInExpires": "Đăng nhập hết hạn sau {{time}}",
"quotaSignInExpired": "Đăng nhập đã hết hạn",
@@ -94,5 +95,6 @@
"resizeSidebar": "Kéo để đổi độ rộng sidebar (nhấp đúp để đặt lại)",
"closeMenu": "Đóng menu",
"sidebar": "Thanh bên",
"now": "bây giờ"
"now": "bây giờ",
"users": "Người dùng"
}
+70 -47
View File
@@ -137,7 +137,8 @@
"incomplete": "Hooks chưa đầy đủ",
"reinstall": "Cài lại hooks",
"success": "Cài hooks thành công",
"failed": "Cài hooks thất bại"
"failed": "Cài hooks thất bại",
"plugin": "Do plugin agenthub cung cấp (trong plugin.json của nó), không nằm trong settings.json"
},
"lanesSection": {
"title": "Workspace",
@@ -311,16 +312,51 @@
"unavailable": "Không khả dụng ở đây: cần systemd --user trên Linux, hoặc phiên đăng nhập GUI trên macOS.",
"foreign": "Đã có một service tên agenthub không do AgentHub tạo — AgentHub không đụng vào."
},
"tray": {
"label": "Hiện icon AgentHub trên thanh trạng thái",
"desc": "Icon AgentHub cạnh đồng hồ (thanh trên cùng, menu bar hoặc khay hệ thống) để mở dashboard, thoát, và bật lại sau khi thoát.",
"unavailable": "Không khả dụng ở đây: cần phiên desktop có giao diện đồ hoạ, và luôn tắt ở server mode."
},
"quit": {
"label": "Thoát AgentHub",
"desc": "Tắt dashboard để không còn chạy ngầm. Mở Claude Code sẽ không tự bật lại; chạy `agenthub start` để bật lại.",
"button": "Thoát",
"confirmTitle": "Thoát AgentHub?",
"confirmMessage": "Mọi terminal của các workspace sẽ bị đóng — agent đang chạy sẽ dừng — và dashboard tắt. Nó giữ tắt cho tới khi bạn chạy `agenthub start`.",
"confirmMessageAutostart": "Mọi terminal của các workspace sẽ bị đóng — agent đang chạy sẽ dừng — và dashboard tắt. Nó giữ tắt cho tới khi bạn chạy `agenthub start`, hoặc tới lần khởi động/đăng nhập máy kế tiếp (đang bật tự khởi động).",
"done": "AgentHub đang tắt. Chạy `agenthub start` để bật lại."
},
"about": {
"title": "Giới thiệu",
"description": "Thông tin runtime của máy chủ.",
"title": "Hệ thống",
"release": "Phiên bản",
"uiBuild": "UI build v{{version}} (hãy build lại client để khớp)",
"uptime": "Thời gian chạy",
"nodejs": "Node.js",
"platform": "Nền tảng",
"wsClients": "WS Clients",
"loadingInfo": "Đang tải thông tin máy chủ..."
"wsClients": "Kết nối trực tiếp",
"wsClientsDesc": "Các tab trình duyệt và công cụ đang nhận cập nhật trực tiếp",
"loadingInfo": "Đang tải thông tin máy chủ...",
"server": "Máy chủ",
"cpu": "CPU",
"cpuValue": "{{count}} lõi · tải {{load}}",
"memory": "Bộ nhớ tiến trình",
"memoryValue": "{{rss}} RSS · heap {{used}} / {{limit}}",
"systemMemory": "Bộ nhớ hệ thống",
"systemMemoryValue": "Đã dùng {{used}} / {{total}}",
"install": "Cài đặt",
"installKind": {
"plugin": "Plugin Claude Code",
"checkout": "Git checkout"
},
"latest": "Bản phát hành mới nhất",
"upToDate": "Đã mới nhất",
"updateAvailable": "Có bản cập nhật",
"unknown": "Không rõ",
"database": "Cơ sở dữ liệu",
"dbFile": "Tệp cơ sở dữ liệu",
"journal": "Chế độ journal",
"activity": "Sự kiện đã ghi",
"activityValue": "{{m5}} trong 5 phút · {{m15}} trong 15 phút · {{h1}} trong 1 giờ"
},
"tables": {
"sessions": "phiên",
@@ -764,9 +800,7 @@
"group": {
"general": "Chung",
"agents": "Tác tử",
"routing": "Định tuyến",
"git": "Git",
"cost": "Chi phí"
"data": "Dữ liệu"
},
"section": {
"appearance": {
@@ -800,37 +834,25 @@
"title": "Định giá",
"description": "Giá mô hình của mọi nhà cung cấp, dùng để ước tính chi phí. Đơn giá theo một triệu token (USD); % là ký tự đại diện trong mẫu; giá giới thiệu hiển thị màu tím."
},
"gitCredentials": {
"title": "Git credentials",
"description": "Thông tin đăng nhập AgentHub đưa cho git khi fetch, pull hoặc push từ dashboard."
},
"worktrees": {
"title": "Worktree",
"description": "Những git worktree chưa theo dõi được gợi ý khi thêm thư mục cho workspace."
},
"data": {
"title": "Dữ liệu",
"description": "Tổng quan chi phí và cơ sở dữ liệu, nhập, nguồn từ xa, xuất, dọn dẹp và vùng nguy hiểm."
},
"about": {
"title": "Giới thiệu",
"description": "Phiên bản máy chủ, thời gian hoạt động và môi trường chạy."
},
"snapshots": {
"title": "Snapshot",
"description": "Các bản sao chỉ đọc, đã đóng băng của phiên, mỗi bản có liên kết riêng."
"title": "Cơ sở dữ liệu",
"description": "Tổng chi phí, tổng quan cơ sở dữ liệu, nhập, nguồn từ xa, xuất, dọn dẹp và vùng nguy hiểm."
},
"reports": {
"title": "Báo cáo",
"description": "Bản tóm tắt phân tích ghi ra theo lịch, dạng HTML hoặc JSON."
"title": "Snapshot & báo cáo",
"description": "Bản snapshot phiên chỉ đọc, mỗi bản một liên kết riêng, và bản tóm tắt phân tích ghi ra theo lịch."
},
"users": {
"title": "Người dùng",
"description": "Tài khoản, mức dùng và nhật ký truy cập."
"system": {
"title": "Hệ thống",
"description": "Phiên bản, cách cài đặt, tự khởi động, runtime máy chủ và tệp cơ sở dữ liệu."
},
"git": {
"title": "Git",
"description": "Thông tin đăng nhập AgentHub đưa cho git khi fetch, pull, push từ dashboard, và các worktree được gợi ý khi Thêm thư mục."
}
},
"backPrevious": "Quay lại",
"unknownSection": "Không có trang cài đặt “{{section}}” — đang hiển thị trang Chung."
"unknownSection": "Không có trang cài đặt “{{section}}” — đang hiển thị trang Giao diện."
},
"search": {
"placeholder": "Tìm cài đặt",
@@ -856,6 +878,18 @@
"vi": "Tiếng Việt"
}
},
"terminalFont": {
"title": "Font terminal",
"description": "Font của các terminal trong workspace. Áp dụng từ lần mở terminal tiếp theo. Chỉ lưu trong trình duyệt này.",
"label": "Font",
"names": {
"jetbrains": "JetBrains Mono",
"maple": "Maple Mono (bo tròn)",
"system": "Font hệ thống"
},
"systemNote": "Font độ rộng cố định của trình duyệt. Font này có thể thiếu một số chữ tiếng Việt (ấ, ế, ố…), khi đó chữ sẽ bị lệch ô.",
"preview": "Xem trước"
},
"rowMenu": {
"copyHost": "Sao chép host",
"copyUrl": "Sao chép endpoint",
@@ -947,20 +981,9 @@
"imported": "Đã nhập {{count}} tùy chọn. Đang tải lại…",
"badFile": "Tệp này không phải tệp tùy chọn của AgentHub."
},
"users": {
"title": "Người dùng",
"description": "Mọi tài khoản trên server này, cùng workspace, session và mức dùng gần đây.",
"name": "Người dùng",
"role": "Vai trò",
"workspaces": "Workspace",
"sessions": "Session",
"last7d": "7 ngày",
"last30d": "30 ngày",
"disabled": "đã khoá",
"tokens": "{{n}} token",
"manageHint": "Thêm, khoá hoặc sửa người dùng trên máy chủ: agenthub users …",
"auditTitle": "Nhật ký truy cập",
"auditDescription": "Mỗi lần admin hoặc analyst mở một session của member.",
"auditEmpty": "Chưa có lượt đọc nào."
"pricingLink": {
"title": "Giá model",
"description": "Giá model của mọi nhà cung cấp nằm chung một trang.",
"open": "Mở trang Giá"
}
}
+73
View File
@@ -0,0 +1,73 @@
{
"title": "Người dùng",
"description": "Mọi tài khoản trên server này, kèm workspace, phiên và mức dùng gần đây.",
"name": "Người dùng",
"role": "Vai trò",
"workspaces": "Workspace",
"sessions": "Phiên",
"last7d": "7 ngày",
"last30d": "30 ngày",
"disabled": "đã khoá",
"you": "bạn",
"tokens": "{{n}} token",
"actions": "Thao tác",
"roles": {
"admin": "Admin",
"analyst": "Analyst",
"member": "Thành viên"
},
"roleHint": {
"admin": "Toàn quyền, kể cả cấu hình của server và dữ liệu của mọi người",
"analyst": "Xem được mọi thứ; không có terminal, không thay đổi gì",
"member": "Chỉ workspace của chính mình"
},
"add": "Thêm người dùng",
"username": "Tên đăng nhập",
"usernameHint": "Chữ, số, '.', '_' hoặc '-'",
"password": "Mật khẩu",
"passwordHint": "Ít nhất 8 ký tự",
"newPassword": "Mật khẩu mới",
"changeRole": "Đổi vai trò",
"disable": "Khoá",
"enable": "Mở khoá",
"resetPassword": "Đặt lại mật khẩu",
"assignWorkspace": "Giao workspace",
"workspace": "Workspace",
"noOwner": "chưa có chủ",
"ownedBy": "của {{name}}",
"noWorkspaces": "Chưa có workspace nào.",
"yourPassword": "Mật khẩu của bạn",
"yourPasswordHint": "Nhập mật khẩu của chính bạn để xác nhận thay đổi này.",
"save": "Lưu",
"dialog": {
"add": "Thêm người dùng",
"role": "Đổi vai trò của {{name}}",
"disable": "Khoá {{name}}?",
"disableBody": "{{name}} bị đăng xuất khỏi mọi nơi và không đăng nhập được cho tới khi mở khoá. Workspace và lịch sử vẫn giữ nguyên.",
"enable": "Mở khoá {{name}}?",
"password": "Đặt lại mật khẩu cho {{name}}",
"passwordBody": "{{name}} bị đăng xuất khỏi mọi nơi và đăng nhập lại bằng mật khẩu mới.",
"workspace": "Giao workspace cho {{name}}",
"workspaceBody": "Các phiên và chi phí của workspace chuyển sang {{name}}. Thành viên chỉ thấy workspace của chính mình."
},
"done": {
"created": "Đã thêm {{name}}",
"role": "{{name}} giờ là {{role}}",
"disabled": "Đã khoá {{name}}",
"enabled": "Đã mở khoá {{name}}",
"password": "Đã đặt lại mật khẩu cho {{name}}",
"workspace": "Đã giao workspace cho {{name}}"
},
"errors": {
"EREAUTH": "Mật khẩu của bạn không đúng.",
"ETOOMANY": "Nhập sai mật khẩu quá nhiều lần. Thử lại sau 15 phút.",
"ESELF": "Bạn không thể tự đổi vai trò hay tự khoá chính mình.",
"EEXISTS": "Tên đăng nhập này đã có người dùng.",
"EBADUSER": "Tên đăng nhập: chữ, số, '.', '_' hoặc '-' (tối đa 64 ký tự).",
"EBADPASSWORD": "Mật khẩu: ít nhất 8 ký tự."
},
"readOnly": "Analyst chỉ xem được tài khoản, không thay đổi được.",
"auditTitle": "Nhật ký audit",
"auditDescription": "Các thay đổi tài khoản thực hiện ở đây, và mỗi lần admin hoặc analyst mở phiên của một thành viên.",
"auditEmpty": "Chưa ghi nhận gì."
}
+1 -1
View File
@@ -82,7 +82,7 @@
--syn-del: 192 31 42; /* #c01f2a */
/* A diff line edited in place (not wholly added or removed). */
--diff-mod: 29 78 216; /* blue-700 #1d4ed8 */
/* A file on the Changes panel's never-stage list (orange, apart from amber M). */
/* An unversioned (untracked) file in the Changes panel, JetBrains' orange. */
--kept: 194 65 12; /* orange-700 #c2410c */
}
@@ -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;
+5
View File
@@ -7,6 +7,7 @@
* ## Fonts
* Only Latin subsets of Inter and JetBrains Mono are imported so Vite bundles
* lean WOFF2 files instead of every glyph subset Google Fonts would serve.
* Maple Mono (the terminal's optional rounded font) ships as one file per weight.
*
* ## Service worker reload policy
* On first visit there is no controlling SW yet — we must not reload when the
@@ -62,6 +63,10 @@ import "@fontsource/jetbrains-mono/latin-500.css";
// visually mangled inside xterm's fixed-width cells.
import "@fontsource/jetbrains-mono/vietnamese-400.css";
import "@fontsource/jetbrains-mono/vietnamese-500.css";
// The rounded terminal font (Settings → Appearance). Its one file already holds
// the Vietnamese glyphs; a face is only downloaded once something uses it.
import "@fontsource/maple-mono/400.css";
import "@fontsource/maple-mono/700.css";
import App from "./App";
import { AuthGate } from "./components/AuthGate";
import { i18nReady } from "./i18n";
+2 -2
View File
@@ -27,7 +27,7 @@ import type { Lane } from "../lib/types";
import { useAiTools } from "../hooks/useAiTools";
import { AiToolIcon } from "../components/AiToolIcon";
import { ModelPicker } from "../components/run/RunSetup";
import { PricingSettings } from "../components/settings/PricingSettings";
import { PricingLink } from "../components/settings/PricingSettings";
import { AntigravityHooksSettings } from "../components/settings/AntigravityHooksSettings";
/** Cards one tool adds below the generic ones. */
@@ -307,7 +307,7 @@ export function AiToolConfig({ toolId }: { toolId: string }) {
<DefaultModel toolId={toolId} saved={tool.defaultModel ?? ""} />
</Card>
)}
{tool.modelVendor && <PricingSettings vendor={tool.modelVendor} id={`${toolId}-pricing`} />}
{tool.modelVendor && <PricingLink vendor={tool.modelVendor} />}
{tool.configFiles > 0 && (
<Card title={t("toolTab.config")} Icon={FileCog}>
<ConfigFiles toolId={toolId} />
+3 -3
View File
@@ -11,7 +11,7 @@
* union is exactly "user" | "project". Its **Overview** also holds the
* dashboard's own Codex settings: CODEX_HOME (editable, so the overview's path
* list leaves it out), AgentHub's Codex hooks (`CodexHooksSettings`) and Model
* Pricing for OpenAI models (the shared `PricingSettings` table).
* A link to the OpenAI table on the Pricing page (`PricingLink`).
* Every card with action buttons also opens a right-click menu repeating
* them (same handlers — Delete still asks for confirmation) plus Copy
* name/path.
@@ -70,7 +70,7 @@ import {
import { ConfigTabBar } from "../components/config/ConfigTabBar";
import { ProjectMemoryModal } from "../components/config/ProjectMemoryModal";
import { HomeDirSetting } from "../components/settings/HomeDirSetting";
import { PricingSettings } from "../components/settings/PricingSettings";
import { PricingLink } from "../components/settings/PricingSettings";
import { CodexHooksSettings } from "../components/settings/CodexHooksSettings";
import {
Empty,
@@ -530,7 +530,7 @@ export function CodexConfig() {
}
/>
<CodexHooksSettings key={hooksKey} />
<PricingSettings vendor="openai" id="codex-pricing" />
<PricingLink vendor="openai" />
</div>
)}
+158 -90
View File
@@ -1,6 +1,6 @@
/**
* @file Settings.tsx
* @description The install-wide settings, as three pages of the Settings nav (`SettingsHub.tsx`): `section="data"` — total cost, database overview, workspace housekeeping (dry run first, confirm before a real run), import, remote sources, push ingest (`components/settings/PushIngestSettings.tsx`), privacy (`components/settings/PrivacySettings.tsx`), export, this browser's preferences backup, session cleanup (whole days ≥ 1, a dry-run count in its confirm) and the danger zone (typed confirmation) —, `section="about"` — the start-at-login switch (`AutostartSettings`) and system information — and `section="notifications"` — browser notifications (per-browser, with a reset) and alerts (server-wide). Server info (`/api/settings/info`) is read only by Data and About, refreshed on activity at most every 10 s. Language, theme and the pet live on the Appearance page (`components/settings/AppearanceSettings.tsx`); per-tool settings live in that tool's page under `/settings/agents/<id>`.
* @description The install-wide settings, as three pages of the Settings nav (`SettingsHub.tsx`): `section="data"` — total cost, database overview, workspace housekeeping (dry run first, confirm before a real run), import, remote sources, push ingest (`components/settings/PushIngestSettings.tsx`), privacy (`components/settings/PrivacySettings.tsx`), export, this browser's preferences backup, session cleanup (whole days ≥ 1, a dry-run count in its confirm) and the danger zone (typed confirmation) —, `section="about"` (the System page) — the start-at-login switch and Quit (`AutostartSettings`), then the server process (release, uptime, platform, CPU, memory, live connections), how AgentHub is installed (the update check's cached status) and the database file — and `section="notifications"` — browser notifications (per-browser, with a reset) and alerts (server-wide). Server info (`/api/settings/info`) is read only by Data and About, refreshed on activity at most every 10 s. Language, theme and the pet live on the Appearance page (`components/settings/AppearanceSettings.tsx`); per-tool settings live in that tool's page under `/settings/agents/<id>`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
@@ -55,7 +55,7 @@
* ----------------------------------------------------------------------------- */
import { Checkbox } from "../components/Checkbox";
import { useEffect, useRef, useState, useCallback } from "react";
import { useEffect, useRef, useState, useCallback, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import {
DollarSign,
@@ -64,7 +64,6 @@ import {
HardDrive,
AlertTriangle,
RotateCcw,
Server,
Bell,
BellOff,
BellRing,
@@ -76,10 +75,6 @@ import {
ShieldCheck,
ShieldAlert,
ShieldX,
Clock,
Cpu,
Globe,
Wifi,
Activity,
Users,
Layers,
@@ -116,7 +111,7 @@ import {
SettingsSegmented,
SettingsSwitchRow,
} from "../components/settings/SettingsRow";
import type { WSMessage } from "../lib/types";
import type { UpdateStatusPayload, WSMessage } from "../lib/types";
import { confirmDialog } from "../lib/confirm";
import { toast } from "../lib/toast";
import { useDialog } from "../hooks/useDialog";
@@ -145,22 +140,13 @@ const NOTIF_TYPES: [NotifType, string][] = [
// ─── Helpers ───
interface SystemInfo {
db: { path: string; size: number; counts: Record<string, number> };
hooks: { installed: boolean; path: string; hooks: Record<string, boolean> };
server: {
version: string;
uptime: number;
node_version: string;
platform: string;
ws_connections: number;
};
}
type SystemInfo = Awaited<ReturnType<typeof api.settings.info>>;
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
if (bytes < 1024 ** 3) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / 1024 ** 3).toFixed(1)} GB`;
}
function formatUptime(seconds: number): string {
@@ -1133,85 +1119,167 @@ export function Settings({
)}
{section === "about" && (
<section id="about" className="scroll-mt-24">
<div className="mb-4">
<AutostartSettings />
</div>
<p className="text-xs text-fg-muted mb-4">{t("about.description")}</p>
<div id="about" className="space-y-6 scroll-mt-24">
<AutostartSettings />
{sysInfo ? (
<div className="card p-5">
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
<div className="bg-surface-2 rounded-lg px-4 py-3">
<div className="flex items-center gap-2 mb-1.5">
<Server className="w-4 h-4 text-accent" />
<p className="text-[11px] text-fg-muted uppercase tracking-wider">
{t("about.release")}
</p>
</div>
<p className="text-sm font-semibold text-fg-secondary font-mono">
v{sysInfo.server.version}
</p>
{sysInfo.server.version !== __APP_VERSION__ && (
<p className="text-[10px] text-status-warning/90 mt-1">
{t("about.uiBuild", { version: __APP_VERSION__ })}
</p>
)}
</div>
<div className="bg-surface-2 rounded-lg px-4 py-3">
<div className="flex items-center gap-2 mb-1.5">
<Clock className="w-4 h-4 text-accent" />
<p className="text-[11px] text-fg-muted uppercase tracking-wider">
{t("about.uptime")}
</p>
</div>
<p className="text-sm font-semibold text-fg-secondary">
{formatUptime(sysInfo.server.uptime)}
</p>
</div>
<div className="bg-surface-2 rounded-lg px-4 py-3">
<div className="flex items-center gap-2 mb-1.5">
<Cpu className="w-4 h-4 text-status-success" />
<p className="text-[11px] text-fg-muted uppercase tracking-wider">
{t("about.nodejs")}
</p>
</div>
<p className="text-sm font-semibold text-fg-secondary font-mono">
{sysInfo.server.node_version}
</p>
</div>
<div className="bg-surface-2 rounded-lg px-4 py-3">
<div className="flex items-center gap-2 mb-1.5">
<Globe className="w-4 h-4 text-syntax-keyword" />
<p className="text-[11px] text-fg-muted uppercase tracking-wider">
{t("about.platform")}
</p>
</div>
<p className="text-sm font-semibold text-fg-secondary">
{sysInfo.server.platform}
</p>
</div>
<div className="bg-surface-2 rounded-lg px-4 py-3">
<div className="flex items-center gap-2 mb-1.5">
<Wifi className="w-4 h-4 text-status-warning" />
<p className="text-[11px] text-fg-muted uppercase tracking-wider">
{t("about.wsClients")}
</p>
</div>
<p className="text-sm font-semibold text-fg-secondary">
{sysInfo.server.ws_connections}
</p>
</div>
</div>
</div>
<SystemDetails info={sysInfo} />
) : (
<p className="text-xs text-fg-muted">{t("about.loadingInfo")}</p>
)}
</section>
</div>
)}
</div>
);
}
/** A read-only value on the right of a System row. */
function Value({ children, mono = false }: { children: ReactNode; mono?: boolean }) {
return (
<span className={`text-sm text-fg-primary ${mono ? "font-mono text-xs" : ""}`}>{children}</span>
);
}
/**
* The System page's facts: the server process, how AgentHub is installed (from
* the update check's last answer — it never fetches), and the database file.
*/
function SystemDetails({ info }: { info: SystemInfo }) {
const { t } = useTranslation("settings");
const [upd, setUpd] = useState<UpdateStatusPayload | null>(null);
useEffect(() => {
api.updates
.status()
.then(setUpd)
.catch(() => {});
}, []);
const srv = info.server;
const mem = srv.memory;
const heapLimit = mem ? (mem.heapLimit ?? mem.heapTotal) : 0;
const load = srv.cpu_load?.map((n) => n.toFixed(2)).join(" / ");
const kind = upd?.install_kind;
return (
<>
<SettingsSection id="server" title={t("about.server")}>
<SettingsRow
label={t("about.release")}
description={
srv.version !== __APP_VERSION__ ? (
<span className="text-status-warning/90">
{t("about.uiBuild", { version: __APP_VERSION__ })}
</span>
) : undefined
}
control={<Value mono>v{srv.version}</Value>}
/>
<SettingsRow
label={t("about.uptime")}
control={<Value>{formatUptime(srv.uptime)}</Value>}
/>
<SettingsRow
label={t("about.platform")}
control={
<Value mono>
{srv.platform}
{srv.arch ? ` · ${srv.arch}` : ""} · Node.js {srv.node_version}
</Value>
}
/>
{srv.cpus > 0 && (
<SettingsRow
label={t("about.cpu")}
control={<Value>{t("about.cpuValue", { count: srv.cpus, load })}</Value>}
/>
)}
{mem && (
<SettingsRow
label={t("about.memory")}
control={
<Value>
{t("about.memoryValue", {
rss: formatBytes(mem.rss),
used: formatBytes(mem.heapUsed),
limit: formatBytes(heapLimit),
})}
</Value>
}
/>
)}
{srv.total_mem > 0 && (
<SettingsRow
label={t("about.systemMemory")}
control={
<Value>
{t("about.systemMemoryValue", {
used: formatBytes(srv.total_mem - srv.free_mem),
total: formatBytes(srv.total_mem),
})}
</Value>
}
/>
)}
<SettingsRow
label={t("about.wsClients")}
description={t("about.wsClientsDesc")}
control={<Value>{srv.ws_connections}</Value>}
/>
</SettingsSection>
{upd && (
<SettingsSection id="install" title={t("about.install")}>
<SettingsRow
label={kind ? t(`about.installKind.${kind}`) : t("about.unknown")}
description={
upd.repo_root ? (
<span className="font-mono break-all">{upd.repo_root}</span>
) : undefined
}
control={upd.local_version ? <Value mono>v{upd.local_version}</Value> : null}
/>
{upd.latest_release && (
<SettingsRow
label={t("about.latest")}
control={
<span className="flex items-center gap-2">
<Value mono>{upd.latest_release}</Value>
<span
className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${
upd.update_available
? "bg-status-warning/10 text-status-warning"
: "bg-status-success/10 text-status-success"
}`}
>
{upd.update_available ? t("about.updateAvailable") : t("about.upToDate")}
</span>
</span>
}
/>
)}
</SettingsSection>
)}
<SettingsSection id="database" title={t("about.database")}>
<SettingsRow
label={t("about.dbFile")}
description={<span className="font-mono break-all">{info.db.path}</span>}
control={<Value>{formatBytes(info.db.size)}</Value>}
/>
{info.db.pragmas && (
<SettingsRow
label={t("about.journal")}
control={<Value mono>{info.db.pragmas.journal_mode}</Value>}
/>
)}
{info.db.load_stats && (
<SettingsRow
label={t("about.activity")}
control={<Value>{t("about.activityValue", info.db.load_stats)}</Value>}
/>
)}
</SettingsSection>
</>
);
}
/** "Clear all data": the one action here that empties the whole database, so
* it asks for a typed phrase (not a second click) and offers an export first. */
function ClearDataDialog({
+175 -178
View File
@@ -1,7 +1,7 @@
/**
* @file SettingsHub.tsx
* @description The Settings page: `/settings/<section>`, a left nav grouped
* General / Agents / Routing / Cost / Git (a `<select>` below `md`), a
* General / Agents / Data (a `<select>` below `md`), a
* search box over `SETTINGS_INDEX` (`lib/settingsSearch.ts`; Ctrl/Cmd+F
* focuses it — and selects its text when it already has focus —, a result list
* deep-links a row's sub-tab and anchor, Enter opens the first result, Escape
@@ -10,7 +10,10 @@
* Back returns to the page before Settings when there is one, else /workspace;
* an unknown section shows a skeleton until the tool list loads, then toasts
* and falls back to Appearance (which also holds the language and the pet;
* `general` and `pet` redirect there). A tool's own settings live under `agents/<id>` — Claude Code and
* `general` and `pet` redirect there). Small pages share one: System is the old
* About (`about` redirects), Git holds git logins and worktree suggestions
* (`git-credentials`, `worktrees`), Snapshots & reports is `reports`
* (`snapshots`). A page's content is capped at a readable width. A tool's own settings live under `agents/<id>` — Claude Code and
* Codex have their own pages, every other installed, enabled catalog tool the
* generic `AiToolConfig` — and a tool's page is hidden while that tool is
* disabled. A workspace's own settings live on its home, not here.
@@ -31,17 +34,14 @@ import {
ArrowLeft,
Bell,
Database,
Info,
Bot,
Camera,
CalendarClock,
Coins,
GitBranch,
KeyRound,
Palette,
Router,
Search,
UsersRound,
Server,
} from "lucide-react";
import { Settings } from "./Settings";
import { CcConfig } from "./CcConfig";
@@ -66,9 +66,8 @@ import { Skeleton } from "../components/Skeleton";
import { AiToolIcon } from "../components/AiToolIcon";
import { toast } from "../lib/toast";
import { useAuth } from "../components/AuthGate";
import { UsersSettings } from "../components/settings/UsersSettings";
const GROUPS = ["general", "agents", "routing", "cost", "git"] as const;
const GROUPS = ["general", "agents", "data"] as const;
type Group = (typeof GROUPS)[number];
interface Section {
@@ -86,7 +85,7 @@ const LEGACY_TOOL: Record<string, string> = {
"claude-code": "agents/claude-code",
codex: "agents/codex",
"ai-router": "ai-proxy",
"git-credentials": "git-credentials",
"git-credentials": "git",
};
/** Classes a deep-linked row wears for a moment after the hub scrolls to it. */
@@ -125,16 +124,15 @@ const MOVED: Record<string, string> = {
pet: "appearance#pet",
// The AI Router → AI Proxy rename.
"ai-router": "ai-proxy",
// Small pages merged into one.
about: "system",
snapshots: "reports#snapshots",
"git-credentials": "git",
worktrees: "git#worktrees",
};
/** Sections whose API a server-mode member may not reach (STAFF_ONLY_PREFIXES in server/lib/auth.js). */
const STAFF_SECTIONS = new Set([
"data",
"reports",
"agents/claude-code",
"agents/codex",
"git-credentials",
]);
const STAFF_SECTIONS = new Set(["data", "agents/claude-code", "agents/codex"]);
/** An unknown section: say so, then show Appearance. */
function UnknownSection({ name }: { name: string }) {
@@ -219,27 +217,12 @@ export function SettingsHub() {
const auth = useAuth();
const isMember = auth.state.mode === "server" && auth.state.user?.role === "member";
const isStaff = auth.state.mode === "server" && !isMember && Boolean(auth.state.user);
const enabled = (id: string) => aiTools.byId(id)?.enabled !== false;
const s = (key: string) => ({
title: t(`settingsNav.section.${key}.title`),
description: t(`settingsNav.section.${key}.description`),
});
const allSections: Section[] = [
{
id: "data",
group: "general",
Icon: Database,
...s("data"),
render: () => <Settings section="data" />,
},
{
id: "about",
group: "general",
Icon: Info,
...s("about"),
render: () => <Settings section="about" />,
},
{
id: "appearance",
group: "general",
@@ -247,31 +230,6 @@ export function SettingsHub() {
...s("appearance"),
render: () => <AppearanceSettings />,
},
{
id: "snapshots",
group: "general",
Icon: Camera,
...s("snapshots"),
render: () => <SnapshotSettings />,
},
{
id: "reports",
group: "general",
Icon: CalendarClock,
...s("reports"),
render: () => <ReportSettings />,
},
...(isStaff
? [
{
id: "users",
group: "general" as const,
Icon: UsersRound,
...s("users"),
render: () => <UsersSettings />,
},
]
: []),
{
id: "notifications",
group: "general",
@@ -279,6 +237,13 @@ export function SettingsHub() {
...s("notifications"),
render: () => <Settings section="notifications" />,
},
{
id: "system",
group: "general",
Icon: Server,
...s("system"),
render: () => <Settings section="about" />,
},
{
id: "agents",
group: "agents",
@@ -322,14 +287,21 @@ export function SettingsHub() {
})),
{
id: "ai-proxy",
group: "routing",
group: "agents",
Icon: Router,
...s("aiProxy"),
render: () => <AiProxyConfig />,
},
{
id: "data",
group: "data",
Icon: Database,
...s("data"),
render: () => <Settings section="data" />,
},
{
id: "pricing",
group: "cost",
group: "data",
Icon: Coins,
...s("pricing"),
render: () => (
@@ -354,18 +326,39 @@ export function SettingsHub() {
),
},
{
id: "git-credentials",
group: "git",
Icon: KeyRound,
...s("gitCredentials"),
render: () => <GitCredentialsConfig />,
id: "reports",
group: "data",
Icon: CalendarClock,
...s("reports"),
render: () => (
<div className="space-y-6">
<div id="snapshots" className="scroll-mt-24">
<SnapshotSettings />
</div>
{/* Scheduled reports read every user's sessions: staff only. */}
{!isMember && <ReportSettings />}
</div>
),
},
{
id: "worktrees",
group: "git",
id: "git",
group: "data",
Icon: GitBranch,
...s("worktrees"),
render: () => <WorktreeVisibilitySettings />,
...s("git"),
render: () => (
<div className="space-y-6">
{/* Git logins are the host's own: staff only. */}
{!isMember && (
<section id="git-credentials" className="scroll-mt-24">
<h2 className="mb-2 text-sm font-semibold text-fg-primary">{t("gitCreds.title")}</h2>
<GitCredentialsConfig />
</section>
)}
<div id="worktrees" className="scroll-mt-24">
<WorktreeVisibilitySettings />
</div>
</div>
),
},
];
// Server mode: a member cannot open the host's own configuration or the
@@ -429,128 +422,132 @@ export function SettingsHub() {
navigate(backDelta(historyIdx(), entryIdx.current ?? 0));
};
const searchBox = (
<label className="relative block">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-fg-muted" />
<input
ref={searchRef}
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && results[0]) navigate(hrefOf(results[0]));
if (e.key === "Escape") setQuery("");
}}
placeholder={t("search.placeholder")}
aria-label={t("search.placeholder")}
className="w-full rounded-lg border border-border bg-surface-1 py-1.5 pl-8 pr-2 text-sm text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
/>
</label>
);
const searchResults = ranked && results.length > 0 && (
<ul
aria-label={t("search.results")}
className="card mt-2 divide-y divide-border/60 overflow-hidden"
>
{results.map((r, i) => (
<li key={`${r.section}${r.to ?? ""}${r.title}`}>
<Link
to={hrefOf(r)}
onClick={() => setQuery("")}
className={`block px-3 py-2 text-xs hover:bg-surface-3 ${i === 0 ? "bg-surface-2" : ""}`}
>
<span className="block text-fg-primary">{r.title}</span>
{!r.sectionTitle && (
<span className="block text-[11px] text-fg-muted">{titleOf(r.section)}</span>
)}
</Link>
</li>
))}
</ul>
);
return (
<div className="space-y-4">
<div>
<Link
to="/workspace"
onClick={back}
className="inline-flex items-center gap-1.5 text-xs text-fg-secondary hover:text-fg-primary"
className="mb-4 inline-flex items-center gap-1.5 text-xs text-fg-secondary hover:text-fg-primary"
>
<ArrowLeft className="h-3.5 w-3.5" />
{canGoBack ? t("settingsNav.backPrevious") : t("settingsNav.back")}
</Link>
<label className="relative block md:w-56">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-fg-muted" />
<input
ref={searchRef}
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && results[0]) navigate(hrefOf(results[0]));
if (e.key === "Escape") setQuery("");
}}
placeholder={t("search.placeholder")}
aria-label={t("search.placeholder")}
className="w-full rounded-lg border border-border bg-surface-1 py-1.5 pl-8 pr-2 text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
/>
</label>
{ranked && (
<div className="md:w-96">
{results.length === 0 ? (
<p role="status" className="px-2 text-xs text-fg-muted md:hidden">
{t("search.noMatch", { query })}
</p>
) : (
<ul
aria-label={t("search.results")}
className="card divide-y divide-border/60 overflow-hidden"
>
{results.map((r, i) => (
<li key={`${r.section}${r.to ?? ""}${r.title}`}>
<Link
to={hrefOf(r)}
onClick={() => setQuery("")}
className={`block px-3 py-2 text-xs hover:bg-surface-3 ${
i === 0 ? "bg-surface-2" : ""
}`}
>
<span className="block text-fg-primary">{r.title}</span>
{!r.sectionTitle && (
<span className="block text-[11px] text-fg-muted">{titleOf(r.section)}</span>
)}
</Link>
</li>
))}
</ul>
)}
</div>
)}
<div className="flex gap-6">
<nav
aria-label={t("settingsNav.label")}
className="sticky top-0 hidden max-h-[calc(100vh-8rem)] w-56 shrink-0 self-start overflow-y-auto md:block"
>
{ranked && shown.length === 0 && (
<p className="px-2 py-1 text-xs text-fg-muted">{t("search.noMatch", { query })}</p>
)}
{GROUPS.map((g) => {
const items = shown.filter((x) => x.group === g);
if (items.length === 0) return null;
return (
<div key={g} className="mb-3">
<p className="px-2 pb-1 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
{t(`settingsNav.group.${g}`)}
</p>
{items.map(({ id, title, Icon }) => (
<Link
key={id}
to={`/settings/${id}`}
aria-current={id === active.id ? "page" : undefined}
data-first-match={id === first ? "true" : undefined}
className={`flex items-center gap-2 rounded-md px-2 py-1.5 text-[13px] transition-colors ${
id === active.id
? "bg-accent/15 text-accent"
: id === first
? "bg-surface-3 text-fg-primary"
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
}`}
>
<Icon className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{title}</span>
</Link>
))}
</div>
);
})}
</nav>
<div className="min-w-0 flex-1">
<select
aria-label={t("settingsNav.label")}
value={active.id}
onChange={(e) => go(e.target.value)}
className="select mb-4 w-full py-1.5 md:hidden"
>
<div className="flex gap-8">
<aside className="sticky top-0 hidden max-h-[calc(100vh-4rem)] w-60 shrink-0 self-start overflow-y-auto pb-6 md:block">
{searchBox}
{searchResults}
<nav aria-label={t("settingsNav.label")} className="mt-4 space-y-5">
{ranked && shown.length === 0 && (
<p className="px-2 py-1 text-xs text-fg-muted">{t("search.noMatch", { query })}</p>
)}
{GROUPS.map((g) => {
const own = sections.filter((x) => x.group === g);
return own.length === 0 ? null : (
<optgroup key={g} label={t(`settingsNav.group.${g}`)}>
{own.map((x) => (
<option key={x.id} value={x.id}>
{x.title}
</option>
))}
</optgroup>
const items = shown.filter((x) => x.group === g);
if (items.length === 0) return null;
const label = t(`settingsNav.group.${g}`);
return (
<div key={g} role="group" aria-label={label}>
<p className="px-2 pb-1.5 text-[11px] font-semibold uppercase tracking-wider text-fg-muted">
{label}
</p>
<div className="space-y-0.5">
{items.map(({ id, title, Icon }) => (
<Link
key={id}
to={`/settings/${id}`}
aria-current={id === active.id ? "page" : undefined}
data-first-match={id === first ? "true" : undefined}
className={`relative flex items-center gap-2.5 rounded-md px-2.5 py-1.5 text-sm transition-colors ${
id === active.id
? "bg-surface-3 font-medium text-fg-primary before:absolute before:inset-y-1.5 before:left-0 before:w-0.5 before:rounded-full before:bg-accent"
: id === first
? "bg-surface-3 text-fg-primary"
: "text-fg-secondary hover:bg-surface-2 hover:text-fg-primary"
}`}
>
<Icon
className={`h-4 w-4 shrink-0 ${id === active.id ? "text-accent" : "text-fg-muted"}`}
/>
<span className="truncate">{title}</span>
</Link>
))}
</div>
</div>
);
})}
</select>
<h1 className="text-2xl font-semibold text-fg-primary">{active.title}</h1>
<p className="mt-1 text-sm text-fg-muted">{active.description}</p>
<div className="mb-6 mt-4 border-b border-border" />
</nav>
</aside>
<div className="min-w-0 max-w-5xl flex-1 pb-10">
<div className="mb-4 md:hidden">
<select
aria-label={t("settingsNav.label")}
value={active.id}
onChange={(e) => go(e.target.value)}
className="select w-full py-1.5"
>
{GROUPS.map((g) => {
const own = sections.filter((x) => x.group === g);
return own.length === 0 ? null : (
<optgroup key={g} label={t(`settingsNav.group.${g}`)}>
{own.map((x) => (
<option key={x.id} value={x.id}>
{x.title}
</option>
))}
</optgroup>
);
})}
</select>
</div>
<header className="mb-6 flex items-start gap-3 border-b border-border pb-5">
<span className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-surface-2 text-accent">
<active.Icon className="h-5 w-5" />
</span>
<div className="min-w-0">
<h1 className="text-xl font-semibold text-fg-primary">{active.title}</h1>
<p className="mt-0.5 text-sm text-fg-muted">{active.description}</p>
</div>
</header>
{active.render()}
</div>
</div>
+456
View File
@@ -0,0 +1,456 @@
/**
* @file Users.tsx
* @description The Users page (`/users`, a sidebar item), server mode only and
* only for an admin or analyst (docs/SERVER-MODE.md): every account with its
* role, state, workspaces, sessions and the tokens/$ it used in the last 7 and
* 30 days (`GET /api/users`), the audit log (`GET /api/users/audit`), and — for
* an admin — the account changes: add a user, change a role, disable/enable,
* reset a password, give a workspace. Every change asks for the admin's OWN
* password again (the server refuses without it); an analyst only reads.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState, type KeyboardEvent, type ReactNode } from "react";
import { Navigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { FolderInput, KeyRound, Lock, LockOpen, RefreshCw, Shield, UserPlus } from "lucide-react";
import { api, type AuditEntry, type AuthUser, type ServerUserUsage } from "../lib/api";
import type { Lane } from "../lib/types";
import { fmt, fmtCost, formatDateTime } from "../lib/format";
import { toast } from "../lib/toast";
import { isSubmitEnter } from "../lib/keys";
import { useSpin } from "../hooks/useSpin";
import { useAuth } from "../components/AuthGate";
import { ConfirmModal } from "../components/ConfirmModal";
import { SettingsSection } from "../components/settings/SettingsRow";
type Role = AuthUser["role"];
const ROLES: Role[] = ["admin", "analyst", "member"];
type DialogKind = "add" | "role" | "disable" | "enable" | "password" | "workspace";
type Dialog = { kind: DialogKind; user?: ServerUserUsage };
export function Users() {
const { t } = useTranslation("users");
const auth = useAuth();
const me = auth.state.mode === "server" ? auth.state.user : null;
const isAdmin = me?.role === "admin";
const [users, setUsers] = useState<ServerUserUsage[] | null>(null);
const [audit, setAudit] = useState<AuditEntry[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [dialog, setDialog] = useState<Dialog | null>(null);
const load = useCallback(
() =>
Promise.all([
api.users
.list()
.then((r) => {
setUsers(r.users);
setError(null);
})
.catch((e: Error) => setError(e.message)),
api.users
.audit()
.then((r) => setAudit(r.entries))
.catch(() => setAudit([])),
]),
[]
);
const [spinning, refresh] = useSpin(load);
useEffect(() => {
if (me && me.role !== "member") void load();
}, [me, load]);
// Local mode, or a member: there is no Users page for them.
if (!me || me.role === "member") return <Navigate to="/" replace />;
return (
<div className="animate-fade-in space-y-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-lg font-semibold text-fg-primary">{t("title")}</h1>
<p className="text-xs text-fg-muted">{t("description")}</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={refresh}
disabled={spinning}
aria-busy={spinning}
className="btn-ghost"
>
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />{" "}
{t("common:refresh")}
</button>
{isAdmin && (
<button
type="button"
onClick={() => setDialog({ kind: "add" })}
className="btn-primary"
>
<UserPlus className="w-4 h-4" /> {t("add")}
</button>
)}
</div>
</div>
<SettingsSection title={t("title")}>
{error && (
<p role="alert" className="py-2 text-xs text-status-danger">
{error}
</p>
)}
{!isAdmin && <p className="pb-2 text-xs text-fg-muted">{t("readOnly")}</p>}
{users && (
<div className="overflow-x-auto">
<table className="w-full text-sm" data-testid="users-table">
<thead className="text-left text-[11px] uppercase text-fg-muted">
<tr>
<th className="py-2 pr-3 font-medium">{t("name")}</th>
<th className="py-2 pr-3 font-medium">{t("role")}</th>
<th className="py-2 pr-3 text-right font-medium">{t("workspaces")}</th>
<th className="py-2 pr-3 text-right font-medium">{t("sessions")}</th>
<th className="py-2 pr-3 text-right font-medium">{t("last7d")}</th>
<th className="py-2 pr-3 text-right font-medium">{t("last30d")}</th>
{isAdmin && <th className="py-2 text-right font-medium">{t("actions")}</th>}
</tr>
</thead>
<tbody className="divide-y divide-border/60">
{users.map((u) => {
const self = u.id === me.id;
return (
<tr key={u.id} className={u.disabled ? "text-fg-muted" : "text-fg-secondary"}>
<td className="py-2 pr-3">
{u.username}
{self && <span className="ml-2 text-[11px] text-accent">({t("you")})</span>}
{u.disabled && <span className="ml-2 text-[11px]">({t("disabled")})</span>}
</td>
<td className="py-2 pr-3" title={t(`roleHint.${u.role}`)}>
{t(`roles.${u.role}`)}
</td>
<td className="py-2 pr-3 text-right tabular-nums">{u.workspaces}</td>
<td className="py-2 pr-3 text-right tabular-nums">{u.sessions}</td>
<td
className="py-2 pr-3 text-right tabular-nums"
title={t("tokens", { n: fmt(u.last7d.tokens) })}
>
{fmtCost(u.last7d.cost)}
</td>
<td
className="py-2 pr-3 text-right tabular-nums"
title={t("tokens", { n: fmt(u.last30d.tokens) })}
>
{fmtCost(u.last30d.cost)}
</td>
{isAdmin && (
<td className="py-1 text-right whitespace-nowrap">
<RowAction
label={t("changeRole")}
disabled={self}
onClick={() => setDialog({ kind: "role", user: u })}
>
<Shield className="h-4 w-4" />
</RowAction>
<RowAction
label={t("resetPassword")}
onClick={() => setDialog({ kind: "password", user: u })}
>
<KeyRound className="h-4 w-4" />
</RowAction>
<RowAction
label={t("assignWorkspace")}
onClick={() => setDialog({ kind: "workspace", user: u })}
>
<FolderInput className="h-4 w-4" />
</RowAction>
<RowAction
label={u.disabled ? t("enable") : t("disable")}
disabled={self}
onClick={() =>
setDialog({ kind: u.disabled ? "enable" : "disable", user: u })
}
>
{u.disabled ? (
<LockOpen className="h-4 w-4" />
) : (
<Lock className="h-4 w-4" />
)}
</RowAction>
</td>
)}
</tr>
);
})}
</tbody>
</table>
</div>
)}
</SettingsSection>
<SettingsSection title={t("auditTitle")} description={t("auditDescription")}>
{audit?.length === 0 && <p className="py-2 text-sm text-fg-muted">{t("auditEmpty")}</p>}
{audit && audit.length > 0 && (
<ul className="divide-y divide-border/60 text-xs" data-testid="audit-list">
{audit.map((a) => (
<li key={a.id} className="flex gap-3 py-1.5">
<span className="w-36 flex-shrink-0 text-fg-muted">{formatDateTime(a.at)}</span>
<span className="w-40 flex-shrink-0 text-fg-secondary">
{a.reader} → {a.owner}
</span>
<span className="min-w-0 truncate font-mono text-fg-muted">{a.path}</span>
</li>
))}
</ul>
)}
</SettingsSection>
{dialog && (
<AccountDialog
dialog={dialog}
users={users ?? []}
onClose={() => setDialog(null)}
onDone={(message) => {
toast.success(message);
setDialog(null);
void load();
}}
/>
)}
</div>
);
}
function RowAction({
label,
disabled,
onClick,
children,
}: {
label: string;
disabled?: boolean;
onClick: () => void;
children: ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
title={label}
aria-label={label}
className="btn-icon disabled:opacity-30"
>
{children}
</button>
);
}
/** One account change: its own fields, then the admin's password to confirm it. */
function AccountDialog({
dialog,
users,
onClose,
onDone,
}: {
dialog: Dialog;
users: ServerUserUsage[];
onClose: () => void;
onDone: (message: string) => void;
}) {
const { t } = useTranslation("users");
const { kind, user } = dialog;
const name = user?.username ?? "";
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [role, setRole] = useState<Role>(user?.role ?? "member");
const [laneId, setLaneId] = useState<number | null>(null);
const [lanes, setLanes] = useState<Lane[] | null>(null);
const [current, setCurrent] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (kind !== "workspace") return;
api.lanes
.list()
.then((r) => setLanes(r.lanes))
.catch(() => setLanes([]));
}, [kind]);
const ready =
current.length > 0 &&
(kind === "add"
? username.trim().length > 0 && password.length > 0
: kind === "password"
? password.length > 0
: kind === "workspace"
? laneId !== null
: kind === "role"
? role !== user?.role
: true);
const submit = async () => {
if (!ready || busy) return;
setBusy(true);
setError(null);
try {
if (kind === "add") {
const r = await api.users.create({ username: username.trim(), password, role }, current);
onDone(t("done.created", { name: r.user.username }));
} else if (kind === "role" && user) {
await api.users.update(user.id, { role }, current);
onDone(t("done.role", { name, role: t(`roles.${role}`) }));
} else if ((kind === "disable" || kind === "enable") && user) {
await api.users.update(user.id, { disabled: kind === "disable" }, current);
onDone(t(kind === "disable" ? "done.disabled" : "done.enabled", { name }));
} else if (kind === "password" && user) {
await api.users.setPassword(user.id, password, current);
onDone(t("done.password", { name }));
} else if (kind === "workspace" && user && laneId !== null) {
await api.users.assignWorkspace(user.id, laneId, current);
onDone(t("done.workspace", { name }));
}
} catch (e) {
const err = e as Error & { code?: string };
setError(
err.code && t(`errors.${err.code}`, { defaultValue: "" })
? t(`errors.${err.code}`)
: err.message
);
} finally {
setBusy(false);
}
};
const titles: Record<DialogKind, string> = {
add: t("dialog.add"),
role: t("dialog.role", { name }),
disable: t("dialog.disable", { name }),
enable: t("dialog.enable", { name }),
password: t("dialog.password", { name }),
workspace: t("dialog.workspace", { name }),
};
const messages: Partial<Record<DialogKind, string>> = {
disable: t("dialog.disableBody", { name }),
password: t("dialog.passwordBody", { name }),
workspace: t("dialog.workspaceBody", { name }),
};
const ownerName = (id: number) => users.find((u) => u.id === id)?.username ?? `#${id}`;
// Enter in any field submits — never mid-IME (Vietnamese typing commits with Enter).
const onKeyDown = (e: KeyboardEvent) => {
if (isSubmitEnter(e)) {
e.preventDefault();
void submit();
}
};
return (
<ConfirmModal
open
title={titles[kind]}
message={messages[kind]}
confirmLabel={kind === "disable" ? t("disable") : kind === "add" ? t("add") : t("save")}
cancelLabel={t("common:cancel")}
destructive={kind === "disable"}
busy={busy}
disabled={!ready}
onConfirm={() => void submit()}
onCancel={onClose}
>
<div className="mt-3 space-y-3" onKeyDown={onKeyDown}>
{kind === "add" && (
<Field label={t("username")} hint={t("usernameHint")}>
<input
className="input w-full text-sm"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="off"
aria-label={t("username")}
/>
</Field>
)}
{(kind === "add" || kind === "password") && (
<Field label={kind === "add" ? t("password") : t("newPassword")} hint={t("passwordHint")}>
<input
type="password"
className="input w-full text-sm"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="new-password"
aria-label={kind === "add" ? t("password") : t("newPassword")}
/>
</Field>
)}
{(kind === "add" || kind === "role") && (
<Field label={t("role")} hint={t(`roleHint.${role}`)}>
<select
className="input w-full text-sm"
value={role}
onChange={(e) => setRole(e.target.value as Role)}
aria-label={t("role")}
>
{ROLES.map((r) => (
<option key={r} value={r}>
{t(`roles.${r}`)}
</option>
))}
</select>
</Field>
)}
{kind === "workspace" && (
<Field label={t("workspace")}>
{lanes && lanes.length === 0 ? (
<p className="text-xs text-fg-muted">{t("noWorkspaces")}</p>
) : (
<select
className="input w-full text-sm"
value={laneId ?? ""}
onChange={(e) => setLaneId(e.target.value ? Number(e.target.value) : null)}
aria-label={t("workspace")}
>
<option value="" />
{(lanes ?? []).map((l) => (
<option key={l.id} value={l.id}>
{(l.title || l.cwd) +
" — " +
(l.owner_id == null
? t("noOwner")
: t("ownedBy", { name: ownerName(l.owner_id) }))}
</option>
))}
</select>
)}
</Field>
)}
<Field label={t("yourPassword")} hint={t("yourPasswordHint")}>
<input
type="password"
className="input w-full text-sm"
value={current}
onChange={(e) => setCurrent(e.target.value)}
autoComplete="current-password"
aria-label={t("yourPassword")}
/>
</Field>
{error && (
<p role="alert" className="text-xs text-status-danger">
{error}
</p>
)}
</div>
</ConfirmModal>
);
}
function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {
return (
<label className="block">
<span className="text-xs font-medium text-fg-secondary">{label}</span>
<div className="mt-1">{children}</div>
{hint && <span className="mt-0.5 block text-[11px] text-fg-muted">{hint}</span>}
</label>
);
}
+25 -1
View File
@@ -67,7 +67,7 @@ import { api } from "../lib/api";
import type { CwdSuggestion, RunHandle, RunListResponse } from "../lib/api";
import type { Lane, LaneFeature, ProofFeature, Session } from "../lib/types";
import { toast } from "../lib/toast";
import { confirmDiscard } from "../lib/confirm";
import { confirmDialog, confirmDiscard } from "../lib/confirm";
import { keepIfSame } from "../lib/keepIfSame";
import { useLanes } from "../hooks/useLanes";
import { LaneConsolePane } from "../components/run/LaneConsolePane";
@@ -347,6 +347,29 @@ function ConsoleArea({
else onNewSession?.();
};
// A pane header's X: stops that one session, as a one-pane tab's X does —
// an AI session may be mid-turn, so it asks first; a shell holds no agent.
const stopPane = async (slot: number) => {
const run = runOf(slot);
if (!run) return;
if (
run.tool !== "shell" &&
!(await confirmDialog({
title: t("tabs.stopTitle", { name: label(run) }),
message: t("tabs.stopBody"),
confirmLabel: t("tabs.stopConfirm"),
destructive: true,
}))
)
return;
try {
await api.run.kill(run.id);
ended(slot);
} catch (err) {
toast.error(err);
}
};
// A split menu's "New terminal" or AI tool (bare, as the tab bar's `+`
// starts it) in the first free slot, placed on that side of the pane —
// unless the workspace changed while it started.
@@ -466,6 +489,7 @@ function ConsoleArea({
toolLabel={aiTools.label}
onNewSplit={(target, edge, tool) => void newSplit(target, edge, tool)}
stateOf={stateOf}
onStop={(slot) => void stopPane(slot)}
renderPane={(slot) => (
<LaneConsolePane
lanes={lanes}
@@ -34,7 +34,7 @@ vi.mock("../../lib/api", () => ({
RUN_MODEL_CHOICES: [],
}));
vi.mock("../../components/settings/PricingSettings", () => ({
PricingSettings: ({ vendor }: { vendor: string }) => <div>pricing:{vendor}</div>,
PricingLink: ({ vendor }: { vendor: string }) => <div>pricing:{vendor}</div>,
}));
const GEMINI: AiTool = {
+55 -1
View File
@@ -1,6 +1,7 @@
/**
* @file Settings.test.tsx
* @description The install-wide Settings pages: General and Notifications never
* @description The install-wide Settings pages: System shows the server's
* runtime, install and database facts; General and Notifications never
* read `/api/settings/info` (it COUNTs whole tables), Data reads it once and
* coalesces a burst of activity instead of re-reading per event, a purge takes
* a whole number of days and runs only after a dry-run count is confirmed, and
@@ -38,9 +39,21 @@ vi.mock("../../lib/api", async (importOriginal) => {
...actual,
api: {
pricing: { totalCost: vi.fn(async () => ({ total_cost: 1 })) },
updates: {
status: vi.fn(async () => ({
git_repo: false,
update_available: true,
install_kind: "plugin",
repo_root: "/home/u/.claude/plugins/cache/agenthub/9.9.8",
local_version: "9.9.8",
latest_release: "v9.9.9",
})),
},
settings: {
info: (...a: unknown[]) => info(...(a as [])),
cleanup: (p: { dry_run?: boolean }) => cleanup(p),
autostart: { get: vi.fn(async () => ({ available: false })) },
tray: { get: vi.fn(async () => ({ available: false })) },
clearData: () => clearData(),
gc: vi.fn(async () => ({ reaped: [], capped: [] })),
exportData: () => "/api/settings/export",
@@ -81,6 +94,47 @@ describe("Settings", () => {
expect(info).not.toHaveBeenCalled();
});
it("shows the server's runtime, install and database facts on System", async () => {
info.mockImplementationOnce(
async () =>
({
db: {
path: "/data/dashboard.db",
size: 5 * 1024 * 1024,
counts: {},
pragmas: { journal_mode: "wal" },
load_stats: { m5: 3, m15: 9, h1: 40 },
},
hooks: { installed: true, path: "", hooks: {} },
server: {
version: "1",
uptime: 3700,
node_version: "v26.0.0",
platform: "linux",
arch: "x64",
cpus: 8,
cpu_load: [0.5, 0.25, 0.125],
total_mem: 16 * 1024 ** 3,
free_mem: 4 * 1024 ** 3,
memory: { rss: 200 * 1024 ** 2, heapUsed: 50 * 1024 ** 2, heapLimit: 4 * 1024 ** 3 },
ws_connections: 2,
},
}) as never
);
render(<Settings section="about" />);
expect(await screen.findByText("/data/dashboard.db")).toBeInTheDocument();
expect(screen.getByText("1h 1m")).toBeInTheDocument();
expect(screen.getByText(/linux · x64 · Node.js v26.0.0/)).toBeInTheDocument();
expect(screen.getByText("8 cores · load 0.50 / 0.25 / 0.13")).toBeInTheDocument();
expect(screen.getByText("200.0 MB RSS · heap 50.0 MB of 4.0 GB")).toBeInTheDocument();
expect(screen.getByText("12.0 GB used of 16.0 GB")).toBeInTheDocument();
expect(screen.getByText("wal")).toBeInTheDocument();
expect(screen.getByText("3 in 5 min · 9 in 15 min · 40 in 1 h")).toBeInTheDocument();
expect(await screen.findByText("Claude Code plugin")).toBeInTheDocument();
expect(screen.getByText("v9.9.9")).toBeInTheDocument();
expect(screen.getByText("Update available")).toBeInTheDocument();
});
it("reads info once on Data and coalesces a burst of activity", async () => {
render(<Settings section="data" />);
await screen.findByText("/tmp/x.db");
@@ -2,12 +2,14 @@
* @file SettingsHub.test.tsx
* @description The Settings page shell: a path picks its section, `/settings`
* and the legacy `?tool=` / `#ai-tools` links redirect to the path form, an
* unknown section falls back to General, a disabled tool has no page while an
* unknown section falls back to Appearance, a disabled tool has no page while an
* installed, enabled catalog tool gets one, the mobile
* select navigates, and the search filters the nav, opens the first match on
* Enter, clears on Escape and is focused by Ctrl+F (a second press selects its
* text), its result list deep-links a row's sub-tab, an anchor in the URL is
* highlighted, Pricing sits in its own Cost group, and an unknown section toasts.
* highlighted, the nav has three groups (General, Agents, Data) with no
* one-page group, Git and Snapshots & reports are one page each (their old
* paths redirect there), and an unknown section toasts.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -31,7 +33,18 @@ vi.mock("../AiToolConfig", () => ({
AiToolConfig: ({ toolId }: { toolId: string }) => <div data-testid={`tool-${toolId}`} />,
}));
vi.mock("../AiProxyConfig", () => ({ AiProxyConfig: () => null }));
vi.mock("../GitCredentialsConfig", () => ({ GitCredentialsConfig: () => null }));
vi.mock("../GitCredentialsConfig", () => ({
GitCredentialsConfig: () => <div data-testid="git-credentials" />,
}));
vi.mock("../../components/settings/WorktreeVisibilitySettings", () => ({
WorktreeVisibilitySettings: () => <div data-testid="worktree-visibility" />,
}));
vi.mock("../../components/settings/SnapshotSettings", () => ({
SnapshotSettings: () => <div data-testid="snapshot-settings" />,
}));
vi.mock("../../components/settings/ReportSettings", () => ({
ReportSettings: () => <div data-testid="report-settings" />,
}));
vi.mock("../../components/settings/AiToolsSettings", () => ({
AiToolsSettings: () => <div data-testid="ai-tools" />,
}));
@@ -124,6 +137,10 @@ describe("SettingsHub", () => {
["/settings?tool=claude-code", "/settings/agents/claude-code"],
["/settings?tool=gemini", "/settings/agents/gemini"],
["/settings#ai-tools", "/settings/agents"],
["/settings/about", "/settings/system"],
["/settings/snapshots", "/settings/reports"],
["/settings/worktrees", "/settings/git"],
["/settings/git-credentials", "/settings/git"],
])("redirects %s to %s", async (from, to) => {
renderAt(from);
await screen.findByRole("heading", { level: 1 });
@@ -170,7 +187,7 @@ describe("SettingsHub", () => {
const links = within(nav())
.getAllByRole("link")
.map((a) => a.textContent);
expect(links).toEqual(["Claude Code", "Codex", "Pricing"]);
expect(links).toEqual(["Pricing"]);
expect(within(nav()).getByRole("link", { name: "Pricing" })).toHaveAttribute(
"data-first-match",
"true"
@@ -234,18 +251,38 @@ describe("SettingsHub", () => {
);
});
it("puts Pricing in its own Cost group and folds language and pet into Appearance", async () => {
it("groups the nav into General, Agents and Data, with no one-page group", async () => {
renderAt("/settings/data");
expect(await screen.findByTestId("settings-data")).toBeInTheDocument();
expect(within(nav()).getByText("Cost")).toBeInTheDocument();
const groups = within(nav())
.getAllByRole("group")
.map((g) => [g.getAttribute("aria-label"), within(g).getAllByRole("link").length]);
expect(groups.map(([name]) => name)).toEqual(["General", "Agents", "Data"]);
for (const [, count] of groups) expect(count).toBeGreaterThan(1);
const links = within(nav())
.getAllByRole("link")
.map((a) => a.textContent);
expect(links.slice(0, 3)).toEqual(["Data", "About", "Appearance"]);
expect(links).not.toContain("General");
expect(links.slice(0, 3)).toEqual(["Appearance", "Notifications", "System"]);
expect(links).toEqual(expect.arrayContaining(["AI Proxy", "Pricing", "Git"]));
expect(links).not.toContain("About");
expect(links).not.toContain("Worktrees");
expect(links).not.toContain("Pet");
});
it("shows git logins and worktree suggestions on one Git page", async () => {
renderAt("/settings/git");
expect(await screen.findByRole("heading", { level: 1, name: "Git" })).toBeInTheDocument();
expect(screen.getByTestId("git-credentials")).toBeInTheDocument();
expect(screen.getByTestId("worktree-visibility")).toBeInTheDocument();
});
it("shows snapshots and scheduled reports on one page", async () => {
renderAt("/settings/reports");
await screen.findByRole("heading", { level: 1, name: "Snapshots & reports" });
expect(screen.getByTestId("snapshot-settings")).toBeInTheDocument();
expect(screen.getByTestId("report-settings")).toBeInTheDocument();
});
it("scrolls to and highlights the anchor the URL names", async () => {
renderAt("/settings/data#data-row");
const row = await screen.findByTestId("settings-data");
@@ -272,7 +309,9 @@ describe("SettingsHub", () => {
const off = subscribeToasts((i) => (seen = i));
renderAt("/settings/nope");
await screen.findByTestId("pet-settings");
expect(seen.some((x) => x.message.includes("nope"))).toBe(true);
const msg = seen.find((x) => x.message.includes("nope"))?.message ?? "";
expect(msg).toContain("Appearance");
expect(msg).not.toContain("General");
off();
});
});
+152
View File
@@ -0,0 +1,152 @@
/**
* @file Users.test.tsx
* @description The Users page: local mode and a member are sent home; an
* analyst reads the table with no account controls; an admin adds a user only
* with their own password typed (the confirm stays disabled until then), sees
* a wrong password reported in the dialog, and cannot change their own role or
* disable themselves.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import type { AuthState, ServerUserUsage } from "../../lib/api";
let authState: AuthState = { mode: "local" };
vi.mock("../../components/AuthGate", () => ({
useAuth: () => ({ state: authState, logout: async () => {} }),
}));
const usage = { cost: 0, tokens: 0 };
const row = (id: number, username: string, role: ServerUserUsage["role"]): ServerUserUsage => ({
id,
username,
role,
disabled: false,
workspaces: 0,
sessions: 0,
last7d: usage,
last30d: usage,
});
const users = vi.hoisted(() => ({
list: vi.fn(),
audit: vi.fn(),
create: vi.fn(),
update: vi.fn(),
setPassword: vi.fn(),
assignWorkspace: vi.fn(),
}));
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
api: {
users,
lanes: { list: vi.fn(async () => ({ lanes: [], counts: {} })) },
},
};
});
import { Users } from "../Users";
function renderPage() {
return render(
<MemoryRouter initialEntries={["/users"]}>
<Routes>
<Route path="/users" element={<Users />} />
<Route path="/" element={<div data-testid="home" />} />
</Routes>
</MemoryRouter>
);
}
beforeEach(() => {
for (const fn of Object.values(users)) fn.mockReset();
users.list.mockResolvedValue({
users: [row(1, "boss", "admin"), row(2, "mia", "member")],
});
users.audit.mockResolvedValue({ entries: [] });
});
describe("Users page", () => {
it("sends local mode and a member home", async () => {
authState = { mode: "local" };
renderPage();
expect(await screen.findByTestId("home")).toBeTruthy();
authState = { mode: "server", user: { id: 2, username: "mia", role: "member" } };
renderPage();
expect((await screen.findAllByTestId("home")).length).toBeGreaterThan(0);
expect(users.list).not.toHaveBeenCalled();
});
it("shows an analyst the accounts with no controls", async () => {
authState = { mode: "server", user: { id: 3, username: "ana", role: "analyst" } };
renderPage();
expect(await screen.findByText("mia")).toBeTruthy();
expect(screen.queryByRole("button", { name: "Add user" })).toBeNull();
expect(screen.queryByRole("button", { name: "Reset password" })).toBeNull();
expect(screen.getByText("Analysts can view accounts but not change them.")).toBeTruthy();
});
it("adds a user only with the admin's own password", async () => {
authState = { mode: "server", user: { id: 1, username: "boss", role: "admin" } };
users.create.mockResolvedValue({
user: { id: 9, username: "neo", role: "member", disabled: false },
});
const ue = userEvent.setup();
renderPage();
await ue.click(await screen.findByRole("button", { name: "Add user" }));
const dialog = await screen.findByRole("dialog");
await ue.type(screen.getByLabelText("Username"), "neo");
await ue.type(screen.getByLabelText("Password"), "neo-pass-123");
const confirm = Array.from(dialog.querySelectorAll("button")).find(
(b) => b.textContent === "Add user"
)!;
expect(confirm.hasAttribute("disabled")).toBe(true);
await ue.type(screen.getByLabelText("Your password"), "boss-pass");
expect(confirm.hasAttribute("disabled")).toBe(false);
await ue.click(confirm);
await waitFor(() =>
expect(users.create).toHaveBeenCalledWith(
{ username: "neo", password: "neo-pass-123", role: "member" },
"boss-pass"
)
);
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
expect(users.list).toHaveBeenCalledTimes(2); // reloaded after the change
});
it("reports a wrong password in the dialog and keeps it open", async () => {
authState = { mode: "server", user: { id: 1, username: "boss", role: "admin" } };
users.setPassword.mockRejectedValue(
Object.assign(new Error("your password is wrong"), { code: "EREAUTH" })
);
const ue = userEvent.setup();
renderPage();
await screen.findByText("mia");
await ue.click(screen.getAllByRole("button", { name: "Reset password" })[1]!);
await ue.type(screen.getByLabelText("New password"), "mia-new-pass-1");
await ue.type(screen.getByLabelText("Your password"), "wrong{Enter}");
expect(await screen.findByRole("alert")).toHaveProperty(
"textContent",
"Your password is wrong."
);
expect(screen.getByRole("dialog")).toBeTruthy();
expect(users.setPassword).toHaveBeenCalledWith(2, "mia-new-pass-1", "wrong");
});
it("never offers an admin a role change or disable on their own row", async () => {
authState = { mode: "server", user: { id: 1, username: "boss", role: "admin" } };
renderPage();
await screen.findByText("mia");
const [ownRole, miaRole] = screen.getAllByRole("button", { name: "Change role" });
const [ownLock, miaLock] = screen.getAllByRole("button", { name: "Disable" });
expect(ownRole!.hasAttribute("disabled")).toBe(true);
expect(ownLock!.hasAttribute("disabled")).toBe(true);
expect(miaRole!.hasAttribute("disabled")).toBe(false);
expect(miaLock!.hasAttribute("disabled")).toBe(false);
});
});
@@ -758,6 +758,21 @@ describe("split panes", () => {
expect(screen.getByTestId("run-param").textContent).toBe("agenthub-lane-1-1");
});
it("a pane's X stops just its session, without taking it out of the tab first", async () => {
withRuns([live(0), live(1), live(2)]);
await open("/workspace?lane=1&run=agenthub-lane-1");
await ctrlClick(screen.getByTestId("console-tab-1"));
await ctrlClick(screen.getByTestId("console-tab-2"));
withRuns([live(0), live(2)]);
await userEvent.setup().click(screen.getByTestId("pane-stop-1"));
await settle();
expect(api.run.kill).toHaveBeenCalledTimes(1);
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1-1");
expect(shownPanes()).toEqual(["pane-0", "pane-2"]);
expect(screen.queryByTestId("console-tab-1")).toBeNull();
});
it("drops a remembered pane whose session is gone", async () => {
window.localStorage.setItem(
"agenthub.workspace.paneLayout",
@@ -2765,137 +2765,71 @@ exports[`screen snapshots > Claude Config 1`] = `
</p>
</div>
</section>
<section
class="scroll-mt-24"
id="pricing"
<div
class="card flex items-center justify-between gap-4 p-5"
>
<div
class="flex flex-wrap items-center justify-between gap-3 mb-4"
class="min-w-0"
>
<div>
<h3
class="text-sm font-medium text-fg-secondary flex items-center gap-2"
>
<svg
class="lucide lucide-dollar-sign w-4 h-4 text-fg-muted"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<line
x1="12"
x2="12"
y1="2"
y2="22"
/>
<path
d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"
/>
</svg>
Model Pricing
<button
aria-label="How model pricing works"
class="inline-flex items-center justify-center rounded-full p-0.5 text-fg-muted hover:text-fg-secondary focus:outline-none focus:ring-1 focus:ring-accent/40"
type="button"
>
<svg
class="lucide lucide-info w-3.5 h-3.5"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<circle
cx="12"
cy="12"
r="10"
/>
<path
d="M12 16v-4"
/>
<path
d="M12 8h.01"
/>
</svg>
</button>
</h3>
<p
class="text-xs text-fg-muted mt-0.5"
>
Rates per million tokens (USD). Use % as wildcard in patterns. Some models have a time-limited introductory rate (shown in violet); usage on or before its cutoff date is billed at that rate, and usage after it at the standard rate.
</p>
</div>
<div
class="flex items-center gap-2"
<h3
class="flex items-center gap-2 text-sm font-semibold text-fg-primary"
>
<button
class="text-xs px-3 py-1.5 rounded-md transition-colors disabled:opacity-50 inline-flex items-center gap-1.5 text-fg-secondary hover:text-fg-primary hover:bg-surface-4"
<svg
class="lucide lucide-dollar-sign h-4 w-4 text-fg-muted"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<svg
class="lucide lucide-rotate-ccw w-3 h-3"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"
/>
<path
d="M3 3v5h5"
/>
</svg>
Reset Defaults
</button>
<button
class="btn-primary text-xs disabled:opacity-50"
>
<svg
class="lucide lucide-plus w-3.5 h-3.5"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5 12h14"
/>
<path
d="M12 5v14"
/>
</svg>
Add Model
</button>
</div>
<line
x1="12"
x2="12"
y1="2"
y2="22"
/>
<path
d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"
/>
</svg>
Model pricing
</h3>
<p
class="mt-0.5 text-xs text-fg-muted"
>
Prices for every vendor's models live on one page.
</p>
</div>
<p
class="card mt-4 px-4 py-6 text-center text-xs text-fg-muted"
<a
class="btn-ghost shrink-0 text-xs"
href="/settings/pricing#pricing-claude"
>
No Claude model prices. Reset Defaults restores them.
</p>
</section>
Open Pricing
<svg
class="lucide lucide-arrow-right h-3.5 w-3.5"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5 12h14"
/>
<path
d="m12 5 7 7-7 7"
/>
</svg>
</a>
</div>
</div>
</div>
</div>
@@ -7142,333 +7076,404 @@ exports[`screen snapshots > Settings — about 1`] = `
<div
class="animate-fade-in space-y-8"
>
<section
class="scroll-mt-24"
<div
class="space-y-6 scroll-mt-24"
id="about"
>
<div
class="mb-4"
<section
class="card p-5 scroll-mt-24"
id="autostart"
>
<section
class="card p-5 scroll-mt-24"
id="autostart"
<h3
class="text-sm font-semibold text-fg-primary"
>
Startup
</h3>
<div
class="mt-2"
>
<h3
class="text-sm font-semibold text-fg-primary"
>
Startup
</h3>
<div
class="mt-2"
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-3 items-center"
>
<div
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-3 items-center"
class="min-w-0"
>
<div
class="min-w-0"
<p
class="text-sm text-fg-secondary"
>
<p
class="text-sm text-fg-secondary"
>
Start AgentHub automatically
</p>
<p
class="mt-0.5 text-xs text-fg-muted"
>
Run the dashboard as a systemd --user service, so it starts at boot without Claude Code being opened. Turning it off keeps the running server up.
</p>
</div>
<div
class="shrink-0"
Start AgentHub automatically
</p>
<p
class="mt-0.5 text-xs text-fg-muted"
>
<button
aria-checked="false"
aria-label="Start AgentHub automatically"
class="relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-50 bg-border-light ring-1 ring-inset ring-fg-muted/80"
role="switch"
type="button"
>
<span
aria-hidden="true"
class="inline-block h-4 w-4 rounded-full bg-white shadow transition-transform translate-x-0.5"
/>
</button>
</div>
Run the dashboard as a systemd --user service, so it starts at boot without Claude Code being opened. Turning it off keeps the running server up.
</p>
</div>
<div
class="shrink-0"
>
<button
aria-checked="false"
aria-label="Start AgentHub automatically"
class="relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-50 bg-border-light ring-1 ring-inset ring-fg-muted/80"
role="switch"
type="button"
>
<span
aria-hidden="true"
class="inline-block h-4 w-4 rounded-full bg-white shadow transition-transform translate-x-0.5"
/>
</button>
</div>
</div>
</section>
</div>
<p
class="text-xs text-fg-muted mb-4"
>
Server runtime information.
</p>
<div
class="card p-5"
>
<div
class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3"
>
<div
class="bg-surface-2 rounded-lg px-4 py-3"
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-3 items-center"
>
<div
class="flex items-center gap-2 mb-1.5"
class="min-w-0"
>
<svg
class="lucide lucide-server w-4 h-4 text-accent"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<rect
height="8"
rx="2"
ry="2"
width="20"
x="2"
y="2"
/>
<rect
height="8"
rx="2"
ry="2"
width="20"
x="2"
y="14"
/>
<line
x1="6"
x2="6.01"
y1="6"
y2="6"
/>
<line
x1="6"
x2="6.01"
y1="18"
y2="18"
/>
</svg>
<p
class="text-[11px] text-fg-muted uppercase tracking-wider"
class="text-sm text-fg-secondary"
>
Show AgentHub icon in the status area
</p>
<p
class="mt-0.5 text-xs text-fg-muted"
>
An AgentHub icon beside the clock (top bar, menu bar or notification area) to open the dashboard, quit it, and start it again after a quit.
</p>
</div>
<div
class="shrink-0"
>
<button
aria-checked="true"
aria-label="Show AgentHub icon in the status area"
class="relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-50 bg-accent-solid"
role="switch"
type="button"
>
<span
aria-hidden="true"
class="inline-block h-4 w-4 rounded-full bg-white shadow transition-transform translate-x-[18px]"
/>
</button>
</div>
</div>
<div
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-3 items-center"
>
<div
class="min-w-0"
>
<p
class="text-sm text-fg-secondary"
>
Quit AgentHub
</p>
<p
class="mt-0.5 text-xs text-fg-muted"
>
Stop the dashboard so nothing runs in the background. Opening Claude Code no longer starts it; run \`agenthub start\` to bring it back.
</p>
</div>
<div
class="shrink-0"
>
<button
class="btn-ghost text-xs text-status-danger disabled:opacity-40"
data-testid="quit-agenthub"
type="button"
>
Quit
</button>
</div>
</div>
</div>
</section>
<section
class="card p-5 scroll-mt-24"
id="server"
>
<h3
class="text-sm font-semibold text-fg-primary"
>
Server
</h3>
<div
class="mt-2"
>
<div
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-3 items-center"
>
<div
class="min-w-0"
>
<p
class="text-sm text-fg-secondary"
>
Release
</p>
<p
class="mt-0.5 text-xs text-fg-muted"
>
<span
class="text-status-warning/90"
>
UI build v2.5.12 (rebuild client to match)
</span>
</p>
</div>
<p
class="text-sm font-semibold text-fg-secondary font-mono"
<div
class="shrink-0"
>
v
</p>
<p
class="text-[10px] text-status-warning/90 mt-1"
>
UI build v2.5.11 (rebuild client to match)
</p>
<span
class="text-sm text-fg-primary font-mono text-xs"
>
v
</span>
</div>
</div>
<div
class="bg-surface-2 rounded-lg px-4 py-3"
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-2 items-center"
>
<div
class="flex items-center gap-2 mb-1.5"
class="min-w-0"
>
<svg
class="lucide lucide-clock w-4 h-4 text-accent"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<circle
cx="12"
cy="12"
r="10"
/>
<polyline
points="12 6 12 12 16 14"
/>
</svg>
<p
class="text-[11px] text-fg-muted uppercase tracking-wider"
class="text-sm text-fg-secondary"
>
Uptime
</p>
</div>
<p
class="text-sm font-semibold text-fg-secondary"
>
0m
</p>
</div>
<div
class="bg-surface-2 rounded-lg px-4 py-3"
>
<div
class="flex items-center gap-2 mb-1.5"
class="shrink-0"
>
<svg
class="lucide lucide-cpu w-4 h-4 text-status-success"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
<span
class="text-sm text-fg-primary "
>
<rect
height="16"
rx="2"
width="16"
x="4"
y="4"
/>
<rect
height="6"
rx="1"
width="6"
x="9"
y="9"
/>
<path
d="M15 2v2"
/>
<path
d="M15 20v2"
/>
<path
d="M2 15h2"
/>
<path
d="M2 9h2"
/>
<path
d="M20 15h2"
/>
<path
d="M20 9h2"
/>
<path
d="M9 2v2"
/>
<path
d="M9 20v2"
/>
</svg>
<p
class="text-[11px] text-fg-muted uppercase tracking-wider"
>
Node.js
</p>
0m
</span>
</div>
<p
class="text-sm font-semibold text-fg-secondary font-mono"
>
v22.0.0
</p>
</div>
<div
class="bg-surface-2 rounded-lg px-4 py-3"
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-2 items-center"
>
<div
class="flex items-center gap-2 mb-1.5"
class="min-w-0"
>
<svg
class="lucide lucide-globe w-4 h-4 text-syntax-keyword"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<circle
cx="12"
cy="12"
r="10"
/>
<path
d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"
/>
<path
d="M2 12h20"
/>
</svg>
<p
class="text-[11px] text-fg-muted uppercase tracking-wider"
class="text-sm text-fg-secondary"
>
Platform
</p>
</div>
<p
class="text-sm font-semibold text-fg-secondary"
<div
class="shrink-0"
>
linux
</p>
<span
class="text-sm text-fg-primary font-mono text-xs"
>
linux
· x64
· Node.js
v22.0.0
</span>
</div>
</div>
<div
class="bg-surface-2 rounded-lg px-4 py-3"
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-2 items-center"
>
<div
class="flex items-center gap-2 mb-1.5"
class="min-w-0"
>
<svg
class="lucide lucide-wifi w-4 h-4 text-status-warning"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 20h.01"
/>
<path
d="M2 8.82a15 15 0 0 1 20 0"
/>
<path
d="M5 12.859a10 10 0 0 1 14 0"
/>
<path
d="M8.5 16.429a5 5 0 0 1 7 0"
/>
</svg>
<p
class="text-[11px] text-fg-muted uppercase tracking-wider"
class="text-sm text-fg-secondary"
>
WS Clients
CPU
</p>
</div>
<p
class="text-sm font-semibold text-fg-secondary"
<div
class="shrink-0"
>
0
</p>
<span
class="text-sm text-fg-primary "
>
1 cores · load 0.00 / 0.00 / 0.00
</span>
</div>
</div>
<div
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-2 items-center"
>
<div
class="min-w-0"
>
<p
class="text-sm text-fg-secondary"
>
Process memory
</p>
</div>
<div
class="shrink-0"
>
<span
class="text-sm text-fg-primary "
>
0 B RSS · heap 0 B of 0 B
</span>
</div>
</div>
<div
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-3 items-center"
>
<div
class="min-w-0"
>
<p
class="text-sm text-fg-secondary"
>
Live connections
</p>
<p
class="mt-0.5 text-xs text-fg-muted"
>
Browser tabs and tools receiving live updates
</p>
</div>
<div
class="shrink-0"
>
<span
class="text-sm text-fg-primary "
>
0
</span>
</div>
</div>
</div>
</div>
</section>
</section>
<section
class="card p-5 scroll-mt-24"
id="install"
>
<h3
class="text-sm font-semibold text-fg-primary"
>
Installation
</h3>
<div
class="mt-2"
>
<div
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-2 items-center"
>
<div
class="min-w-0"
>
<p
class="text-sm text-fg-secondary"
>
Unknown
</p>
</div>
<div
class="shrink-0"
/>
</div>
</div>
</section>
<section
class="card p-5 scroll-mt-24"
id="database"
>
<h3
class="text-sm font-semibold text-fg-primary"
>
Database
</h3>
<div
class="mt-2"
>
<div
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-3 items-center"
>
<div
class="min-w-0"
>
<p
class="text-sm text-fg-secondary"
>
Database file
</p>
<p
class="mt-0.5 text-xs text-fg-muted"
>
<span
class="font-mono break-all"
>
/tmp/test.db
</span>
</p>
</div>
<div
class="shrink-0"
>
<span
class="text-sm text-fg-primary "
>
0 B
</span>
</div>
</div>
<div
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-2 items-center"
>
<div
class="min-w-0"
>
<p
class="text-sm text-fg-secondary"
>
Journal mode
</p>
</div>
<div
class="shrink-0"
>
<span
class="text-sm text-fg-primary font-mono text-xs"
>
wal
</span>
</div>
</div>
<div
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-2 items-center"
>
<div
class="min-w-0"
>
<p
class="text-sm text-fg-secondary"
>
Events recorded
</p>
</div>
<div
class="shrink-0"
>
<span
class="text-sm text-fg-primary "
>
0 in 5 min · 0 in 15 min · 0 in 1 h
</span>
</div>
</div>
</div>
</section>
</div>
</div>
</div>
`;
@@ -338,6 +338,10 @@ vi.mock("../../lib/api", async (importOriginal) => {
get: r({ available: true, kind: "systemd", enabled: false, foreign: false }),
set: r({ available: true, kind: "systemd", enabled: true, foreign: false }),
},
tray: {
get: r({ available: true, enabled: true, running: true }),
set: r({ available: true, enabled: false, running: false }),
},
privacy: {
get: r({
redact_secrets: false,
@@ -639,11 +643,11 @@ describe("screen snapshots", () => {
});
});
// Pricing, hook status and CLAUDE_HOME belong to Claude Code (#40 part B):
// they sit under the Claude Code tab's Overview, not on Dashboard or on the
// other sub-tabs.
// Hook status and CLAUDE_HOME belong to Claude Code (#40 part B): they sit
// under the Claude Code tab's Overview, not on Dashboard or on the other
// sub-tabs. Its prices live on the Pricing page; the Overview only links there.
describe("Claude Code's AgentHub settings", () => {
it("shows pricing, hooks and CLAUDE_HOME on the Overview only", async () => {
it("shows hooks and CLAUDE_HOME on the Overview only", async () => {
const dashboard = render(
<MemoryRouter initialEntries={["/settings"]}>
<Settings />
@@ -662,12 +666,14 @@ describe("Claude Code's AgentHub settings", () => {
);
await settle();
expect(screen.queryByTestId("cc-tab-agenthub")).toBeNull();
for (const id of ["pricing", "hooks", "claude-home"]) {
for (const id of ["hooks", "claude-home"]) {
expect(container.querySelector(`section#${id}`)).not.toBeNull();
}
expect(container.querySelector("section#pricing")).toBeNull();
expect(container.querySelector('a[href="/settings/pricing#pricing-claude"]')).not.toBeNull();
fireEvent.click(screen.getByTestId("cc-tab-skills"));
await settle();
expect(container.querySelector("section#pricing")).toBeNull();
expect(container.querySelector("section#hooks")).toBeNull();
});
});
+54 -13
View File
@@ -85,11 +85,13 @@ A dashboard shared by several people runs in **server mode** ([SERVER-MODE.md](S
| `POST /api/auth/login` `{username, password}` | `{user}` + the cookie; `401 EBADLOGIN`, `429 ETOOMANY`, `404 ENOTSERVER` in local mode |
| `POST /api/auth/logout` | `{ok: true}`, cookie cleared |
In server mode a **member** sees only rows of the workspaces they own (`lanes.owner_id`): every list, count, chart and cost answer is limited to them, and a request naming another user's session, agent, workspace or run (in the path, query or body) answers `404 ENOTFOUND`. Admin and analyst see everything; `GET /api/users` lists every account with its workspaces, sessions and 7/30-day `{cost, tokens}`, and `GET /api/users/audit[?limit=]` the staff reads of members' sessions (both `403 EADMIN` for a member). See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees).
In server mode a **member** sees only rows of the workspaces they own (`lanes.owner_id`): every list, count, chart and cost answer is limited to them, and a request naming another user's session, agent, workspace or run (in the path, query or body) answers `404 ENOTFOUND`. Admin and analyst see everything; `GET /api/users` lists every account with its workspaces, sessions and 7/30-day `{cost, tokens}`, and `GET /api/users/audit[?limit=]` the staff reads of members' sessions and admins' account changes (both `403 EADMIN` for a member). See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees).
An admin changes accounts with `POST /api/users` (`{username, password, role}` → `201 {user}`), `PATCH /api/users/:id` (`{role?, disabled?}`), `POST /api/users/:id/password` (`{password}`; that user's logins end) and `POST /api/users/:id/workspaces` (`{laneId}`; the workspace's owner becomes that user). Every one carries `currentPassword`, the admin's own password: `403 EREAUTH` when it is wrong or missing, `429 ETOOMANY` after 10 wrong tries in 15 minutes. `409 ESELF` for an admin's own role or disable, `409 EEXISTS` for a taken name, `400 EBADUSER`/`EBADPASSWORD`/`EBADROLE`, `404 ENOUSER`/`ENOLANE`; an analyst gets `403 EREADONLY`, a member `403 EADMIN`, and local mode `403 ELOCALMODE`. No answer carries a password hash; each change is written to the audit log.
`GET /api/accounts[?force=1]` lists the Claude account pool ([SERVER-MODE.md](SERVER-MODE.md#claude-account-pool)): `{accounts: [{name, addedAt, expiresAt, expired, livePanes, spend5h, spend7d, quota: {fiveHour, sevenDay, checkedAt, error} | null, quotaPercent, full}], blockPercent}`. It never includes a token. `force=1` re-reads every quota first. Run rows (`/api/run/history`) carry `account`, the pool account their pane ran on (`null` = the machine's own login).
`GET /api/settings/info` includes `server.version` (the running dashboard release). Pair with `agenthub version` or the Settings About panel to confirm client and server builds match after deploy.
`GET /api/settings/info` includes `server.version` (the running dashboard release). Pair with `agenthub version` or Settings → System to confirm client and server builds match after deploy.
```mermaid
sequenceDiagram
@@ -580,6 +582,7 @@ that file.
```http
GET /api/lanes/:id/git/log?limit=30[&file=<path>][&ref=<branch>]
GET /api/lanes/:id/git/blame?path=<path>
GET /api/lanes/:id/git/file?path=<path>[&rev=work|HEAD|<sha>]
PUT /api/lanes/:id/git/file {path, content, baseSha}
GET /api/lanes/:id/git/raw?path=<image>[&rev=work|HEAD|<sha>]
@@ -596,10 +599,16 @@ answer `{available:false}` with HTTP **200** for a cwd git cannot read.
refs, parents}]}`, newest first in `--date-order` (no parent before its
children; `parents` = parent shas, first parent first), `limit` clamped to 200. `file=` narrows it to the
commits touching that path — relative, no `..` segment, else
`400 EBADPATH`. `ref=` reads another branch's history instead of HEAD's
`400 EBADPATH` — with each `parents` REWRITTEN to the file's own previous
commit (`--parents`), so the file's graph is one joined-up line. `ref=` reads another branch's history instead of HEAD's
(where the panel's Cherry-pick finds its commits): a local or
remote-tracking branch that exists, else `400 ENOBRANCH`. Reading one commit's diff is
`/git/changes?range=commit&sha=`.
- **`/git/blame`** → `{available, path, commits: {<sha>: {short, author,
date, summary}}, lines: [<sha>|null]}` — `git blame --porcelain` of the
working-tree file (Annotate); `null` = a line not committed yet. Same
lexical guard as `/git/file`, `403 ESECRET`, `400 ETOOBIG` over 1 MB,
`400 ENOFILE`; an untracked file answers `{available:false}`.
- **`PUT /git/file`** saves the whole-file editor (same-origin guarded;
`server/lib/lane-file-write.js`). Same path guards as the read, plus: the
file must already exist, be text and within the 1 MB cap, and `baseSha`
@@ -689,12 +698,15 @@ POST /api/lanes/:id/git/commit-message
The Changes panel's ✨ button beside the commit box. Runs the user's own
`claude` CLI — `claude -p --safe-mode --no-session-persistence --tools "" --model haiku`
— on the **staged** diff of the workspace's cwd (or `?session=` worktree), sent on
— on the **staged** diff of the workspace's cwd (or `?session=` worktree) — or,
with a body `{"paths": [...]}` (the panel's ticked files), on those files'
working-tree diff against HEAD, untracked ones included — sent on
stdin together with the last ten commit subjects so the message follows the
repo's style. Only reads: no tool, hook or plugin runs, no transcript is saved,
and a `settings.local.json` is never part of the diff. Same-origin, because
each call spends the user's Claude quota. Answers `{"message": "..."}`;
`400 ENOSTAGED` with nothing staged, `400 ENOTOOL` without `claude` on PATH,
`400 ENOSTAGED` with nothing staged (or no readable ticked file), `400 EBADPATH`
for a path git does not report changed, `400 ENOTOOL` without `claude` on PATH,
`502 EAI` when the CLI fails, answers empty or runs past 120 s.
#### Act on a workspace's working copy
@@ -718,7 +730,7 @@ cannot collide on `index.lock`.
| `discard` | `git restore --source=HEAD --staged --worktree` (tracked) / `git clean -fd` (untracked) | `{"paths": ["..."], "confirm": true}` |
| `stage-hunk` | `git apply --cached` of ONE hunk of `git diff -U<context> HEAD -- <path>` — or, with `block`, of only that hunk's `block`-th run of +/- lines (the other runs folded into context, as an edited `git add -p` hunk) | `{"path": "...", "hunk": "@@ -1,2 +1,3 @@", "block": 0, "context": 3}` |
| `discard-hunk` | `git apply -R` of that one hunk (or that one `block` of it), off the working tree | `{"path": "...", "hunk": "@@ ... @@", "block": 0, "context": 3, "confirm": true}` |
| `commit` | `git add -A` then `git commit -m <message>`, or the index as it stands with `only: "staged"` | `{"message": "...", "only": "staged"}` |
| `commit` | `git add -A` then `git commit -m <message>`, or the index as it stands with `only: "staged"`, or — with `paths` — `git commit --only -- <paths>`: exactly those files' working-tree content (an untracked one is added first, and taken back out if the commit fails), every other change left as it was | `{"message": "...", "only": "staged"}` / `{"message": "...", "paths": ["..."]}` |
| `switch` | `git switch [-c] <branch>` | `{"branch": "...", "create": false}` |
| `branch-create` | `git branch <branch> [<base>]` — never switches | `{"branch": "...", "base": "..."}` |
| `branch-delete` | `git branch -d <branch>` | `{"branch": "..."}` |
@@ -730,12 +742,24 @@ cannot collide on `index.lock`.
| `cherry-pick` | `git cherry-pick <sha>` — a non-merge commit, onto a clean repo-root workspace | `{"sha": "9b3e74a"}` |
| `revert` | `git revert --no-edit <sha>` — same guards as `cherry-pick` | `{"sha": "9b3e74a"}` |
| `undo-commit` | `git reset --soft HEAD~1` — only an UNPUSHED, non-merge, non-root HEAD that is still `sha`; its changes stay staged | `{"confirm": true, "sha": "<HEAD the user saw>"}` |
| `amend` | `git commit --amend -m <message>`, or `--no-edit` with no message — only an UNPUSHED HEAD that is still `sha`; takes the index as it stands | `{"message": "...", "sha": "<HEAD>"}` |
| `amend` | `git commit --amend -m <message>`, or `--no-edit` with no message — only an UNPUSHED HEAD that is still `sha`; takes the index as it stands, or with `paths` exactly those files (`--only`; an empty list rewords and needs a message) | `{"message": "...", "sha": "<HEAD>", "paths": ["..."]}` |
| `ignore` | appends one anchored, escaped `/<path>` line per path to the WORKSPACE's own `.gitignore` (created if missing, never a line it already has) — untracked paths only (`400 ETRACKED`), never through a symlink or onto a non-regular file (`400 EBADPATH`) | `{"paths": ["..."]}` |
```json
{ "action": "commit", "ok": true, "output": "[feat/x 9b3e74a] fix: the thing" }
```
- `commit`/`amend` also take `hunks` — `{"<path>": [{"hunk": "@@ … @@", "blocks": [0]}]}`
with `context` — to commit only those change blocks of a tracked,
non-renamed file (alone or beside whole `paths`): the commit is built in a
temporary index from git's own fresh diff (never text from the request) and
the real index entries of the committed paths are reset to the new HEAD
afterwards, the rest left as an unstaged change. A hunk no longer in the diff
is `409 EHUNKGONE`, an untracked path `400 EUNTRACKED`, a malformed entry
`400 EBADPATH`.
- `commit`/`amend` with `paths` refuse a conflicted path (`409 ECONFLICTS`) —
a merge is finished by `merge-commit` — and any path git does not report
changed (`400 EBADPATH`).
- `pull` is **always** `--ff-only` and `switch` refuses a dirty tree
(`400 EDIRTY`): neither can invent a merge commit or a conflict underneath a
running agent. There is deliberately no rebase, hard reset or stash here —
@@ -843,7 +867,7 @@ DELETE /api/git-credentials/:protocol/:host Delete one (404 ENOTFOUND)
AgentHub's OWN git logins, one per `protocol + host`, shared by everyone using
this install; the **Git credentials** tab of Settings
(`/settings/git-credentials`) and the git bar's login form both write
(`/settings/git`) and the git bar's login form both write
here. Stored in `<CLAUDE_HOME>/agenthub-git-credentials/credentials.json`
(dir `0700`, file `0600`). **No response ever carries a password.** Writes are
same-origin guarded; a bad protocol/host is `400 EBADHOST`, an empty login
@@ -1786,7 +1810,24 @@ GET /api/settings/autostart
PUT /api/settings/autostart
```
Settings → About's **Start AgentHub automatically** switch. `GET` answers `{ available, kind, enabled, foreign }`: `kind` is `systemd` (a `--user` unit) or `launchd` (a LaunchAgent), `available` is false with no systemd `--user` / no macOS GUI login, `foreign` is true when a unit named `agenthub` exists that AgentHub did not write. `PUT { enabled: boolean }` (same-origin only) enables or disables the unit — writing one for the running server first when none exists — and answers the same shape. It never starts or stops the server. Off by default: a plugin install gets no unit until this is turned on. `400 EBADVALUE` for a non-boolean, `400 EUNAVAILABLE` with no service manager, `409 EFOREIGN` for a foreign unit.
Settings → System's **Start AgentHub automatically** switch. `GET` answers `{ available, kind, enabled, foreign }`: `kind` is `systemd` (a `--user` unit) or `launchd` (a LaunchAgent), `available` is false with no systemd `--user` / no macOS GUI login, `foreign` is true when a unit named `agenthub` exists that AgentHub did not write. `PUT { enabled: boolean }` (same-origin only) enables or disables the unit — writing one for the running server first when none exists — and answers the same shape. It never starts or stops the server. Off by default: a plugin install gets no unit until this is turned on. `400 EBADVALUE` for a non-boolean, `400 EUNAVAILABLE` with no service manager, `409 EFOREIGN` for a foreign unit.
#### Status-area icon
```http
GET /api/settings/tray
PUT /api/settings/tray
```
Settings → System's **Show AgentHub icon in the status area** switch (`server/lib/tray.js`). `GET` answers `{ available, enabled, running }`: `available` is false in server mode and on a Linux machine with no graphical session (`DISPLAY`/`WAYLAND_DISPLAY`, read from `systemctl --user show-environment` too), `running` is true while the icon process heartbeats. `PUT { enabled: boolean }` (same-origin only) removes or writes `<data dir>/runtime/tray-off` and starts or stops the icon process at once; it never touches the server. On by default. `400 EBADVALUE` for a non-boolean.
#### Quit AgentHub
```http
POST /api/settings/quit
```
Settings → System's **Quit AgentHub** and `agenthub stop` (issue #65; same-origin only, admin-only in server mode). Writes `<data dir>/runtime/stopped`, kills every `agenthub-lane-*` tmux session (their runs are recorded `killed`), answers `{ ok: true, killed }` and then stops the server: through `systemctl --user stop --no-block agenthub.service` / `launchctl bootout` when AgentHub's unit is running it (a SIGTERM would be restarted by `Restart=always`/`KeepAlive`), else by SIGTERM. While the flag exists the plugin bootstrap starts no server; every server clears it when it starts listening, so a boot/login through an enabled autostart unit brings the dashboard back.
---
@@ -2068,7 +2109,7 @@ curl -X DELETE http://localhost:4820/api/pricing/gpt-5.1-codex
GET /api/pricing/cost?tz_offset=<minutes>&sources=<csv>&lane=<id>&tool=<id>&from=<YYYY-MM-DD>&to=<YYYY-MM-DD>&date=<YYYY-MM-DD>
```
The total cost with a per-model `breakdown`, plus `daily_costs: [{date, cost}]` by local date (`tz_offset` is the browser's `getTimezoneOffset()`). `date=<YYYY-MM-DD>` prices only that local day — `total_cost`, `breakdown`, `by_tool` and `daily_costs` all cover just it (any other shape is `400 INVALID_INPUT`); the status bar's **Today** asks for the local date and shows the day's token total beside it, listing tokens and cost per model on hover (as it does for **Workspace** and **Session**). A day is the day the tokens were SPENT: each session's total is split over the local days of its `token_usage_hourly` rows (one per UTC hour, from each Claude transcript message's timestamp), and whatever the total holds beyond those rows — a session recorded before that table and whose transcript is gone, sub-agent files, Codex and Antigravity sessions — is booked to the session's start date. The days always add up to the total. `lane=<id>` narrows it to the sessions that workspace owns (`sessions.lane_id`, or held by one of its `dashboard_runs` and not stamped yet — the rule of `GET /api/sessions?lane=`; a non-numeric id is `400 INVALID_INPUT`); the status bar shows it as **Workspace $…** on the Workspace page, beside **Session $…** from `GET /api/pricing/cost/:sessionId` for the replayed session or the focused pane's. `tool=<id>` narrows it to one AI tool's sessions (`sessions.tool`), and `by_tool: [{tool, cost}]` (most expensive first) splits the total per tool — each tool priced on its own rows, so the code-execution free allowance applies per tool. A Codex turn on the fast/priority service tier is booked as `speed = fast` (priced with the model's priority rates, which the pricing sync writes into the fast columns), one on the flex tier as `service_tier = flex` (half price, like batch).
The total cost with a per-model `breakdown`, plus `daily_costs: [{date, cost}]` by local date (`tz_offset` is the browser's `getTimezoneOffset()`). `date=<YYYY-MM-DD>` prices only that local day — `total_cost`, `breakdown`, `by_tool` and `daily_costs` all cover just it (any other shape is `400 INVALID_INPUT`); the status bar's **Today** asks for the local date and shows the day's token total beside it, listing tokens and cost per model on hover (as it does for **Workspace** and **Session**). A day is the day the tokens were SPENT: each session's total is split over the local days of its `token_usage_hourly` rows (one per UTC hour, from each Claude transcript message's timestamp), and whatever the total holds beyond those rows — a session recorded before that table and whose transcript is gone, sub-agent files, Codex and Antigravity sessions — is booked to the session's start date. The days always add up to the total. `lane=<id>` narrows it to the sessions that workspace owns (`sessions.lane_id`, or held by one of its `dashboard_runs` and not stamped yet — the rule of `GET /api/sessions?lane=`; a non-numeric id is `400 INVALID_INPUT`); the status bar shows it as **Workspace $…** on the Workspace page, beside **Session $…** from `GET /api/pricing/cost/:sessionId` for the replayed session or the focused pane's. `tool=<id>` narrows it to one AI tool's sessions (`sessions.tool`), and `by_tool: [{tool, cost}]` (most expensive first) splits the total per tool — each tool priced on its own rows, so the code-execution free allowance applies per tool. A Codex turn on the fast/priority service tier is booked as `speed = fast` (priced with the model's priority rates, which the pricing sync writes into the fast columns), one on the flex tier as `service_tier = flex` (half price, like batch). A Claude request whose whole prompt (input + cache read + cache write) is above 100k tokens is also counted into the bucket's `long_*_tokens` (returned on each `breakdown` entry) and that part is priced at the rule's `long_input_per_mtok` / `long_output_per_mtok` (Haiku 5.5: 5x; cache rates scale by the input ratio; a rule with no long rates prices it at its standard rates).
`from` / `to` (local dates, inclusive, read with `tz_offset`; either end may be left open) narrow `total_cost`, `breakdown`, `by_tool`, `feature_costs` and `unpriced_models` to the tokens SPENT on those days (the same split as `daily_costs`). `daily_costs` itself stays the whole series, so a 30-day trend or a month-to-date pace is unaffected by the range. A malformed date, a date from year 9999, or `from` after `to` is `400 INVALID_INPUT`.
@@ -2083,7 +2124,7 @@ curl "http://localhost:4820/api/pricing/cost?tz_offset=-420&from=2026-09-01&to=2
GET /api/pricing/quota[?force=1]
```
How much of each native tool's plan quota is spent: `{ tools: [{ id: "claude"|"codex", active, label?, account?, expiresAt?, windows: [{ label, usedPercent, resetsAt }], error?, asOf? }], checkedAt }` (`server/lib/quota.js`). **Claude** asks Anthropic's `GET /api/oauth/usage` with the access token the CLI holds right now — never refreshed, so an expired token answers `error: "ELIVEEXPIRED"` until Claude Code refreshes it — cached 5 min (`force=1`, the status bar's click, re-asks at most every 30 s); no OAuth login, no entry. That live account is `active: true`; every OTHER saved auth profile follows as `active: false` with its `label`, asked with its OWN stored token only while that token is valid — never refreshed here, since a refresh rotates the saved credentials (only the Profiles modal's **Check quota** does that) — and, once expired, showing the last figure this server read (`asOf`) or `error: "EPROFILEEXPIRED"`. Each Claude token's owner is also asked (`GET /api/oauth/profile`): the live row carries the label and email of the account its token belongs to (`.claude.json` can name another), and a saved profile whose token belongs to a different account answers `error: "EWRONGACCOUNT"` with no windows, never that other account's usage. **Codex** reads the newest `rate_limits` from its rollout journals, no network (`asOf` = when the journal recorded it; a window past its reset reads 0). `account` is the signed-in email and `expiresAt` when the stored access token runs out — Claude's `claudeAiOauth.expiresAt`, Codex's `auth.json` access-token `exp` (JWT claims read unverified; no token leaves the server). Labels: `5h`, `wk`, `mo`, else `<n>d`/`<n>h` (the hover spells `wk`/`mo` out as week/month). The status bar shows one "N% used" chip per tool (its live account) at its right end and, on hover, every account grouped per tool with a row per window — bar, percent, reset time.
How much of each native tool's plan quota is spent: `{ tools: [{ id: "claude"|"codex"|"antigravity"|"cursor"|"grok"|"kimi"|"opencode", active, label?, account?, plan?, expiresAt?, windows: [{ label, usedPercent, resetsAt, group? }], error?, asOf? }], checkedAt }` (`server/lib/quota.js`). **Claude** asks Anthropic's `GET /api/oauth/usage` with the access token the CLI holds right now — never refreshed, so an expired token answers `error: "ELIVEEXPIRED"` until Claude Code refreshes it — cached 5 min (`force=1`, the status bar's click, re-asks at most every 30 s); no OAuth login, no entry. That live account is `active: true`; every OTHER saved auth profile follows as `active: false` with its `label`, asked with its OWN stored token only while that token is valid — never refreshed here, since a refresh rotates the saved credentials (only the Profiles modal's **Check quota** does that) — and, once expired, showing the last figure this server read (`asOf`) or `error: "EPROFILEEXPIRED"`. Each Claude token's owner is also asked (`GET /api/oauth/profile`): the live row carries the label and email of the account its token belongs to (`.claude.json` can name another), and a saved profile whose token belongs to a different account answers `error: "EWRONGACCOUNT"` with no windows, never that other account's usage. **Codex** reads the newest `rate_limits` from its rollout journals, no network (`asOf` = when the journal recorded it; a window past its reset reads 0). **Antigravity** runs the catalog-detected `agy` as `agy --print /usage --output-format json` — the CLI's own `/usage` command, no model call (`num_turns` 0, no conversation) — in a private empty temp directory with a 60 s timeout, only while the tool is enabled in AI Tools and `agy --version` is ≥ 1.1.11 (an older agy may bill `/usage` as a model request); cached like Claude, a failed run shows the last figure (`asOf`) or `error: "EUPSTREAM"`. One window per pool bucket (`5h`, `wk`), `group` naming the pool ("Gemini Models", "Claude and GPT models"); the report names no account and no plan. Running agy may let it update itself; `AGENTHUB_AGY_USAGE=0` never runs it (no entry). **Cursor** reads the Cursor CLI's (`cursor-agent`) own login, `$XDG_CONFIG_HOME/cursor/auth.json` (default `~/.config`), and sends it ONLY to cursor.com's undocumented `GET /api/usage-summary` as the `WorkosCursorSessionToken=<user id>::<token>` cookie (user id from `~/.cursor/cli-config.json` `authInfo`), while its JWT is valid and never refreshed — expired or a 401 is `error: "ETOKENEXPIRED"`; cached like Claude; no file (macOS keeps it in the keychain), no entry. One `mo` window: `individualUsage.plan.totalPercentUsed`, resetting at `billingCycleEnd` (an Enterprise seat without `plan` shows its plan name and no window). **Grok Build** reads the CLI's `auth.x.ai` session in `$GROK_HOME/auth.json` (default `~/.grok`) and sends it only to `cli-chat-proxy.grok.com`'s `GET /v1/billing?format=credits` (`wk` = `creditUsagePercent`, `mo` = `used / monthlyLimit`). **Kimi Code** reads `$KIMI_CODE_HOME/credentials/kimi-code.json` (default `~/.kimi-code`) and sends it only to `api.kimi.com`'s `GET /coding/v1/usages` (`5h`/`wk`/`mo` from `limit_5h`/`limit_7d`/`limit_month_total`). Both are used only while valid and never refreshed (`error: "ETOKENEXPIRED"`; a Kimi refresh would rotate the CLI's refresh token and sign it out); no file, no entry. **OpenCode Go** (`id: "opencode"`, `plan: "Go"`) reads opencode's own `opencode-go` API key — `auth.json`, else the `credential` table of `opencode.db`, under `$XDG_DATA_HOME/opencode` (default `~/.local/share`) — and sends it only to `opencode.ai`'s `GET /zen/go/v1/usage` (`5h`/`wk`/`mo` meters); an opencode with no Go key has no entry. These readers follow Orca's (MIT). All three cached like Claude. `plan` is the subscription's name as the tool reports it — Claude's `subscriptionType`, Codex's `chatgpt_plan_type` id-token claim, Cursor's `membershipType`, Grok's `subscriptionTier` — shown as a badge beside the account. `account` is the signed-in email and `expiresAt` when the stored access token runs out — Claude's `claudeAiOauth.expiresAt`, Codex's and Cursor's access-token `exp` (JWT claims read unverified; no token leaves the server but Cursor's own, to cursor.com). Labels: `5h`, `wk`, `mo`, else `<n>d`/`<n>h` (the hover spells `wk`/`mo` out as week/month). The status bar shows one "N% used" chip per tool (its live account) at its right end and, on hover, every account grouped per tool with a row per window — bar, percent, reset time.
#### Analytics
```http
@@ -2307,7 +2348,7 @@ Pulls history from **every enabled** source sequentially (one SSH connection at
#### Push ingest (`POST /api/hooks/ingest-batch`)
For a machine the dashboard cannot reach over SSH: it pushes its own hook payloads. Off until **Settings → Data → Push ingest** creates a token (`POST /api/settings/push-token`, returned once; only its SHA-256 is stored as `AGENTHUB_PUSH_TOKEN_SHA256`).
For a machine the dashboard cannot reach over SSH: it pushes its own hook payloads. Off until **Settings → Database → Push ingest** creates a token (`POST /api/settings/push-token`, returned once; only its SHA-256 is stored as `AGENTHUB_PUSH_TOKEN_SHA256`).
```http
POST /api/hooks/ingest-batch
@@ -2567,7 +2608,7 @@ DELETE /api/pets/:id Delete a pet (its folder under the data di
The mascot (Sagi) can wear a pet: right-click it for **Hide pet**, **Size** (48–250 px), **Choose pet** (Sagi, the featured pets, your own — deletable), **Upload your own…**, **Import .codex-pet bundle…**, **Find more on codex-pets.net** and **Pet settings…**, which opens the pet block of **Settings → Appearance** (`/settings/appearance#pet`, `client/src/components/settings/PetSettings.tsx`; the old `/settings/pet` redirects there): show/hide, speech bubbles, size with a live preview, and the same choose / download / upload / import / delete as a grid. The menu and the page share `components/Sagi/usePets.ts`. Which pet is worn and its size are viewer preferences in `localStorage` (`agenthub.pet`, `agenthub.pet.size`); the pets live on the server, one folder each under `<data dir>/pets/<id>/` with a `meta.json` AgentHub writes.
A **Codex pet** is the `.codex-pet.zip` format Codex Desktop uses (the bundles shared on codex-pets.net and in `mileson/codex-pets`): `pet.json` (`id`, `displayName`, `description`, `spritesheetPath`) and `spritesheet.webp`, a 1536×1872 atlas of 8 columns × 9 rows of 192×208 frames — idle, run right, run left, waving, jumping, failed, waiting, running, review, with 6/8/8/4/5/8/6/6/6 frames. The mascot plays the row of its mood (an agent working → running, waiting for you → waiting, an error → failed, a finished turn → jumping, idle/asleep → idle); dragged it runs left or right with the drag (and along the glide to its edge), hovered it waves. Each row keeps its own pace with a longer hold on its last frame (idle ~220 ms a frame, then ~1.4 s). Only the two files are read, at the archive root or one folder deep; an entry's size is checked from the zip's directory before it is inflated; the sheet must be a WebP of exactly that size (`400 EBADPET` otherwise). An uploaded image is shown still and bobs; SVG is refused because an SVG served from the dashboard's origin runs script.
A **Codex pet** is the `.codex-pet.zip` format Codex Desktop uses (the bundles shared on codex-pets.net and in `mileson/codex-pets`): `pet.json` (`id`, `displayName`, `description`, `spritesheetPath`) and `spritesheet.webp`, a 1536×1872 atlas of 8 columns × 9 rows of 192×208 frames — idle, run right, run left, waving, jumping, failed, waiting, running, review, with 6/8/8/4/5/8/6/6/6 frames — or a v2 atlas (`spriteVersionNumber: 2`, 1536×2288, 11 rows) whose rows 9–10 add 16 gaze poses, 22.5° apart clockwise from straight up. The mascot plays the row of its mood (an agent working → running, waiting for you → waiting, an error → failed, a finished turn → jumping, a live session at rest → review, idle/asleep → idle); dragged it runs left or right with the drag (and along the glide to its edge), hovered it waves, and a v2 pet at idle turns toward the pointer (back to idle 2 s after the pointer rests). Frames play at Codex's own timings (`codex-rs/tui/src/pets/model.rs`: e.g. running 120 ms a frame, its last 220 ms); idle plays at the drawn pace (280/110/110/140/140/320 ms), a sleeping pet at Codex's 6× slower one. Waving, jumping, failed and review are moments — they play three times and settle into idle; running, waiting and the run rows loop. Only the two files are read, at the archive root or one folder deep; an entry's size is checked from the zip's directory before it is inflated; the sheet must be a WebP of exactly one of those sizes (`400 EBADPET` otherwise). An uploaded image is shown still and bobs; SVG is refused because an SVG served from the dashboard's origin runs script.
**Featured pets are downloaded, never bundled.** The three offered (Goku, Border Collie, 11) are other people's uploads to codex-pets.net with no licence to redistribute them, so AgentHub ships none of their art: `POST /api/pets/featured/:id` takes only an id from the fixed list in `server/lib/pets.js` (`404 ENOPET` for any other, with no request made) and fetches `https://codex-pets.net/api/pets/<id>` and `/spritesheet` — no URL from the request, no redirects followed. A pet id is always made by the server (`p-<12 hex>` for an upload, `cp-<featured id>`), every write is same-origin (`403 EBADORIGIN`), and at most 50 pets are kept (`409 ETOOMANY`). Other errors: `413 ETOOBIG`, `502 EFETCH` (codex-pets.net unreachable or refusing).
+3 -1
View File
@@ -99,7 +99,9 @@ The CLI talks to the local dashboard server — **API-backed commands require it
| Command | Description |
| ------- | ----------- |
| `agenthub status` | At-a-glance up/down indicator (`●` running / `○` not running); exits `1` when down |
| `agenthub start [--port N]` | Start the production server **in the background** (detached; survives closing the terminal), wait up to 30 s for `/api/health`, print the URL + PID and the `kill <pid>` stop command. Logs append to `data/agenthub-server.log`. No-ops with a pointer when a server is already up. Requires a built client (`npm run build` once) |
| `agenthub start [--port N]` | Start the production server **in the background** (detached; survives closing the terminal), wait up to 30 s for `/api/health`, print the URL + PID and the `agenthub stop` hint. Logs append to `data/agenthub-server.log`. No-ops with a pointer when a server is already up. Requires a built client (`npm run build` once). Always ends a Quit (`agenthub stop`); in a plugin install it starts the server the way a session does — by running `scripts/plugin-bootstrap.js` |
| `agenthub stop` | **Quit AgentHub** (issue #65): closes every workspace terminal (`agenthub-lane-*` tmux sessions — running agents stop), stops the server — through `systemctl --user stop` / `launchctl bootout` when the autostart unit runs it — and keeps it stopped: Claude Code's `SessionStart` bootstrap starts no server while `<data dir>/runtime/stopped` exists. `agenthub start` (or any server starting, e.g. a boot/login with autostart on) clears it. With the server already down it only sets the flag |
| `agenthub tray` | Show AgentHub's **status-area icon** now, in the foreground (`scripts/tray.js`): Open AgentHub, Quit AgentHub (a submenu asks again, then runs `agenthub stop`), Start AgentHub after a quit (`agenthub start`), Hide this icon. A production server already keeps the icon up on its own unless Settings → System turned it off; this command does not change that setting. Exits at once when an icon is already running |
| `agenthub repl` (aliases `shell`, `i`) | Open the **interactive shell** — see [Interactive REPL](#interactive-repl) |
| `agenthub server setup [--hostname name] [--admin name]` | Turn **server mode** on (logins for a shared dashboard): writes `AGENTHUB_MODE=server` (and `DASHBOARD_ALLOWED_HOSTS`) to the settings file and creates the first admin, asking for its password. Works with the server down; restart it afterwards. See [SERVER-MODE.md](SERVER-MODE.md) |
| `agenthub server off` / `agenthub server status` | Back to local mode (accounts kept) / show the mode and account count |
+5 -2
View File
@@ -398,7 +398,7 @@ CREATE TABLE notifications (
Per-model pricing rules for cost calculation, keyed by `model_pattern` (a SQL-style glob; `%` matches any characters). Rates are per **million** tokens (USD). The longest matching pattern wins.
**Automatic rates.** `server/lib/pricing-sync.js` runs at server start and every 15 minutes, and fetches LiteLLM's public price list (`model_prices_and_context_window.json` on GitHub) at most once a day. Each pass (1) INSERTS a row, `source = 'sync'`, for every Claude, OpenAI or Gemini model seen in `token_usage` whose family (the id minus a `-YYYYMMDD` / `-YYYY-MM-DD` suffix; OpenAI means `gpt-…`, `o<n>…` or `codex-…`; Gemini means `gemini-…`, taken whole) has no `<family>%` row of its own, AND for every **current** model of the three vendors before anyone uses it — a list key that is a family itself, `mode` `chat`/`responses`, not past its `deprecation_date`, not an audio/search/TTS/realtime/image/embedding variant, and of the current generation (any `claude-<name>-…`; `gpt-` 5 and up; `gemini-` 3 and up — `MIN_GEN`); (2) UPDATES the rates of its own `sync` rows when the list changes; (3) adopts a row from before the `source` column (`NULL`: a seed, an old sync insert or an old edit) as `sync` only while its rates still equal the list exactly. A row edited through `PUT /api/pricing` becomes `source = 'user'` and is never touched again; a deleted row's pattern goes to `model_pricing_dismissed` and is never added back. An entry is taken only from its own vendor (`litellm_provider` `anthropic` / `openai`, and `vertex_ai-language-models` or `gemini` for Gemini); an OpenAI or Gemini row carries no cache-write charge unless the list names one, and no fast rate. Gemini's >200k-token rate is not modelled (the base rate is used). A proxy's model id (`clinepass/…`) is never priced automatically. `POST /api/settings/reset-pricing` empties `model_pricing_dismissed` for its scope and runs a sync right after reseeding (waiting up to 3 s), so the OpenAI and Gemini rows a reset wiped come straight back; with `{scope: "claude"}` (the Claude Code tab's **Reset Defaults**) it deletes and reseeds only `claude-…` rows, so other vendors' rows and a custom proxy row survive. A rate outside 0–1000 $/MTok is rejected, and a failed fetch changes nothing. Set `AGENTHUB_PRICING_SYNC=0` (or `false`/`off`) to turn it off.
**Automatic rates.** `server/lib/pricing-sync.js` runs at server start and every 15 minutes, and fetches LiteLLM's public price list (`model_prices_and_context_window.json` on GitHub) at most once a day. Each pass (1) INSERTS a row, `source = 'sync'`, for every Claude, OpenAI or Gemini model seen in `token_usage` whose family (the id minus a `-YYYYMMDD` / `-YYYY-MM-DD` suffix; OpenAI means `gpt-…`, `o<n>…` or `codex-…`; Gemini means `gemini-…`, taken whole) has no `<family>%` row of its own, AND for every **current** model of the three vendors before anyone uses it — a list key that is a family itself, `mode` `chat`/`responses`, not past its `deprecation_date`, not an audio/search/TTS/realtime/image/embedding variant, and of the current generation (any `claude-<name>-…`; `gpt-` 5 and up; `gemini-` 3 and up — `MIN_GEN`); (2) UPDATES the rates of its own `sync` rows when the list changes; (3) adopts a row from before the `source` column (`NULL`: a seed, an old sync insert or an old edit) as `sync` only while its rates still equal the list exactly. A row edited through `PUT /api/pricing` becomes `source = 'user'` and is never touched again; a deleted row's pattern goes to `model_pricing_dismissed` and is never added back. An entry is taken only from its own vendor (`litellm_provider` `anthropic` / `openai`, and `vertex_ai-language-models` or `gemini` for Gemini); an OpenAI or Gemini row carries no cache-write charge unless the list names one, and no fast rate. A Claude entry's `*_above_100k_tokens` input/output rates fill `long_input_per_mtok` / `long_output_per_mtok` (Haiku 5.5); Gemini's >200k-token rate is not modelled (the base rate is used). A proxy's model id (`clinepass/…`) is never priced automatically. `POST /api/settings/reset-pricing` empties `model_pricing_dismissed` for its scope and runs a sync right after reseeding (waiting up to 3 s), so the OpenAI and Gemini rows a reset wiped come straight back; with `{scope: "claude"}` (the Claude Code tab's **Reset Defaults**) it deletes and reseeds only `claude-…` rows, so other vendors' rows and a custom proxy row survive. A rate outside 0–1000 $/MTok is rejected, and a failed fetch changes nothing. Set `AGENTHUB_PRICING_SYNC=0` (or `false`/`off`) to turn it off.
```sql
CREATE TABLE model_pricing (
@@ -411,6 +411,8 @@ CREATE TABLE model_pricing (
cache_write_1h_per_mtok REAL NOT NULL DEFAULT 0, -- 1h-ephemeral cache-write tier
fast_input_per_mtok REAL NOT NULL DEFAULT 0, -- fast-mode premium rates
fast_output_per_mtok REAL NOT NULL DEFAULT 0,
long_input_per_mtok REAL NOT NULL DEFAULT 0, -- prompt above 100k tokens
long_output_per_mtok REAL NOT NULL DEFAULT 0,
-- Time-limited introductory (promo) rates. When intro_until is set, usage on
-- or before that date (YYYY-MM-DD) is priced at the intro_* rates and usage
-- after it at the standard rates. All 0 / NULL = no promo.
@@ -434,6 +436,7 @@ CREATE TABLE model_pricing (
| `input_per_mtok` / `output_per_mtok` | REAL | NO | Standard input / output rate per 1M tokens |
| `cache_read_per_mtok` / `cache_write_per_mtok` / `cache_write_1h_per_mtok` | REAL | NO | Cache read + 5m/1h cache-write rates |
| `fast_input_per_mtok` / `fast_output_per_mtok` | REAL | NO | Fast-mode premium rates (0 = no premium) |
| `long_input_per_mtok` / `long_output_per_mtok` | REAL | NO | Rates for a request whose whole prompt (input + cache read + cache write) is above 100k tokens (`LONG_CONTEXT_TOKENS` in `server/lib/pricing-constants.js`); the cache rates scale by `long_input / input`. 0 = none. They price `token_usage`'s `long_*_tokens` subsets — the part of each count spent by such requests, kept at its high-water mark (`stmts.raiseLongTokens`; the exact reconcile path uses `setLongTokens`) |
| `intro_*_per_mtok` | REAL | NO | Introductory (promo) rates, mirroring the standard fields |
| `intro_until` | TEXT | YES | Promo cutoff `YYYY-MM-DD`. Usage on/before it uses the intro rates; NULL = no promo. Editable per-rule in Settings |
| `source` | TEXT | YES | `sync` = kept at LiteLLM's rate by the automatic sync (shown **Auto** in Settings), `user` = edited in Settings and never touched by the sync, NULL = from before this column (adopted as `sync` only while it equals the list) |
@@ -509,7 +512,7 @@ Managed through the `/api/remote-sources/*` routes; sync/status changes are broa
### token_usage_hourly
The same buckets as `token_usage` (session, model, speed, inference_geo, service_tier and the eight count columns, no baselines), split by `hour` — the UTC hour `YYYY-MM-DDTHH` a Claude transcript message was written in. The hook writer fills it from `TranscriptCache`'s `tokensByHour` on every transcript-bearing event (`writeHourly` in `server/lib/hourly-usage.js`, `stmts.upsertHourlyUsage`, MAX per column so a rewritten transcript never takes an hour back), and the server back-fills sessions that have tokens but no hourly rows a few seconds after each start (`backfillHourly`, one transcript per tick). Only the daily split of `GET /api/pricing/cost` reads it; `token_usage` stays the total, and what it holds beyond these rows is booked to the session's start date. Cascades with its session.
The same buckets as `token_usage` (session, model, speed, inference_geo, service_tier, the eight count columns and the five `long_*_tokens` subsets, no baselines), split by `hour` — the UTC hour `YYYY-MM-DDTHH` a Claude transcript message was written in. The hook writer fills it from `TranscriptCache`'s `tokensByHour` on every transcript-bearing event (`writeHourly` in `server/lib/hourly-usage.js`, `stmts.upsertHourlyUsage`, MAX per column so a rewritten transcript never takes an hour back), and the server back-fills sessions that have tokens but no hourly rows a few seconds after each start (`backfillHourly`, one transcript per tick). Only the daily split of `GET /api/pricing/cost` reads it; `token_usage` stays the total, and what it holds beyond these rows is booked to the session's start date. Cascades with its session.
---
+1
View File
@@ -260,6 +260,7 @@ DASHBOARD_ANTIGRAVITY_POLL_MS=15000 # agy conversation files → Antigravity
AGENTHUB_GEMINI_DIR= # where agy keeps its data (default ~/.gemini)
DASHBOARD_CURSOR_POLL_MS=15000 # Cursor agent transcripts → Cursor sessions (no tokens)
AGENTHUB_CURSOR_DIR= # where Cursor keeps its data (default ~/.cursor)
AGENTHUB_AGY_USAGE= # 0 never runs `agy --print /usage` for the Usage panel's Antigravity quota (default on; running agy may update it)
# Pricing and saved settings
AGENTHUB_PRICING_SYNC= # 0|false|off disables automatic pricing from LiteLLM (default on)
+1 -1
View File
@@ -23,7 +23,7 @@ Comprehensive guide to integrating with Claude Code's hook system for real-time
Claude Code provides a hook system that allows external tools to receive real-time events during agent execution. Agent Dashboard uses these hooks to capture session lifecycle, tool executions, and notifications.
> **Privacy (Settings → Data → Privacy, `server/lib/privacy.js`):** a hook whose `cwd` is an ignored folder (`AGENTHUB_IGNORE_CWD`, or under one — path-boundary match) is answered `{ok: true, ignored: true}` and nothing is stored; with **Redact secrets** on (`AGENTHUB_REDACT_SECRETS=1`) the stored `events.data` and `events.summary` have API keys, tokens, bearer headers, private keys and `password=`-style values replaced by `[redacted]` (`trim_event_data` / `redact_text` in `server/db.js`). The hook route still reads the raw payload in memory, and the transcript on disk is untouched. Hooks from another machine can be pushed to `POST /api/hooks/ingest-batch` with a token (see [API.md → Push ingest](./API.md#push-ingest-post-apihooksingest-batch)).
> **Privacy (Settings → Database → Privacy, `server/lib/privacy.js`):** a hook whose `cwd` is an ignored folder (`AGENTHUB_IGNORE_CWD`, or under one — path-boundary match) is answered `{ok: true, ignored: true}` and nothing is stored; with **Redact secrets** on (`AGENTHUB_REDACT_SECRETS=1`) the stored `events.data` and `events.summary` have API keys, tokens, bearer headers, private keys and `password=`-style values replaced by `[redacted]` (`trim_event_data` / `redact_text` in `server/db.js`). The hook route still reads the raw payload in memory, and the transcript on disk is untouched. Hooks from another machine can be pushed to `POST /api/hooks/ingest-batch` with a token (see [API.md → Push ingest](./API.md#push-ingest-post-apihooksingest-batch)).
> **Cursor (informational):** Live hooks fire from Claude Code. **Cursor** sessions that only exist as JSONL under `~/.claude` (Cursor uses the same paths locally) are still counted — they appear via startup import, continuous project sync, or remote SSH sync, not via hooks.
+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:
+10 -6
View File
@@ -66,10 +66,12 @@ Internal build — all rights reserved.
images dragged onto a terminal or a prompt box are uploaded and attached by
their saved path.
- **Settings as one page per section** (`/settings/<section>`): a left nav
grouped General (General / Data / About / Appearance / Pet / Notifications) /
Agents / Routing / Cost / Git, a search box (Ctrl+F) over every section whose
results deep-link a row's sub-tab and anchor, a page per installed AI tool, per-vendor model pricing, and which
untracked worktrees a workspace's Add folder suggests.
grouped General (Appearance — language, theme, pet — / Notifications /
System) / Agents (AI tools, one page per installed AI tool, AI Proxy) / Data
(Database / Pricing / Snapshots & reports / Git — logins and which untracked
worktrees a workspace's Add folder suggests), a search box (Ctrl+F) over every
section whose results deep-link a row's sub-tab and anchor. Model prices live
only on Pricing; a tool's page links there.
- **Claude Code + Codex configuration explorer.** Settings hosts one page per
tool: every plugin, skill, subagent, slash command, MCP server, hook, settings
file, memory file, marketplace, keybinding and statusline Claude Code knows
@@ -82,7 +84,9 @@ Internal build — all rights reserved.
OS live) and a theme per mode — dark: Default, Darcula, Apple Dark, Orca Dark;
light: Default, Apple Light, Canvas, Orca Light. Each mode
remembers its own theme, so the sidebar's quick toggle swaps between the two.
Per-browser only.
It also picks the workspace terminals' font: JetBrains Mono (default), the
rounded Maple Mono, or the browser's own fixed-width font (which may lack some
Vietnamese letters). Per-browser only.
- **Notification sounds.** Settings → Notifications picks a sound (None, Chime,
Pop, Alert — off by default) played with every notification shown, and each
notification type can pick its own.
@@ -209,7 +213,7 @@ agenthub service uninstall # disable and remove it
On Linux, `agenthub service install` also tries `loginctl enable-linger` so the service
starts on a cold boot, before anyone logs in; if that needs elevated rights on
your machine it prints the `sudo loginctl enable-linger <user>` command to run
once yourself. The same is a switch in the dashboard — Settings → About →
once yourself. The same is a switch in the dashboard — Settings → System →
**Start AgentHub automatically** (off by default; it enables or disables the
unit and never stops the running server). Plugin installs get it only once
that switch is on — see
+1 -1
View File
@@ -176,7 +176,7 @@ agenthub service uninstall # disable và gỡ bỏ
`agenthub service install` cũng thử chạy `loginctl enable-linger` để service khởi
động được cả khi cold boot (chưa ai login); nếu máy cần quyền cao hơn, lệnh
này sẽ in ra `sudo loginctl enable-linger <user>` để bạn tự chạy một lần.
Trong dashboard cũng có công tắc tương ứng — Settings → About → **Tự động khởi
Trong dashboard cũng có công tắc tương ứng — Settings → Hệ thống → **Tự động khởi
động AgentHub** (mặc định tắt; chỉ enable/disable unit, không bao giờ dừng
server đang chạy). Bản cài qua plugin chỉ được tự khởi động khi công tắc này
bật — xem
+56 -2
View File
@@ -131,7 +131,7 @@ waits on an install:
7. **CLI** — write the `~/.local/bin/agenthub` launcher, never clobbering a `agenthub` the bootstrap did not write. It pins the node that ran the bootstrap and falls back to `node` on `PATH` when that binary is gone (a Homebrew Cellar or nvm path deleted by an upgrade).
8. **UI build** — `client/` is copied into the runtime dir and built there (`npm ci --include=dev --ignore-scripts`, falling back to `npm install` with the same flags, then `tsc -b` and `vite build` run by the bootstrap's own `node` — the client's `build` script without `npm run`), landing in `runtime/client-dist` — the same directory the server serves from. Nothing in it looks `node` up on PATH: on Windows an install script or a `.bin/*.cmd` shim runs through cmd.exe, and the server's PATH may have no `node` (esbuild's postinstall failed there with "'node' is not recognized" and the update kept the old UI); esbuild, the only dependency with an install script, finds its binary without it. `--include=dev` is required, not tidy: the entire build toolchain (`vite`, `typescript`, `@vitejs/plugin-react`, `tailwindcss`, `@types/*`) is a devDependency, and npm reads an inherited `NODE_ENV=production` as `--omit=dev`. Every bootstrap descended from the running server carries that env — the self-update chain, and any `SessionStart` fired by a `claude` inside a tmux pane the server started — so without the flag the install drops `vite` and the build dies on missing types. Skipped when a bundle for the current version already exists; a failure here does not fail the bootstrap (the API still works, and `/agenthub-open` can retry) but does mean `/run` 404s until it's fixed — and when a bundle from the PREVIOUS version is still on disk the failure is quieter still: the dashboard keeps serving the old UI against the new API, visible only as the Settings page's `UI build vX (rebuild client to match)` line.
9. **Server** — when the user has turned **Start AgentHub automatically** on
(Settings → About; OFF by default), install/refresh the
(Settings → System; OFF by default), install/refresh the
`agenthub.service` unit on Linux — on macOS with a GUI login, the
`io.smartgift.agenthub` LaunchAgent (see [Auto-start on boot](#auto-start-on-boot)) — instead
of a bare spawn, so the server is supervised and comes back after a reboot
@@ -241,9 +241,43 @@ cushion — one build deep, because a full copy of this repo is ~130 MB. Outside
a git checkout the parent directory holds the user's own work. Best effort
throughout: a directory that will not delete is left alone, never an error.
### Quit
Settings → System → **Quit AgentHub** or `agenthub stop` (issue #65,
`server/lib/quit.js`, `POST /api/settings/quit`) closes every workspace
terminal and stops the server, through the service manager when the unit runs
it. It writes `<data dir>/runtime/stopped` first: while that file exists
`fastPathOk` is true with no server and the worker starts none (`--force`, i.e.
`/agenthub-update`, overrides it; a stale unit is still repaired, not started).
Any server that starts clears it — `agenthub start`, which in a plugin install
runs this bootstrap, or a boot/login through an enabled autostart unit — so
autostart keeps working across a reboot.
### Status-area icon
Like 9router's, AgentHub puts an icon beside the clock — GNOME's top bar, the
macOS menu bar, the Windows notification area (`server/lib/tray.js`,
`scripts/tray.js`). It is its own process, drawn by the `systray2` package's
helper binary (installed with the other runtime dependencies), so a server
restart or a Quit never takes it away: after a Quit its **Start AgentHub** is
the way back. A production server starts it once it is listening and checks
every minute (a Linux server started at boot has no `DISPLAY` until someone
logs in; three quick deaths in a row and that server stops trying). It is on by
default; **Hide this icon** in its menu and Settings → System → **Show AgentHub
icon in the status area** write `<data dir>/runtime/tray-off`, and only that
switch removes it again. One icon per data dir: `<data dir>/runtime/tray.json`
holds its pid and install root and is touched every 5 s as a heartbeat — a
stale file is never trusted, so a pid reused after a reboot is never
signalled. A server replaces an icon from another install root (the previous
plugin build) once at start. Never in server mode, nor from a development
server. The icon starts at login only when the dashboard does (start at login
below); there is no separate autostart entry. Verified on Ubuntu GNOME (X11);
on macOS `systray2` ships only an x86_64 helper (Apple Silicon runs it through
Rosetta 2, else no icon appears), and Windows is untested.
### Auto-start on boot
Opt-in, and off by default: Settings → About → **Start AgentHub automatically**
Opt-in, and off by default: Settings → System → **Start AgentHub automatically**
(`GET/PUT /api/settings/autostart`, `server/lib/autostart.js`). The switch IS
the unit's enabled state — on writes a unit for the running server when none
exists and enables it, off disables it; neither starts or stops the server. A
@@ -318,6 +352,26 @@ SIGTERM from anything at all would otherwise leave the dashboard down for good.
An explicit `systemctl stop` — which is what `agenthub service uninstall` and
the Settings panel do — still stops it.
The unit also sets **`KillMode=process`**: a stop or an update's restart ends
the server process alone. tmux forks its server from whichever process first
asks for a session — the dashboard itself after a boot with autostart on — so
it can live in the unit's cgroup, and `control-group` would kill it and every
lane pane on each update. **An update never closes a pane; only Quit does**
(it kills the `agenthub-lane-*` sessions itself). Existing units pick this up
when the next update rewrites them; a checkout's unit on `agenthub service
install`. The launchd twin is `AbandonProcessGroup`.
Belt and braces on Linux: the session that STARTS the tmux server (no server
running yet) is created through `systemd-run --user --scope`
(`server/lib/tmux.js`), so the server lives in a transient scope of its own —
not in `agenthub.service`, and not in the terminal or IDE scope a dev server
was started from. Stopping either, systemd-oomd reaping either, or an old unit
still on `control-group` leaves the panes alone. With no user manager to ask
(SSH without linger, no systemd) it falls back to a plain start. On macOS tmux
daemonizes into its own session and the plist abandons its process group. On
Windows (psmux) whether the server outlives the dashboard has not been
verified yet.
`agenthub service install` (the checkout path) keeps using `installUserService`:
it runs from your own shell, outside the unit's cgroup, so it may block and
start in one step.
+11 -2
View File
@@ -115,7 +115,8 @@ Workspaces made before server mode, or by the host account, have none.
session a member owns (its page, transcript, events or cost), the read is
written to the audit log: `agenthub users audit`, or `GET /api/users/audit`.
- `GET /api/users` (admin/analyst) lists every account with its workspaces,
sessions, and the tokens and $ it used in the last 7 and 30 days.
sessions, and the tokens and $ it used in the last 7 and 30 days — the
Users page in the sidebar shows it, with the audit log under it.
A member also cannot open the host's own configuration, or any view across all
users: Claude Code / Codex config (except the command and skill lists the
@@ -183,7 +184,15 @@ apart, not agents.
## Accounts
Accounts are managed from the host's shell, never from a web route:
An admin manages accounts on the **Users** page (sidebar; admins and analysts
see it, an analyst only reads): add a user, change a role, disable or enable,
reset a password, give a workspace. Every change asks for the admin's OWN
password again — the server refuses without it (`EREAUTH`, and `ETOOMANY`
after 10 wrong tries in 15 minutes) — an admin can never demote or disable
themselves (`ESELF`), and each change is written to the audit log. In local
mode these routes refuse (`ELOCALMODE`): there is no login to check.
The same from the host's shell:
```bash
agenthub users list
+32
View File
@@ -12,3 +12,35 @@ Goal (2026-10-07): finish the server part — S2 ownership as proposed, review i
- [x] Full suites: server 2532/2535 (3 lane-detect fail on a foreign process holding port 3000, same on clean HEAD), client 1074/1074; browser e2e member vs admin; docs synced
- [x] Upgrade-impact review (2026-10-07): old vs new API diff on a copy of the real DB (local mode identical but for noise), old+new builds sharing one migrated DB, 5-lens review + verify. Fixed: lock names (any single dir name ≤255, holders with spaces normalised, CLI fails fast on non-409), AuthGate rendering on an older server / DASHBOARD_TOKEN 401, pane_token_hash kept out of exports; documented the LAN origin change. Suites: server 2536/2536, client 1078/1078
- Blocked on sudo / a real host: S0 spike, S3 (one Linux user per account), S4 (shared /opt tools)
## 2026-10-08 — per-user isolation (S3/S4 + per-user proxy/plugins + Codex pool)
Decided: own Linux user per account; skills/plugins = admin baseline + user's own; AI Proxy = user's own + admin's shared (no key shown); tool binaries shared under `/opt/agenthub/tools`; Claude pool shared (exists); NEW Codex multi-account pool. Spike allowed on this machine with sudo (undo log: `/tmp/agh-spike/UNDO.sh`).
- [x] S0 spike (2026-10-08, this machine; test users agh-test1/2 kept for S3 dev, undo `/tmp/agh-spike/UNDO.sh`) → results below
- [ ] Per-user AI Proxy store (+ admin shared)
- [ ] S3 provisioning + spawn/attach as the account
- [ ] S4 shared tools in /opt
- [ ] Per-user skills/plugins (cc-config per account HOME, admin baseline)
- [ ] Codex account pool
- [ ] Security review, docs, suites
### S0 results (claude 2.1.293, codex 0.155.1, tmux 3.7c, node 26)
| Check | Result |
| --- | --- |
| Layout: `/srv/agenthub/users` 0751 root; home `<acct>:agenthub` 0750; member's own group private | GO — a member cannot list or read another home or the host's |
| claude / codex / node run from root-owned `/opt/agenthub/tools/bin` with a foreign HOME | GO — nothing written outside the home; a root-owned binary cannot self-update (plus `DISABLE_AUTOUPDATER=1`) |
| Per-account tmux server, socket in a setgid `agenthub` dir, `server-access -a <host>` + `chmod 0770` | GO — host send-keys/capture/attach work, the pane runs as the member; tmux keeps group rw across attach; another member is refused |
| Hooks as the member: `node /opt/.../hook-handler.js` → `CLAUDE_DASHBOARD_PORT` on loopback | GO — SessionStart/UserPromptSubmit arrive with the member's transcript_path |
| Codex rollouts readable by the dashboard via group | GO — 0644 under 0775 |
| Claude transcripts readable by the dashboard via group | **NO** — `.jsonl` created 0600, `sessions/` 0700; a default ACL is masked to `---`. Needs a decision |
| agy | not run (it self-updates on any run); root-owned /opt stops that |
| Host group membership | needs a new login: a running `systemd --user` keeps its old groups → server mode wants a system unit with `SupplementaryGroups=agenthub` |
| Shared Claude pool token across Linux users | setup-token is long-lived, no refresh rotation → safe |
| Codex pool with ChatGPT logins | untested (no spare account): refresh tokens rotate, so a copied `auth.json` must be copied back under a lock; API-key accounts have no such risk |
### Open decision: how the dashboard reads every tool's files (user, 2026-10-08: "all AI tools, lightest")
Proposal: server mode runs as a system unit with `AmbientCapabilities=CAP_DAC_READ_SEARCH` (read any file, no write; zero per-read cost, no per-tool code) fenced by `InaccessiblePaths=` (`/etc/shadow`, `/etc/gshadow`, `/etc/sudoers*`, `/root`, `/home`) and `NoNewPrivileges`. Every WRITE in a member's folder (git-ops, file editor, worktrees, tmux spawn) runs as the member via `sudo -u <acct>` (sudoers: host may run commands as `%agenthub-members` only, never root). Git reads need `-c safe.directory=*` (dubious-ownership check). Verification command not yet run (blocked by the agent's permission classifier — user to run it).
Code work waits until the in-flight Quit/tray/Users change-set is committed, then a new branch.
+398 -2
View File
@@ -3,7 +3,7 @@
openapi: 3.0.3
info:
title: Agent Dashboard for Claude Code API
version: 2.5.11
version: 2.5.12
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
contact:
name: Nguyễn Ngọc Trí Vĩ
@@ -1100,6 +1100,12 @@ components:
fast_output_per_mtok:
type: number
description: Fast-mode output rate (0 = none)
long_input_per_mtok:
type: number
description: Input rate for a request whose prompt is above 100k tokens (0 = none); cache rates scale by long_input / input
long_output_per_mtok:
type: number
description: Output rate for a request whose prompt is above 100k tokens (0 = none)
source:
type: string
nullable: true
@@ -1190,6 +1196,17 @@ components:
type: integer
code_execution_requests:
type: integer
long_input_tokens:
type: integer
description: Part of input_tokens spent by requests whose prompt was above 100k tokens
long_output_tokens:
type: integer
long_cache_read_tokens:
type: integer
long_cache_write_tokens:
type: integer
long_cache_write_1h_tokens:
type: integer
cost:
type: number
matched_rule:
@@ -11556,6 +11573,33 @@ paths:
description: ESECRET — `.claude/settings.local.json` (where an older build kept the AI Proxy key) is refused at every rev, at any depth, and under any path that resolves to it (a symlinked `.claude`).
'404':
description: Lane not found.
/api/lanes/{id}/git/blame:
get:
tags:
- Lanes
summary: 'Annotate: each line''s last commit'
description: '`git blame --porcelain` of the working-tree file, for the Changes panel''s Annotate gutter. Same lexical lane guard and secret refusal as /git/file, capped at 1 MB. A line nobody committed yet is null. An untracked file (no history) answers {available:false}. Read-only, no same-origin guard.'
operationId: blameLaneFile
parameters:
- name: id
in: path
required: true
schema:
type: integer
- name: path
in: query
required: true
schema:
type: string
responses:
'200':
description: '{available, path, commits: {<sha>: {short, author, date, summary}}, lines: [<sha>|null]}.'
'400':
description: EBADPATH, ENOFILE or ETOOBIG.
'403':
description: ESECRET.
'404':
description: Lane not found.
/api/lanes/{id}/git/raw:
get:
tags:
@@ -11745,6 +11789,7 @@ paths:
- revert
- undo-commit
- amend
- ignore
requestBody:
required: false
content:
@@ -11779,10 +11824,24 @@ paths:
type: array
items:
type: string
description: stage, unstage, discard; each must be a path git reports changed.
description: 'stage, unstage, discard, ignore (untracked only, else 400 ETRACKED; appends an anchored line to the workspace''s .gitignore); each must be a path git reports changed. commit and amend: commit exactly these files'' working-tree content (`git commit --only`), an untracked one included, leaving every other change — staged or not — uncommitted; a conflicted one is 409 ECONFLICTS. amend with an empty list rewords only (a message is then required, else 400 ENOCHANGES).'
path:
type: string
description: stage-hunk, discard-hunk; a path git reports changed (not untracked).
hunks:
type: object
additionalProperties:
type: array
items:
type: object
properties:
hunk:
type: string
blocks:
type: array
items:
type: integer
description: 'commit, amend (optional): the hunk-level commit — per tracked, non-renamed path, only these change blocks of these hunks (`@@` lines as the diff at `context` returned them). Built in a temporary index from git''s own fresh diff and committed from it; the real index entries of the committed paths are then reset to the new HEAD, the rest of a partial file left as an unstaged change. A hunk no longer in the diff is 409 EHUNKGONE, an untracked path 400 EUNTRACKED, a bad shape 400 EBADPATH.'
hunk:
type: string
description: stage-hunk, discard-hunk; the hunk's `@@` line as the diff returned it. The server re-reads the diff and applies git's own text of that hunk; a hunk no longer present is 409 EHUNKGONE.
@@ -12706,6 +12765,75 @@ paths:
description: 'EBADORIGIN: another site asked'
'409':
description: 'EFOREIGN: a unit named agenthub that AgentHub did not write'
/api/settings/tray:
get:
tags:
- Settings
summary: Whether AgentHub's icon shows in the OS status area
operationId: getTray
responses:
'200':
description: Status-area icon state
content:
application/json:
schema:
type: object
required:
- available
- enabled
- running
properties:
available:
type: boolean
description: False in server mode, or on Linux with no graphical session
enabled:
type: boolean
description: On by default
running:
type: boolean
description: The icon process is heartbeating
put:
tags:
- Settings
summary: Show or hide the status-area icon
description: Clears or writes `<data dir>/runtime/tray-off` and starts or stops the icon process. Never touches the server. Same-origin only.
operationId: setTray
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- enabled
properties:
enabled:
type: boolean
responses:
'200':
description: The new state
content:
application/json:
schema:
type: object
required:
- available
- enabled
- running
properties:
available:
type: boolean
description: False in server mode, or on Linux with no graphical session
enabled:
type: boolean
description: On by default
running:
type: boolean
description: The icon process is heartbeating
'400':
description: 'EBADVALUE: `enabled` is not a boolean'
'403':
description: 'EBADORIGIN: another site asked'
/api/settings/codex-hooks:
get:
tags:
@@ -13426,6 +13554,274 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
post:
tags:
- Auth
operationId: createServerUser
summary: 'Admin: add an account. Needs the admin''s own password again (currentPassword); audited'
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- username
- password
- currentPassword
properties:
username:
type: string
password:
type: string
minLength: 8
role:
type: string
enum:
- admin
- analyst
- member
default: member
currentPassword:
type: string
responses:
'201':
description: Created
content:
application/json:
schema:
type: object
properties:
user:
$ref: '#/components/schemas/AuthUser'
'400':
description: EBADUSER / EBADPASSWORD / EBADROLE
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'403':
description: EREAUTH (own password wrong) / EREADONLY / EADMIN / ELOCALMODE
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'409':
description: EEXISTS
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'429':
description: ETOOMANY — 10 wrong passwords in 15 minutes
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/users/{id}:
patch:
tags:
- Auth
operationId: updateServerUser
summary: 'Admin: change a role and/or disable (ending every login) or enable. Never your own (ESELF)'
parameters:
- name: id
in: path
required: true
schema:
type: integer
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- currentPassword
properties:
role:
type: string
enum:
- admin
- analyst
- member
disabled:
type: boolean
currentPassword:
type: string
responses:
'200':
description: Changed
content:
application/json:
schema:
type: object
properties:
user:
$ref: '#/components/schemas/AuthUser'
'400':
description: EBADPASSWORD / EBADROLE / EBADFIELD
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'403':
description: EREAUTH (own password wrong) / EREADONLY / EADMIN / ELOCALMODE
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'404':
description: ENOUSER / ENOLANE
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'409':
description: ESELF — your own role or disable
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'429':
description: ETOOMANY — 10 wrong passwords in 15 minutes
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/users/{id}/password:
post:
tags:
- Auth
operationId: resetServerUserPassword
summary: 'Admin: a new password for that user; every login of theirs ends'
parameters:
- name: id
in: path
required: true
schema:
type: integer
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- password
- currentPassword
properties:
password:
type: string
minLength: 8
currentPassword:
type: string
responses:
'200':
description: Changed
content:
application/json:
schema:
type: object
properties:
user:
$ref: '#/components/schemas/AuthUser'
'400':
description: EBADPASSWORD / EBADROLE / EBADFIELD
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'403':
description: EREAUTH (own password wrong) / EREADONLY / EADMIN / ELOCALMODE
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'404':
description: ENOUSER / ENOLANE
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'409':
description: ESELF — your own role or disable
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'429':
description: ETOOMANY — 10 wrong passwords in 15 minutes
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/users/{id}/workspaces:
post:
tags:
- Auth
operationId: assignServerUserWorkspace
summary: 'Admin: make that user the owner of a workspace (lane)'
parameters:
- name: id
in: path
required: true
schema:
type: integer
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- laneId
- currentPassword
properties:
laneId:
type: integer
currentPassword:
type: string
responses:
'200':
description: Changed
content:
application/json:
schema:
type: object
properties:
user:
$ref: '#/components/schemas/AuthUser'
'400':
description: EBADPASSWORD / EBADROLE / EBADFIELD
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'403':
description: EREAUTH (own password wrong) / EREADONLY / EADMIN / ELOCALMODE
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'404':
description: ENOUSER / ENOLANE
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'409':
description: ESELF — your own role or disable
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'429':
description: ETOOMANY — 10 wrong passwords in 15 minutes
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/users/audit:
get:
tags:
+80 -2
View File
@@ -1,12 +1,12 @@
{
"name": "agenthub",
"version": "2.5.11",
"version": "2.5.12",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agenthub",
"version": "2.5.11",
"version": "2.5.12",
"hasInstallScript": true,
"license": "UNLICENSED",
"dependencies": {
@@ -18,6 +18,7 @@
"multer": "^2.0.0",
"redoc": "^2.5.3",
"swagger-ui-express": "^5.0.1",
"systray2": "^2.1.4",
"tar": "^7.4.3",
"uuid": "^11.1.0",
"web-push": "^3.6.7",
@@ -1150,6 +1151,20 @@
"license": "MIT",
"optional": true
},
"node_modules/fs-extra": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
"integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
"universalify": "^2.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -1224,6 +1239,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC"
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@@ -1453,6 +1474,18 @@
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
},
"node_modules/jsonfile": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
"integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
"license": "MIT",
"dependencies": {
"universalify": "^2.0.0"
},
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
@@ -2788,6 +2821,42 @@
"url": "https://github.com/Mermade/oas-kit?sponsor=1"
}
},
"node_modules/systray2": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/systray2/-/systray2-2.1.4.tgz",
"integrity": "sha512-ncviZ7m2fecb2tAAx7pl4nlbnwvSyL4GIKnRUvfiJXTsHysh3chSlxVxNk7Yj8jlfF2TsBn2CjLwCfhJQkR7/w==",
"license": "MIT",
"dependencies": {
"debug": "^4.3.2",
"fs-extra": "^10.0.0"
},
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/systray2/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/systray2/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/tar": {
"version": "7.5.13",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz",
@@ -2907,6 +2976,15 @@
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"license": "MIT"
},
"node_modules/universalify": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
"license": "MIT",
"engines": {
"node": ">= 10.0.0"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "agenthub",
"version": "2.5.11",
"version": "2.5.12",
"bin": {
"agenthub": "bin/agenthub.js"
},
@@ -79,6 +79,7 @@
"multer": "^2.0.0",
"redoc": "^2.5.3",
"swagger-ui-express": "^5.0.1",
"systray2": "^2.1.4",
"tar": "^7.4.3",
"uuid": "^11.1.0",
"web-push": "^3.6.7",
+11
View File
@@ -22,6 +22,7 @@ const {
normalizeGeo,
normalizeTier,
accumulateBucket,
longArgs,
} = require("../server/lib/token-usage");
const {
@@ -822,6 +823,11 @@ function writeSessionTokens(dbModule, sessionId, tokensByModel, opts = {}) {
tokens.webFetch || 0,
tokens.codeExec || 0
);
// The exact path also clears a stale subset; the default one only raises.
const long = longArgs(tokens);
const key = [sessionId, tokens.model, tokens.speed, tokens.geo, tokens.tier];
if (opts.exact) stmts.setLongTokens.run(...(long || [0, 0, 0, 0, 0]), ...key);
else if (long) stmts.raiseLongTokens.run(...long, ...key);
written++;
}
}
@@ -852,6 +858,11 @@ function subagentTokenRows(tokensByModel) {
web_search_requests: b.webSearch || 0,
web_fetch_requests: b.webFetch || 0,
code_execution_requests: b.codeExec || 0,
long_input_tokens: b.longInput || 0,
long_output_tokens: b.longOutput || 0,
long_cache_read_tokens: b.longCacheRead || 0,
long_cache_write_tokens: b.longCacheWrite || 0,
long_cache_write_1h_tokens: b.longCacheWrite1h || 0,
};
const hasUsage =
row.input_tokens ||
+1 -1
View File
@@ -247,4 +247,4 @@ if (require.main === module) {
// isOurEntry is also used by scripts/plugin-bootstrap.js to strip hook entries
// a previous checkout install left behind (they would double-count events).
module.exports = { installHooks, isInsideContainer, isOurEntry, pluginProvidesHooks };
module.exports = { HOOK_TYPES, installHooks, isInsideContainer, isOurEntry, pluginProvidesHooks };
+8 -2
View File
@@ -35,6 +35,7 @@ const crypto = require("crypto");
const { spawn, spawnSync } = require("child_process");
const { getDataDir, getClaudeHome, getSettingsPath } = require("../server/lib/claude-home");
const { isQuit } = require("../server/lib/quit");
const { getServerInfoPath } = require("../server/lib/server-info");
const { isOurEntry } = require("./install-hooks");
@@ -178,7 +179,8 @@ function fastPathOk(rt = runtimeDir()) {
// worker. (Observed: 271 PreToolUse events for ~135 real tool calls, and one
// Task spawning two subagent rows.)
if (hasLegacyHooks()) return false;
return serverIsLive();
// Quit (server/lib/quit.js): nothing to start until the user starts it.
return serverIsLive() || isQuit(rt);
}
/* ------------------------------------------------------- node version gate */
@@ -740,7 +742,11 @@ function bootstrap(rt = runtimeDir(), { force = false } = {}) {
// running process must be replaced"; `unitStale` is "the unit names a cache
// directory that is no longer this one", which is invisible until a reboot
// and must be repaired even while a perfectly healthy server is up.
const needsServer = movedRoot || !serverIsLive();
// A Quit holds every start off (an explicit `--force` from
// /agenthub-update overrides it); the unit is still repaired below.
const quit = !force && isQuit(rt);
if (quit) log(rt, "AgentHub was quit — not starting it (agenthub start brings it back)");
const needsServer = !quit && (movedRoot || !serverIsLive());
const unitStale = svc.unitIsStale("agenthub", PLUGIN_ROOT);
if (needsServer || unitStale) {
const unit = {
+7
View File
@@ -127,6 +127,12 @@ function unitIsActive(name) {
return String(res.stdout || "").trim() === "active";
}
/** `KillMode=process`: a stop or an update's restart ends the server alone.
* tmux forks its server from whichever process first asks for a session —
* the dashboard itself, after a boot with autostart on — so it can sit in
* this unit's cgroup, and the default `control-group` would kill it and every
* lane pane with it. Panes close only on Quit (`server/lib/quit.js`), which
* kills them itself. The launchd twin is `AbandonProcessGroup`. */
function unitContent({ description, workingDir, execStart, env }) {
const envLines = Object.entries(env)
.map(([k, v]) => `Environment=${k}=${v}`)
@@ -142,6 +148,7 @@ WorkingDirectory=${workingDir}
ExecStart=${execStart}
Restart=always
RestartSec=3
KillMode=process
${envLines}
[Install]
+7
View File
@@ -42,6 +42,13 @@ const env = {
AGENTHUB_SETTINGS_FILE: path.join(tmuxDir, "settings.env"),
// No suite may fetch LiteLLM's price list (reset-pricing syncs at once).
AGENTHUB_PRICING_SYNC: "0",
// Nor run the developer's real agy (it may update itself) or send their
// real Cursor, Grok, Kimi or OpenCode login for the quota.
AGENTHUB_AGY_USAGE: "0",
XDG_CONFIG_HOME: tmuxDir,
XDG_DATA_HOME: tmuxDir,
GROK_HOME: tmuxDir,
KIMI_CODE_HOME: tmuxDir,
DASHBOARD_PIPELINES_DIR: path.join(dir, "fixtures", "pipelines"),
};
delete env.TMUX;
+180
View File
@@ -0,0 +1,180 @@
#!/usr/bin/env node
/**
* @file tray.js — the AgentHub icon in the OS status area, as its own process
* (started and kept alive by `server/lib/tray.js`; `agenthub tray` runs it in
* the foreground). Draws through `systray2`'s helper binary, polls the
* dashboard's /api/health, and offers Open, Quit/Start (the CLI's own
* `agenthub stop|start`, so the icon stops and starts AgentHub exactly as the
* terminal does) and Hide this icon (the same off flag as Settings → System).
* One per data dir: `<data dir>/runtime/tray.json`, touched as a heartbeat.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
require("../server/lib/windows-hide").install(); // before child_process is read
const fs = require("fs");
const path = require("path");
const http = require("http");
const { spawn } = require("child_process");
const { dashboardUrl } = require("../server/lib/dashboard-url");
const { getDataDir } = require("../server/lib/claude-home");
const tray = require("../server/lib/tray");
const ROOT = path.resolve(__dirname, "..");
function loadSysTray() {
try {
return require("systray2").default;
} catch {
// A plugin install keeps its dependencies in the runtime dir; the CLI
// launcher does not put that on NODE_PATH the way the server's unit does.
return require(path.join(getDataDir(), "runtime", "node_modules", "systray2")).default;
}
}
/** npm drops the executable bit on systray2's bundled helper binaries. */
function chmodHelper() {
if (process.platform === "win32") return;
const name = process.platform === "darwin" ? "tray_darwin_release" : "tray_linux_release";
for (const id of ["systray2", path.join(getDataDir(), "runtime", "node_modules", "systray2")]) {
try {
fs.chmodSync(path.join(path.dirname(require.resolve(id)), "traybin", name), 0o755);
return;
} catch {
/* try the next location */
}
}
}
function isUp(base) {
return new Promise((resolve) => {
const req = http.get(`${base}/api/health`, { timeout: 2000 }, (res) => {
res.resume();
resolve(res.statusCode === 200);
});
req.on("timeout", () => req.destroy());
req.on("error", () => resolve(false));
});
}
function openUrl(url) {
const [cmd, args] =
process.platform === "darwin"
? ["open", [url]]
: process.platform === "win32"
? ["cmd", ["/c", "start", "", url]]
: ["xdg-open", [url]];
spawn(cmd, args, { detached: true, stdio: "ignore" }).unref();
}
function cli(command) {
spawn(process.execPath, [path.join(ROOT, "bin", "agenthub.js"), command], {
detached: true,
stdio: "ignore",
}).unref();
}
function main() {
const other = tray.liveTray();
if (other && other.pid !== process.pid) {
console.log(`AgentHub's icon is already running (pid ${other.pid})`);
return;
}
tray.writePid(process.pid, ROOT);
chmodHelper();
const SysTray = loadSysTray();
const item = (title, extra = {}) => ({ title, tooltip: "", enabled: true, ...extra });
const status = item("AgentHub", { enabled: false });
const open = item("Open AgentHub");
// Quit closes every terminal session, so it takes a second click (the web
// UI asks in a dialog; a tray menu has none).
const quitYes = item("Yes, quit — closes every terminal session");
const quit = item("Quit AgentHub", { items: [quitYes] });
const start = item("Start AgentHub", { hidden: true });
const hide = item("Hide this icon");
const systray = new SysTray({
menu: {
icon: fs.readFileSync(path.join(ROOT, "client", "public", "favicon.ico")).toString("base64"),
isTemplateIcon: false,
title: "",
tooltip: "AgentHub",
items: [status, open, quit, start, hide],
},
});
let up = null;
// After a Quit/Start click, "Stopping…"/"Starting…" stays until the server
// reaches the asked-for state (or 30 s pass) — not undone by the next poll.
let want = null;
let waitUntil = 0;
let base = dashboardUrl();
// systray2 finds an item by the id it stamped on OUR object — so update in place.
const show = (it, changes) => {
Object.assign(it, changes);
systray.sendAction({ type: "update-item", item: it });
};
const host = () => base.replace(/^https?:\/\//, "");
const poll = async () => {
try {
const now = new Date();
fs.utimesSync(tray.pidPath(), now, now);
} catch {
tray.writePid(process.pid, ROOT); // the file went missing: claim it again
}
base = dashboardUrl();
const next = await isUp(base);
if (Date.now() < waitUntil && next !== want) return;
waitUntil = 0;
if (next === up) return;
up = next;
show(status, { title: up ? `AgentHub — running (${host()})` : "AgentHub — stopped" });
show(open, { enabled: up });
show(quit, { hidden: !up });
show(start, { hidden: up, enabled: true, title: "Start AgentHub" });
};
const exit = () => {
try {
if (tray.liveTray()?.pid === process.pid) fs.rmSync(tray.pidPath(), { force: true });
} catch {
/* best effort */
}
systray.kill(true);
setTimeout(() => process.exit(0), 2000); // a helper that never started cannot be killed
};
process.on("SIGTERM", exit);
process.on("SIGINT", exit);
systray.onClick(({ item: clicked }) => {
if (clicked.__id === open.__id) openUrl(base);
else if (clicked.__id === quitYes.__id) {
cli("stop");
show(quit, { hidden: true });
show(start, { hidden: false, enabled: false, title: "Stopping…" });
[up, want, waitUntil] = [null, false, Date.now() + 30_000];
} else if (clicked.__id === start.__id) {
cli("start");
show(start, { enabled: false, title: "Starting…" });
[up, want, waitUntil] = [null, true, Date.now() + 30_000];
} else if (clicked.__id === hide.__id) {
tray.setEnabled(false); // writes the off flag and signals this process
}
});
systray
.ready()
.then(() => {
systray.onExit(() => process.exit(0)); // the helper exists only once ready
poll();
setInterval(poll, tray.HEARTBEAT_MS);
})
.catch((err) => {
console.error(`AgentHub icon could not start: ${err?.message || err}`);
exit();
});
}
if (require.main === module) main();
module.exports = { main };
+10 -10
View File
@@ -59,7 +59,7 @@ describe("/api/ai-tools", () => {
assert.equal(agy.path, path.join(BIN, "agy"));
assert.equal(agy.resume, true);
assert.equal(agy.bypass, true);
assert.equal(body.tools.find((t) => t.id === "gemini").installed, false);
assert.equal(body.tools.find((t) => t.id === "opencode").installed, false);
assert.equal("args" in agy, false);
});
@@ -76,7 +76,7 @@ describe("/api/ai-tools", () => {
it("answers ENOTOOL / EBADDEFAULT / EBADFIELD", async () => {
assert.equal((await request("PUT", "/api/ai-tools/nope", { enabled: false })).status, 404);
const bad = await request("PUT", "/api/ai-tools/gemini", { default: true });
const bad = await request("PUT", "/api/ai-tools/opencode", { default: true });
assert.equal(bad.status, 400);
assert.equal(bad.body.error.code, "EBADDEFAULT");
assert.equal((await request("PUT", "/api/ai-tools/claude", { enabled: "no" })).status, 400);
@@ -112,10 +112,10 @@ describe("/api/ai-tools", () => {
});
it("refresh re-detects", async () => {
fs.writeFileSync(path.join(BIN, "gemini"), "#!/bin/sh\n");
fs.chmodSync(path.join(BIN, "gemini"), 0o755);
fs.writeFileSync(path.join(BIN, "opencode"), "#!/bin/sh\n");
fs.chmodSync(path.join(BIN, "opencode"), 0o755);
const r = await request("POST", "/api/ai-tools/refresh");
assert.equal(r.body.tools.find((t) => t.id === "gemini").installed, true);
assert.equal(r.body.tools.find((t) => t.id === "opencode").installed, true);
});
it("/api/run/binary resolves through the catalog", async () => {
@@ -132,9 +132,9 @@ describe("lanes on catalog tools", () => {
fs.mkdirSync(d, { recursive: true });
return d;
};
let r = await request("POST", "/api/lanes", { cwd: dir("a"), title: "a", tool: "gemini" });
let r = await request("POST", "/api/lanes", { cwd: dir("a"), title: "a", tool: "opencode" });
assert.equal(r.status, 201);
assert.equal(r.body.lane.tool, "gemini");
assert.equal(r.body.lane.tool, "opencode");
r = await request("POST", "/api/lanes", { cwd: dir("b"), title: "b", tool: "omp" });
assert.equal(r.status, 201, "a tool that is not installed may still get a lane");
r = await request("POST", "/api/lanes", { cwd: dir("c"), title: "c", tool: "nope" });
@@ -212,8 +212,8 @@ describe("install / update / icons", () => {
const notInstalled = await request("POST", "/api/ai-tools/codex/update");
assert.equal(notInstalled.status, 409);
assert.equal(notInstalled.body.error.code, "ENOTINSTALLED");
// gemini was put on PATH by the refresh test, outside any npm prefix.
const notNpm = await request("POST", "/api/ai-tools/gemini/update");
// opencode was put on PATH by the refresh test, outside any npm prefix.
const notNpm = await request("POST", "/api/ai-tools/opencode/update");
assert.equal(notNpm.status, 409);
assert.equal(notNpm.body.error.code, "ENOTNPM");
const cross = await request("POST", "/api/ai-tools/copilot/install", undefined, {
@@ -241,7 +241,7 @@ describe("install / update / icons", () => {
assert.deepEqual(spawned.at(-1), [path.join(BIN, "claude"), "update"]);
const v = await request("GET", "/api/ai-tools/versions");
assert.equal(v.status, 200);
assert.equal(v.body.gemini.npmManaged, false);
assert.equal(v.body.opencode.npmManaged, false);
});
it("update-all answers ENOTHING when nothing has an update", async () => {
+13
View File
@@ -82,6 +82,19 @@ describe("commit-message", () => {
assert.doesNotMatch(input, /staged line/);
});
it("with paths, reads exactly those files' working-tree changes, untracked included", async () => {
write("a.txt", "one\nticked edit\n");
write("new.txt", "brand new\n");
write("other.txt", "not ticked\n");
write(".claude/settings.local.json", '{"env":{"KEY":"sk-ticked"}}\n');
const input = await buildInput(REPO, ["a.txt", "new.txt", ".claude/settings.local.json"]);
assert.match(input, /ticked edit/);
assert.match(input, /brand new/);
assert.doesNotMatch(input, /not ticked|staged line|sk-ticked|settings\.local/);
await assert.rejects(buildInput(REPO, ["nope.txt"]), { code: "EBADPATH" });
await assert.rejects(buildInput(REPO, []), { code: "ENOSTAGED" });
});
it("reports a failing or silent CLI as EAI", async () => {
await assert.rejects(suggestCommitMessage(REPO, { bin: fakeClaude("boom", 1) }), {
code: "EAI",
@@ -0,0 +1,301 @@
/**
* @file The Changes panel's JetBrains-style commit against a REAL repo:
* `commit`/`amend` with `paths` take exactly the ticked files' working-tree
* content (an untracked one included) and leave every other change — staged
* or not — where it was; a failed commit puts a ticked untracked file back
* out of the index. And `ignore` (Add to .gitignore): an anchored, escaped
* line in the workspace's own .gitignore, only for an untracked path, never
* twice, never through a symlink. Then the hunk-level commit (`hunks`: only
* the ticked change blocks, through a temporary index, the real index synced
* afterwards and the working tree untouched), `blameFile` (Annotate) and a
* file's history with its parents rewritten so the graph joins up.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, beforeEach } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const { runGitAction } = require("../lib/git-ops");
const { blameFile, listCommits } = require("../lib/git-diff");
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-commit-paths-"));
const REPO = path.join(ROOT, "repo");
const g = (...args) => {
const env = { ...process.env };
delete env.GIT_DIR;
delete env.GIT_WORK_TREE;
delete env.GIT_INDEX_FILE;
return execFileSync("git", args, { cwd: REPO, encoding: "utf8", env });
};
const write = (file, body) => fs.writeFileSync(path.join(REPO, file), body);
const read = (file) => fs.readFileSync(path.join(REPO, file), "utf8");
const status = () => g("status", "--porcelain=v1", "--untracked-files=all");
const inHead = () => g("ls-tree", "-r", "--name-only", "HEAD").split("\n").filter(Boolean);
beforeEach(() => {
fs.rmSync(REPO, { recursive: true, force: true });
fs.mkdirSync(REPO, { recursive: true });
g("init", "-q", "-b", "main");
g("config", "user.email", "t@h");
g("config", "user.name", "t");
write("a.txt", "a\n");
write("b.txt", "b\n");
write("c.txt", "c\n");
g("add", "-A");
g("commit", "-qm", "init");
});
describe("commit with paths", () => {
it("commits only the ticked files, untracked included, and leaves the rest", async () => {
write("a.txt", "a2\n"); // ticked, unstaged
write("b.txt", "b2\n");
g("add", "b.txt"); // staged but NOT ticked: must stay staged, uncommitted
write("c.txt", "c2\n"); // not ticked
write("new.txt", "n\n"); // ticked, untracked
await runGitAction(REPO, "commit", { message: "pick", paths: ["a.txt", "new.txt"] });
assert.equal(g("log", "-1", "--format=%s").trim(), "pick");
assert.deepEqual(
g("show", "--name-only", "--format=", "HEAD").split("\n").filter(Boolean).sort(),
["a.txt", "new.txt"]
);
assert.equal(g("show", "HEAD:a.txt"), "a2\n");
assert.match(status(), /^M {2}b\.txt$/m);
assert.match(status(), /^ M c\.txt$/m);
});
it("commits a partly staged file's working-tree content, a deletion too", async () => {
write("a.txt", "staged\n");
g("add", "a.txt");
write("a.txt", "staged\nand more\n");
fs.rmSync(path.join(REPO, "b.txt"));
await runGitAction(REPO, "commit", { message: "m", paths: ["a.txt", "b.txt"] });
assert.equal(g("show", "HEAD:a.txt"), "staged\nand more\n");
assert.ok(!inHead().includes("b.txt"));
assert.equal(status(), "");
});
it("refuses an empty list and a path git does not report changed", async () => {
write("a.txt", "a2\n");
await assert.rejects(runGitAction(REPO, "commit", { message: "m", paths: [] }), {
code: "EBADPATH",
});
await assert.rejects(runGitAction(REPO, "commit", { message: "m", paths: ["b.txt"] }), {
code: "EBADPATH",
});
});
it("puts a ticked untracked file back out of the index when the commit fails", async () => {
write("new.txt", "n\n");
const hook = path.join(REPO, ".git", "hooks", "pre-commit");
fs.writeFileSync(hook, "#!/bin/sh\nexit 1\n", { mode: 0o755 });
await assert.rejects(runGitAction(REPO, "commit", { message: "m", paths: ["new.txt"] }));
assert.match(status(), /^\?\? new\.txt$/m);
});
});
describe("amend with paths", () => {
it("an empty list rewords only, leaving the staged change out", async () => {
write("a.txt", "a2\n");
g("add", "a.txt");
const head = g("rev-parse", "HEAD").trim();
await runGitAction(REPO, "amend", { message: "reworded", sha: head, paths: [] });
assert.equal(g("log", "-1", "--format=%s").trim(), "reworded");
assert.equal(g("show", "HEAD:a.txt"), "a\n");
assert.match(status(), /^M {2}a\.txt$/m);
});
it("folds the ticked files in, and refuses an empty list with no message", async () => {
write("new.txt", "n\n");
const head = g("rev-parse", "HEAD").trim();
await assert.rejects(runGitAction(REPO, "amend", { sha: head, paths: [] }), {
code: "ENOCHANGES",
});
await runGitAction(REPO, "amend", { sha: head, paths: ["new.txt"] });
assert.equal(g("log", "-1", "--format=%s").trim(), "init");
assert.ok(inHead().includes("new.txt"));
assert.equal(g("rev-list", "--count", "HEAD").trim(), "1");
});
});
describe("ignore", () => {
it("appends an anchored, escaped line once, keeping the file's last line", async () => {
write(".gitignore", "node_modules");
write("debug*.log", "x\n");
write("note.md", "x\n");
await runGitAction(REPO, "ignore", { paths: ["debug*.log", "note.md"] });
assert.equal(read(".gitignore"), "node_modules\n/debug\\*.log\n/note.md\n");
assert.doesNotMatch(status(), /debug|note/);
});
it("creates the file when there is none, in a subdirectory too", async () => {
fs.mkdirSync(path.join(REPO, "dir"));
write("dir/x y.tmp", "x\n");
await runGitAction(REPO, "ignore", { path: "dir/x y.tmp" });
assert.equal(read(".gitignore"), "/dir/x y.tmp\n");
assert.doesNotMatch(status(), /x y\.tmp/);
});
it("refuses a tracked file and a .gitignore that is a symlink", async () => {
write("a.txt", "a2\n");
await assert.rejects(runGitAction(REPO, "ignore", { paths: ["a.txt"] }), { code: "ETRACKED" });
write("new.txt", "n\n");
const outside = path.join(ROOT, "outside-ignore");
fs.writeFileSync(outside, "");
fs.symlinkSync(outside, path.join(REPO, ".gitignore"));
await assert.rejects(runGitAction(REPO, "ignore", { paths: ["new.txt"] }), {
code: "EBADPATH",
});
assert.equal(fs.readFileSync(outside, "utf8"), "");
});
});
/** The `@@` lines of `file`'s HEAD → working tree diff at -U3, in order. */
const headers = (file) =>
g("diff", "-U3", "HEAD", "--", file)
.split("\n")
.filter((l) => l.startsWith("@@ "));
describe("commit with hunks", () => {
beforeEach(() => {
write("long.txt", `${Array.from({ length: 30 }, (_, i) => i + 1).join("\n")}\n`);
g("add", "long.txt");
g("commit", "-qm", "long");
});
const edit = (pairs) => {
let text = read("long.txt");
for (const [from, to] of pairs) text = text.replace(new RegExp(`^${from}$`, "m"), to);
write("long.txt", text);
};
it("commits only the ticked hunk, leaving the rest in the working tree, unstaged", async () => {
edit([
["3", "three"],
["25", "twentyfive"],
]);
const [first] = headers("long.txt");
await runGitAction(REPO, "commit", {
message: "part",
context: 3,
hunks: { "long.txt": [{ hunk: first, blocks: [0] }] },
});
const committed = g("show", "HEAD:long.txt");
assert.match(committed, /^three$/m);
assert.match(committed, /^25$/m);
assert.match(read("long.txt"), /^twentyfive$/m);
// The index followed the commit: the rest is an unstaged change only.
assert.match(status(), /^ M long\.txt$/m);
});
it("takes one block of a hunk that holds two, with whole files beside it", async () => {
edit([
["3", "three"],
["5", "five"],
]);
write("a.txt", "a2\n");
const [only] = headers("long.txt");
await runGitAction(REPO, "commit", {
message: "block",
context: 3,
paths: ["a.txt"],
hunks: { "long.txt": [{ hunk: only, blocks: [1] }] },
});
const committed = g("show", "HEAD:long.txt");
assert.match(committed, /^3$/m);
assert.match(committed, /^five$/m);
assert.equal(g("show", "HEAD:a.txt"), "a2\n");
assert.match(status(), /^ M long\.txt$/m);
assert.doesNotMatch(status(), /a\.txt/);
});
it("refuses a hunk no longer in the diff, an untracked file, and leaves the index alone", async () => {
edit([["3", "three"]]);
write("new.txt", "n\n");
g("add", "b.txt");
await assert.rejects(
runGitAction(REPO, "commit", {
message: "m",
hunks: { "long.txt": [{ hunk: "@@ -1,1 +1,1 @@", blocks: [0] }] },
}),
{ code: "EHUNKGONE" }
);
await assert.rejects(
runGitAction(REPO, "commit", {
message: "m",
hunks: { "new.txt": [{ hunk: "@@ -0,0 +1 @@", blocks: [0] }] },
}),
{ code: "EUNTRACKED" }
);
await assert.rejects(
runGitAction(REPO, "commit", {
message: "m",
hunks: { "long.txt": [{ hunk: headers("long.txt")[0], blocks: ["x"] }] },
}),
{ code: "EBADPATH" }
);
assert.equal(g("rev-list", "--count", "HEAD").trim(), "2");
assert.match(status(), /^ M long\.txt$/m);
});
it("amends with only the ticked hunk", async () => {
edit([
["3", "three"],
["25", "twentyfive"],
]);
const head = g("rev-parse", "HEAD").trim();
const [, second] = headers("long.txt");
await runGitAction(REPO, "amend", {
sha: head,
context: 3,
paths: [],
hunks: { "long.txt": [{ hunk: second, blocks: [0] }] },
});
assert.equal(g("log", "-1", "--format=%s").trim(), "long");
assert.equal(g("rev-list", "--count", "HEAD").trim(), "2");
const committed = g("show", "HEAD:long.txt");
assert.match(committed, /^twentyfive$/m);
assert.match(committed, /^3$/m);
});
});
describe("blameFile", () => {
it("names each line's commit, and none for a line not committed yet", async () => {
write("a.txt", "a\nnew line\n");
const blame = await blameFile(REPO, "a.txt");
assert.equal(blame.lines.length, 2);
const first = blame.commits[blame.lines[0]];
assert.equal(first.summary, "init");
assert.equal(first.author, "t");
assert.equal(blame.lines[1], null);
});
it("refuses a path outside the lane and a secret file", async () => {
await assert.rejects(blameFile(REPO, "../x"), { code: "EBADPATH" });
fs.mkdirSync(path.join(REPO, ".claude"));
write(".claude/settings.local.json", "{}\n");
g("add", "-f", ".claude/settings.local.json");
g("commit", "-qm", "secret");
await assert.rejects(blameFile(REPO, ".claude/settings.local.json"), { code: "ESECRET" });
});
});
describe("a file's history", () => {
it("rewrites parents to the file's own commits, so the graph joins up", async () => {
write("a.txt", "a2\n");
g("commit", "-qam", "a2");
write("b.txt", "b2\n");
g("commit", "-qam", "b only");
write("a.txt", "a3\n");
g("commit", "-qam", "a3");
const commits = await listCommits(REPO, { file: "a.txt" });
assert.deepEqual(
commits.map((c) => c.subject),
["a3", "a2", "init"]
);
assert.deepEqual(commits[0].parents, [commits[1].sha]);
});
});
+9
View File
@@ -141,3 +141,12 @@ describe("install-hooks host-only guard (#193)", () => {
assert.equal(isInsideContainer(), false);
});
});
describe("HOOK_TYPES", () => {
it("is the event list the plugin ships, so the hook status checks every one", () => {
const exported = require("../../scripts/install-hooks").HOOK_TYPES;
const plugin = require("../../.claude-plugin/plugin.json");
assert.deepEqual([...exported].sort(), Object.keys(plugin.hooks).sort());
assert.deepEqual([...exported].sort(), [...HOOK_TYPES].sort());
});
});
+10
View File
@@ -146,6 +146,16 @@ describe("importing a .codex-pet bundle", () => {
assert.equal(r.status, 201);
});
it("accepts a v2 sheet (1536×2288, the 16 gaze poses in rows 9–10)", async () => {
const r = await request("POST", "/api/pets/import", {
body: bundle({
"pet.json": { spriteVersionNumber: 2 },
"spritesheet.webp": webp(1536, 2288),
}),
});
assert.equal(r.status, 201);
});
it("refuses a bundle missing a file, a sheet of the wrong size, and a non-zip", async () => {
const missing = await request("POST", "/api/pets/import", { body: bundle({ "pet.json": {} }) });
assert.equal(missing.status, 400);
+31
View File
@@ -205,6 +205,37 @@ describe("fast path", () => {
});
});
describe("fast path after a Quit (issue #65)", () => {
beforeEach(resetRuntime);
it("starts nothing while the stopped flag is set, and starts again once it is gone", () => {
const pkg = require("../../package.json");
const { markQuit, clearQuit } = require("../lib/quit");
const prevHome = process.env.HOME;
process.env.HOME = TMP_HOME; // the real unit may name another root: stale
try {
fs.mkdirSync(path.join(RT, "node_modules"), { recursive: true });
boot.writeState(
{
pluginVersion: pkg.version,
depsHash: boot.depsHash(),
pluginRoot: path.resolve(__dirname, "..", ".."),
},
RT
);
// No server is live: without the flag the worker would start one.
assert.equal(boot.fastPathOk(RT), false);
markQuit(RT);
assert.equal(boot.fastPathOk(RT), true);
clearQuit(RT);
assert.equal(boot.fastPathOk(RT), false);
} finally {
if (prevHome === undefined) delete process.env.HOME;
else process.env.HOME = prevHome;
}
});
});
describe("bootstrap lock", () => {
beforeEach(resetRuntime);
+71 -1
View File
@@ -1,7 +1,8 @@
/**
* @file Unit tests for the enhanced pricing calculator and the shared token-usage
* normalizer: 5m/1h cache-write split, server-tool surcharges, and the per-bucket
* pricing modifiers (fast mode, US data residency, Batch API).
* pricing modifiers (fast mode, US data residency, Batch API), and the
* long-context subsets priced at a rule's long_* rates.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -363,3 +364,72 @@ describe("DEFAULT_PRICING — Opus 5.5", () => {
assert.equal(r.total_cost.toFixed(2), "2.72");
});
});
describe("long-context tier (prompt > 100k tokens)", () => {
// Haiku 5.5: every rate x5 above a 100k-token prompt.
const HAIKU = [
{
model_pattern: "claude-haiku-5-5%",
display_name: "Claude Haiku 5.5",
input_per_mtok: 0.1,
output_per_mtok: 0.5,
cache_read_per_mtok: 0.01,
cache_write_per_mtok: 0.125,
cache_write_1h_per_mtok: 0.2,
long_input_per_mtok: 0.5,
long_output_per_mtok: 2.5,
},
];
const haiku = (extra) => bucket({ model: "claude-haiku-5-5", ...extra });
it("counts a request into the long subsets by its whole prompt: input + cache read + cache write", () => {
const short = extractUsageFields({ input_tokens: 100_000, output_tokens: 5 });
assert.equal(short.longInput, 0);
const long = extractUsageFields({
input_tokens: 10,
output_tokens: 5,
cache_read_input_tokens: 90_000,
cache_creation_input_tokens: 10_000,
cache_creation: { ephemeral_5m_input_tokens: 4_000, ephemeral_1h_input_tokens: 6_000 },
});
assert.deepEqual(
[
long.longInput,
long.longOutput,
long.longCacheRead,
long.longCacheWrite,
long.longCacheWrite1h,
],
[10, 5, 90_000, 10_000, 6_000]
);
});
it("prices the long subset at the long rates, cache rates scaled by the input ratio", () => {
const all = {
input_tokens: M,
output_tokens: M,
cache_read_tokens: M,
cache_write_tokens: 2 * M,
cache_write_1h_tokens: M,
};
const long = Object.fromEntries(Object.entries(all).map(([k, v]) => [`long_${k}`, v]));
// 0.5 + 2.5 + 0.05 + 0.625 + 1.0
assert.equal(calculateCost([haiku({ ...all, ...long })], HAIKU).total_cost, 4.675);
});
it("prices only the long part of a bucket at the long rates", () => {
const r = calculateCost([haiku({ input_tokens: 2 * M, long_input_tokens: M })], HAIKU);
assert.equal(r.total_cost, 0.6);
assert.equal(r.breakdown[0].long_input_tokens, M);
});
it("never prices more long tokens than the bucket holds", () => {
const r = calculateCost([haiku({ input_tokens: M, long_input_tokens: 3 * M })], HAIKU);
assert.equal(r.total_cost, 0.5);
});
it("prices a long subset at the standard rates when the rule has no long rates", () => {
const r = calculateCost([bucket({ input_tokens: M, long_input_tokens: M })], RULES);
assert.equal(r.total_cost, 5);
});
});
+33 -1
View File
@@ -4,7 +4,8 @@
* before it is used, the sync's own rows follow a rate change while a user's
* row and a deleted pattern are never touched, a bad list or a failed fetch
* changes nothing, and an OpenAI model (a Codex session's) is priced from its
* `openai` entry too.
* `openai` entry too, and a Claude entry's above-100k rates fill the long_*
* columns.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -428,3 +429,34 @@ describe("pricing sync", () => {
}
});
});
describe("long-context rates", () => {
it("takes Anthropic's above-100k input/output rates into the long columns", () => {
const e = {
...LIST["claude-opus-9-9"],
input_cost_per_token_above_100k_tokens: 0.00002,
output_cost_per_token_above_100k_tokens: 0.0001,
};
const r = toRow("claude-opus-9-9", e);
assert.equal(r.longIn, 20);
assert.equal(r.longOut, 100);
assert.equal(toRow("claude-opus-9-9", LIST["claude-opus-9-9"]).longIn, 0);
});
it("writes them, and fills them into a sync row that predates them", async () => {
const e = {
...LIST["claude-opus-9-9"],
input_cost_per_token_above_100k_tokens: 0.00002,
output_cost_per_token_above_100k_tokens: 0.0001,
};
useModel("claude-opus-9-9");
await syncPricing(db, {
fetchList: async () => ({ "claude-opus-9-9": LIST["claude-opus-9-9"] }),
});
assert.equal(row("claude-opus-9-9%").long_input_per_mtok, 0);
_resetCache();
await syncPricing(db, { fetchList: async () => ({ "claude-opus-9-9": e }) });
assert.equal(row("claude-opus-9-9%").long_input_per_mtok, 20);
assert.equal(row("claude-opus-9-9%").long_output_per_mtok, 100);
});
});
+3 -7
View File
@@ -651,7 +651,7 @@ describe("pty-run", () => {
let savedPath;
before(() => {
fs.mkdirSync(BIN, { recursive: true });
for (const name of ["agy", "gemini", "grok", "goose", "command-code"]) {
for (const name of ["agy", "grok", "goose", "command-code"]) {
fs.writeFileSync(path.join(BIN, name), "#!/bin/sh\n");
fs.chmodSync(path.join(BIN, name), 0o755);
}
@@ -734,15 +734,11 @@ describe("pty-run", () => {
pty.spawnRun({ laneId: 15, cwd: "/tmp/repo", tool, initialPrompt: "-x y", ...extra })
)
);
assert.deepEqual(argvFor("gemini"), [at("gemini"), "--prompt-interactive", " -x y"]);
assert.deepEqual(argvFor("antigravity"), [at("agy"), "-i", " -x y"]);
// Behind a `--` separator the prompt is already a positional, kept as typed.
assert.deepEqual(argvFor("grok"), [at("grok"), "--", "-x y"]);
assert.deepEqual(argvFor("command-code"), [at("command-code"), "--trust", " -x y"]);
assert.deepEqual(argvFor("gemini", { bypass: false }), [
at("gemini"),
"--prompt-interactive",
" -x y",
]);
assert.deepEqual(argvFor("antigravity", { bypass: false }), [at("agy"), "-i", " -x y"]);
});
it("pastes an after-start prompt later and adds bypassEnv only on bypass", (t) => {
+131
View File
@@ -0,0 +1,131 @@
/**
* @file Tests Quit AgentHub (issue #65, server/lib/quit.js and
* POST /api/settings/quit): the stopped flag, stopping through the service
* manager when a unit of ours runs the server (a SIGTERM would be restarted),
* and the route killing every lane pane before it stops. tmux runs only in the
* isolated server `npm run test:server` sets up.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const http = require("node:http");
const { execFileSync } = require("node:child_process");
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-quit-"));
process.env.DASHBOARD_DATA_DIR = TMP;
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
process.env.DASHBOARD_LIVENESS_PROBE = "0";
const quit = require("../lib/quit");
after(() => fs.rmSync(TMP, { recursive: true, force: true }));
describe("stopped flag", () => {
it("is set by markQuit and cleared by clearQuit, in the data dir's runtime", () => {
assert.equal(quit.flagPath(), path.join(TMP, "runtime", "stopped"));
assert.equal(quit.isQuit(), false);
quit.markQuit();
assert.equal(quit.isQuit(), true);
quit.clearQuit();
assert.equal(quit.isQuit(), false);
quit.clearQuit(); // already gone: fine
});
});
describe("stopServer", () => {
const fakeSvc = (kind, active) => ({
kind,
systemctlAvailable: () => true,
unitIsOurs: () => true,
unitIsActive: () => active,
});
const spawned = [];
const spawn = (cmd, args) => {
spawned.push([cmd, ...args].join(" "));
return { unref() {} };
};
it("SIGTERMs itself when no unit of ours is running", () => {
let killed = 0;
quit.stopServer({ svc: fakeSvc("systemd", false), spawn, kill: () => killed++ });
assert.equal(killed, 1);
assert.deepEqual(spawned, []);
});
it("stops a running unit through systemd, never by a signal Restart=always undoes", () => {
let killed = 0;
quit.stopServer({ svc: fakeSvc("systemd", true), spawn, kill: () => killed++ });
assert.equal(killed, 0);
assert.deepEqual(spawned.splice(0), ["systemctl --user stop --no-block agenthub.service"]);
});
it("boots a running LaunchAgent out", { skip: process.platform === "win32" }, () => {
quit.stopServer({ svc: fakeSvc("launchd", true), spawn, kill: () => {} });
assert.match(spawned.splice(0)[0], /^launchctl bootout gui\/\d+\/io\.smartgift\.agenthub$/);
});
});
// Only inside the runner's private tmux server: never the developer's own.
const isolatedTmux = !!process.env.TMUX_TMPDIR && !process.env.TMUX;
let hasTmux = false;
try {
execFileSync("tmux", ["-V"], { stdio: "ignore" });
hasTmux = true;
} catch {
/* not installed */
}
describe(
"POST /api/settings/quit",
{ skip: !(isolatedTmux && hasTmux) && "needs the runner's isolated tmux" },
() => {
let server;
let base;
let stops = 0;
const realStop = quit.stopServer;
before(async () => {
quit.stopServer = () => stops++; // this process is the test runner
const { createApp, startServer } = require("../index");
server = await startServer(createApp(), 0);
base = `http://127.0.0.1:${server.address().port}`;
});
after(() => {
quit.stopServer = realStop;
server?.close();
require("../db").db.close();
});
it("closes every lane pane, sets the flag, then stops the server", async () => {
execFileSync("tmux", ["new-session", "-d", "-s", "agenthub-lane-9001", "sleep 60"]);
execFileSync("tmux", ["new-session", "-d", "-s", "agenthub-lane-9001-2", "sleep 60"]);
execFileSync("tmux", ["new-session", "-d", "-s", "not-ours", "sleep 60"]);
try {
const body = await new Promise((resolve, reject) => {
const req = http.request(`${base}/api/settings/quit`, { method: "POST" }, (res) => {
let text = "";
res.on("data", (c) => (text += c));
res.on("end", () => resolve(JSON.parse(text)));
});
req.on("error", reject);
req.end();
});
assert.deepEqual(body, { ok: true, killed: 2 });
assert.equal(quit.isQuit(), true);
const left = execFileSync("tmux", ["list-sessions", "-F", "#{session_name}"], {
encoding: "utf8",
});
assert.deepEqual(left.trim().split("\n"), ["not-ours"]);
await new Promise((r) => setImmediate(r));
assert.equal(stops, 1);
} finally {
execFileSync("tmux", ["kill-session", "-t", "not-ours"]);
}
});
}
);
+375 -3
View File
@@ -4,7 +4,11 @@
* reset since Codex last wrote reading as empty, and the Codex login's
* account + expiry read from `auth.json`; and a saved Claude profile asked
* with its own token only while it is valid, never refreshed, and never shown
* with the usage of a token that belongs to a different account.
* with the usage of a token that belongs to a different account; each
* tool's plan name; the Cursor CLI's login asked of cursor.com only while
* valid; Antigravity's `agy --print /usage` pools, never on an old agy; and
* the Grok, Kimi Code and OpenCode Go logins, each sent only to its own host
* while valid, never refreshed.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -12,6 +16,19 @@ const { test } = require("node:test");
const assert = require("node:assert");
const { codexWindow, windowLabel } = require("../lib/quota");
// No test may read (or send) the machine's real Cursor login.
process.env.XDG_CONFIG_HOME = require("node:fs").mkdtempSync(
require("node:path").join(require("node:os").tmpdir(), "quota-xdg-")
);
// Nor run the machine's real agy: only the Antigravity test's fake one runs.
process.env.AGENTHUB_AGY_USAGE = "0";
// Nor its real Grok, Kimi or OpenCode logins.
for (const k of ["GROK_HOME", "KIMI_CODE_HOME", "XDG_DATA_HOME"]) {
process.env[k] = require("node:fs").mkdtempSync(
require("node:path").join(require("node:os").tmpdir(), "quota-home-")
);
}
test("windowLabel names the plan windows", () => {
assert.strictEqual(windowLabel(300), "5h");
assert.strictEqual(windowLabel(10080), "wk");
@@ -47,11 +64,18 @@ test("codexAccount reads the email and access-token expiry, never a token", () =
fs.writeFileSync(
path.join(home, "auth.json"),
JSON.stringify({
tokens: { id_token: jwt({ email: "a@b.c" }), access_token: jwt({ exp: 1_800_000_000 }) },
tokens: {
id_token: jwt({
email: "a@b.c",
"https://api.openai.com/auth": { chatgpt_plan_type: "plus" },
}),
access_token: jwt({ exp: 1_800_000_000 }),
},
})
);
assert.deepStrictEqual(codexAccount(), {
account: "a@b.c",
plan: "plus",
expiresAt: new Date(1_800_000_000_000).toISOString(),
});
} finally {
@@ -177,7 +201,13 @@ test("the live row is the account its token belongs to, not the one .claude.json
// Thắng's identity, Victor's token.
fs.writeFileSync(
path.join(home, ".credentials.json"),
JSON.stringify({ claudeAiOauth: { accessToken: "live-v", expiresAt: Date.now() + 3600_000 } })
JSON.stringify({
claudeAiOauth: {
accessToken: "live-v",
expiresAt: Date.now() + 3600_000,
subscriptionType: "max",
},
})
);
fs.writeFileSync(
`${home}.json`,
@@ -200,6 +230,7 @@ test("the live row is the account its token belongs to, not the one .claude.json
assert.strictEqual(live.label, "Victor");
assert.strictEqual(live.account, "victor@x.y");
assert.strictEqual(live.ownerUuid, undefined);
assert.strictEqual(live.plan, "max");
assert.deepStrictEqual(
claude.filter((t) => !t.active).map((t) => t.label),
["Thắng"]
@@ -212,3 +243,344 @@ test("the live row is the account its token belongs to, not the one .claude.json
fs.rmSync(root, { recursive: true, force: true });
}
});
test("Cursor: the CLI's own login asks cursor.com for its plan + cycle usage, never past expiry", async () => {
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { quotas, __resetCache } = require("../lib/quota");
const jwt = (claims) => `h.${Buffer.from(JSON.stringify(claims)).toString("base64url")}.s`;
const root = fs.mkdtempSync(path.join(os.tmpdir(), "quota-cursor-"));
const auth = path.join(root, "config", "cursor", "auth.json");
fs.mkdirSync(path.dirname(auth), { recursive: true });
fs.mkdirSync(path.join(root, "cursor"));
fs.writeFileSync(
path.join(root, "cursor", "cli-config.json"),
JSON.stringify({ authInfo: { email: "c@d.e", authId: "auth0|user_1" } })
);
const prev = {
xdg: process.env.XDG_CONFIG_HOME,
dir: process.env.AGENTHUB_CURSOR_DIR,
fetch: global.fetch,
};
process.env.XDG_CONFIG_HOME = path.join(root, "config");
process.env.AGENTHUB_CURSOR_DIR = path.join(root, "cursor");
const asked = [];
global.fetch = async (url, opts) => {
if (!url.includes("cursor.com")) return { ok: false, status: 401, json: async () => ({}) };
asked.push(`${url} ${opts.headers.Cookie}`);
return {
ok: true,
status: 200,
json: async () => ({
billingCycleEnd: "2099-01-01T00:00:00.000Z",
membershipType: "pro",
individualUsage: { plan: { totalPercentUsed: 9.5 } },
}),
};
};
const cursor = async () => (await quotas()).tools.find((t) => t.id === "cursor");
try {
__resetCache();
assert.strictEqual(await cursor(), undefined); // not logged in: no row
const token = jwt({ sub: "auth0|user_1", exp: Math.floor(Date.now() / 1000) + 3600 });
fs.writeFileSync(auth, JSON.stringify({ accessToken: token, refreshToken: "r" }));
__resetCache();
assert.deepStrictEqual(await cursor(), {
id: "cursor",
active: true,
account: "c@d.e",
plan: "pro",
expiresAt: new Date(
JSON.parse(Buffer.from(token.split(".")[1], "base64url")).exp * 1000
).toISOString(),
windows: [{ label: "mo", usedPercent: 9.5, resetsAt: "2099-01-01T00:00:00.000Z" }],
});
assert.deepStrictEqual(asked, [
`https://cursor.com/api/usage-summary WorkosCursorSessionToken=user_1::${token}`,
]);
fs.writeFileSync(auth, JSON.stringify({ accessToken: jwt({ sub: "auth0|user_1", exp: 1 }) }));
__resetCache();
assert.strictEqual((await cursor()).error, "ETOKENEXPIRED");
assert.strictEqual(asked.length, 1);
} finally {
for (const [k, v] of [
["XDG_CONFIG_HOME", prev.xdg],
["AGENTHUB_CURSOR_DIR", prev.dir],
]) {
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
global.fetch = prev.fetch;
__resetCache();
fs.rmSync(root, { recursive: true, force: true });
}
});
test("Antigravity: `agy --print /usage` in an empty dir, one row per pool window, never on an old agy", async () => {
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const aiTools = require("../lib/ai-tools");
const { quotas, __resetCache } = require("../lib/quota");
const bin = fs.mkdtempSync(path.join(os.tmpdir(), "quota-agy-"));
const log = path.join(bin, "calls");
const version = path.join(bin, "version");
fs.writeFileSync(version, "1.2.17");
const bucket = (id, window, rf) => ({
id,
window,
remaining_fraction: rf,
reset_time: "2099-01-01T00:00:00Z",
});
fs.writeFileSync(
path.join(bin, "out.json"),
JSON.stringify({
status: "SUCCESS",
num_turns: 0,
command: {
name: "usage",
data: {
groups: [
{
name: "Gemini Models",
buckets: [bucket("g-w", "weekly", 0.75), bucket("g-5", "5h", 1)],
},
{ name: "Claude and GPT models", buckets: [bucket("c-5", "5h", 0.5)] },
],
},
},
})
);
fs.writeFileSync(
path.join(bin, "agy"),
`#!/bin/sh\necho "$PWD $*" >> '${log}'\n[ "$1" = --version ] && { cat '${version}'; exit 0; }\ncat '${path.join(bin, "out.json")}'\n`,
{ mode: 0o755 }
);
const prevPath = process.env.PATH;
process.env.PATH = `${bin}:${prevPath}`;
delete process.env.AGENTHUB_AGY_USAGE;
const agy = async () => (await quotas()).tools.find((t) => t.id === "antigravity");
try {
aiTools.refresh();
__resetCache();
const row = await agy();
assert.strictEqual(row.active, true);
assert.deepStrictEqual(row.windows, [
{ group: "Gemini Models", label: "5h", usedPercent: 0, resetsAt: "2099-01-01T00:00:00.000Z" },
{
group: "Gemini Models",
label: "wk",
usedPercent: 25,
resetsAt: "2099-01-01T00:00:00.000Z",
},
{
group: "Claude and GPT models",
label: "5h",
usedPercent: 50,
resetsAt: "2099-01-01T00:00:00.000Z",
},
]);
const calls = fs.readFileSync(log, "utf8").trim().split("\n");
assert.strictEqual(calls[0].split(" ").slice(1).join(" "), "--version");
const [cwd, ...args] = calls[1].split(" ");
assert.deepStrictEqual(args, ["--print", "/usage", "--output-format", "json"]);
assert.notStrictEqual(cwd, process.cwd());
assert.strictEqual(fs.existsSync(cwd), false); // the private dir is gone again
// An agy older than 1.1.11 may bill /usage as a model request: never asked.
fs.writeFileSync(version, "1.1.10");
fs.writeFileSync(log, "");
__resetCache();
assert.strictEqual(await agy(), undefined);
assert.match(fs.readFileSync(log, "utf8"), /^\S+ --version\n$/);
} finally {
process.env.PATH = prevPath;
process.env.AGENTHUB_AGY_USAGE = "0";
aiTools.refresh();
__resetCache();
fs.rmSync(bin, { recursive: true, force: true });
}
});
/** A fetch stub answering per host, recording every `url auth` it was asked. */
function stubFetch(answers) {
const asked = [];
const prev = global.fetch;
global.fetch = async (url, opts = {}) => {
asked.push(`${url} ${opts.headers?.Authorization || ""}`.trim());
const hit = Object.entries(answers).find(([k]) => url.startsWith(k));
if (!hit) return { ok: false, status: 404, json: async () => ({}) };
const [status, body] = hit[1];
return { ok: status < 300, status, json: async () => body };
};
return { asked, restore: () => (global.fetch = prev) };
}
test("Grok: the CLI's own auth.x.ai session asks cli-chat-proxy for credits + plan, never past expiry", async () => {
const fs = require("node:fs");
const path = require("node:path");
const { quotas, __resetCache } = require("../lib/quota");
const auth = path.join(process.env.GROK_HOME, "auth.json");
const f = stubFetch({
"https://cli-chat-proxy.grok.com/v1/billing?format=credits": [
200,
{
config: {
creditUsagePercent: 12,
currentPeriod: { end: "2099-01-08T00:00:00Z" },
subscriptionTier: "SuperGrok",
monthlyLimit: { val: "200" },
used: { val: "50" },
billingPeriodEnd: "2099-02-01T00:00:00Z",
},
},
],
});
const grok = async () => (await quotas()).tools.find((t) => t.id === "grok");
try {
__resetCache();
assert.strictEqual(await grok(), undefined);
const later = new Date(Date.now() + 3600_000).toISOString();
fs.writeFileSync(
auth,
JSON.stringify({
"https://other.example": { key: "tok-other", expires_at: later },
"https://auth.x.ai": { key: "tok-x", user_id: "u1", email: "g@x.ai", expires_at: later },
})
);
__resetCache();
assert.deepStrictEqual(await grok(), {
id: "grok",
active: true,
account: "g@x.ai",
plan: "SuperGrok",
expiresAt: later,
windows: [
{ label: "wk", usedPercent: 12, resetsAt: "2099-01-08T00:00:00.000Z" },
{ label: "mo", usedPercent: 25, resetsAt: "2099-02-01T00:00:00.000Z" },
],
});
assert.deepStrictEqual(
f.asked.filter((a) => a.includes("grok.com")),
["https://cli-chat-proxy.grok.com/v1/billing?format=credits Bearer tok-x"]
);
fs.writeFileSync(
auth,
JSON.stringify({ "https://auth.x.ai": { key: "tok-x", expires_at: "2000-01-01T00:00:00Z" } })
);
__resetCache();
assert.strictEqual((await grok()).error, "ETOKENEXPIRED");
assert.strictEqual(f.asked.filter((a) => a.includes("grok.com")).length, 1);
} finally {
f.restore();
fs.rmSync(auth, { force: true });
__resetCache();
}
});
test("Kimi Code: the CLI's own token asks api.kimi.com/coding for 5h, week and month", async () => {
const fs = require("node:fs");
const path = require("node:path");
const { quotas, __resetCache } = require("../lib/quota");
const dir = path.join(process.env.KIMI_CODE_HOME, "credentials");
fs.mkdirSync(dir, { recursive: true });
const file = path.join(dir, "kimi-code.json");
const f = stubFetch({
"https://api.kimi.com/coding/v1/usages": [
200,
{
usages: {
limit_5h: { used_ratio: 0.3, reset_time: "2099-01-01T05:00:00Z" },
limit_7d: { used_ratio: "0.2", reset_time: "2099-01-07T00:00:00Z" },
limit_month_total: { used_ratio: 0.4, reset_time: "2099-02-01T00:00:00Z" },
limit_month_code: { used_ratio: 0.25, reset_time: "2099-02-01T00:00:00Z" },
},
},
],
});
const kimi = async () => (await quotas()).tools.find((t) => t.id === "kimi");
try {
__resetCache();
assert.strictEqual(await kimi(), undefined);
const exp = Math.floor(Date.now() / 1000) + 600;
fs.writeFileSync(
file,
JSON.stringify({ access_token: "tok-k", refresh_token: "r", expires_at: exp })
);
__resetCache();
const row = await kimi();
assert.strictEqual(row.expiresAt, new Date(exp * 1000).toISOString());
assert.deepStrictEqual(
row.windows.map((w) => [w.label, Math.round(w.usedPercent)]),
[
["5h", 30],
["wk", 20],
["mo", 40],
]
);
assert.deepStrictEqual(
f.asked.filter((a) => a.includes("kimi")),
["https://api.kimi.com/coding/v1/usages Bearer tok-k"]
);
fs.writeFileSync(file, JSON.stringify({ access_token: "tok-k", expires_at: 1 }));
__resetCache();
assert.strictEqual((await kimi()).error, "ETOKENEXPIRED");
assert.strictEqual(f.asked.filter((a) => a.includes("kimi")).length, 1);
} finally {
f.restore();
fs.rmSync(file, { force: true });
__resetCache();
}
});
test("OpenCode Go: opencode's own `opencode-go` key asks opencode.ai for its meters", async () => {
const fs = require("node:fs");
const path = require("node:path");
const { quotas, __resetCache } = require("../lib/quota");
const dir = path.join(process.env.XDG_DATA_HOME, "opencode");
fs.mkdirSync(dir, { recursive: true });
const file = path.join(dir, "auth.json");
const f = stubFetch({
"https://opencode.ai/zen/go/v1/usage": [
200,
{
usage: {
rolling: { percent: 10, resetsAt: "2099-01-01T05:00:00Z" },
weekly: { percent: 55.5, resetsAt: "2099-01-07T00:00:00Z" },
},
},
],
});
const go = async () => (await quotas()).tools.find((t) => t.id === "opencode");
try {
__resetCache();
// A login with no Go key (Copilot through opencode, say) is no row.
fs.writeFileSync(file, JSON.stringify({ "github-copilot": { type: "oauth", access: "a" } }));
assert.strictEqual(await go(), undefined);
fs.writeFileSync(
file,
JSON.stringify({
"opencode-go": { type: "api", key: "sk-go" },
other: { type: "api", key: "x" },
})
);
__resetCache();
assert.deepStrictEqual(await go(), {
id: "opencode",
active: true,
plan: "Go",
windows: [
{ label: "5h", usedPercent: 10, resetsAt: "2099-01-01T05:00:00.000Z" },
{ label: "wk", usedPercent: 55.5, resetsAt: "2099-01-07T00:00:00.000Z" },
],
});
assert.deepStrictEqual(
f.asked.filter((a) => a.includes("opencode")),
["https://opencode.ai/zen/go/v1/usage Bearer sk-go"]
);
} finally {
f.restore();
fs.rmSync(file, { force: true });
__resetCache();
}
});
@@ -0,0 +1,260 @@
/**
* @file server-mode-user-admin.test.js
* @description Managing accounts from the dashboard (Users page): only an
* admin may create a user, change a role, disable/enable, reset a password or
* give a workspace, every change needs the admin's OWN password again
* (EREAUTH, throttled), an admin can never demote or disable themselves, no
* answer carries a password hash, each change lands in the audit log, and in
* local mode — no login at all — the routes refuse.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const path = require("node:path");
const fs = require("node:fs");
const os = require("node:os");
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "server-mode-user-admin-"));
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
process.env.DASHBOARD_LIVENESS_PROBE = "0";
const { createApp, startServer } = require("../index");
const { db } = require("../db");
const auth = require("../lib/auth");
let server;
let BASE;
const ids = {};
const BOSS_PASS = "admin-pass-1";
async function call(method, p, { cookie, body, origin } = {}) {
const res = await fetch(BASE + p, {
method,
headers: {
"Content-Type": "application/json",
...(cookie ? { cookie } : {}),
...(origin ? { origin } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
return { status: res.status, body: await res.json().catch(() => null) };
}
async function login(username, password) {
const res = await fetch(BASE + "/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
return res.status === 200 ? res.headers.get("set-cookie").split(";")[0] : null;
}
before(async () => {
process.env.AGENTHUB_MODE = "server";
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
ids.boss = (await auth.createUser("boss", BOSS_PASS, "admin")).id;
ids.ana = (await auth.createUser("ana", "analyst-pass-1", "analyst")).id;
ids.mia = (await auth.createUser("mia", "member-pass-1", "member")).id;
ids.lane = db
.prepare("INSERT INTO lanes (cwd, tool) VALUES ('/w/ownerless', 'claude')")
.run().lastInsertRowid;
});
after(() => {
delete process.env.AGENTHUB_MODE;
server?.close();
fs.rmSync(TMP, { recursive: true, force: true });
});
describe("managing accounts from the dashboard", () => {
it("lets an admin create a user, with their own password again", async () => {
const boss = await login("boss", BOSS_PASS);
const r = await call("POST", "/api/users", {
cookie: boss,
body: {
username: "neo",
password: "neo-pass-123",
role: "member",
currentPassword: BOSS_PASS,
},
});
assert.equal(r.status, 201);
assert.deepEqual(
{ username: r.body.user.username, role: r.body.user.role },
{ username: "neo", role: "member" }
);
assert.ok(!JSON.stringify(r.body).includes("scrypt"), "no password hash");
assert.ok(await login("neo", "neo-pass-123"), "the new user can log in");
const dup = await call("POST", "/api/users", {
cookie: boss,
body: { username: "neo", password: "neo-pass-123", currentPassword: BOSS_PASS },
});
assert.equal(dup.status, 409);
assert.equal(dup.body.error.code, "EEXISTS");
const short = await call("POST", "/api/users", {
cookie: boss,
body: { username: "zed", password: "short", currentPassword: BOSS_PASS },
});
assert.equal(short.status, 400);
assert.equal(short.body.error.code, "EBADPASSWORD");
});
it("refuses without the admin's password, or with a wrong one", async () => {
const boss = await login("boss", BOSS_PASS);
for (const currentPassword of [undefined, "not-the-password"]) {
const r = await call("POST", "/api/users", {
cookie: boss,
body: { username: "eve", password: "eve-pass-123", currentPassword },
});
assert.equal(r.status, 403);
assert.equal(r.body.error.code, "EREAUTH");
}
assert.equal(auth.findUser("eve"), undefined);
});
it("is an admin's only: an analyst is read-only, a member never reaches it", async () => {
const body = { username: "eve", password: "eve-pass-123", currentPassword: "analyst-pass-1" };
const ana = await call("POST", "/api/users", {
cookie: await login("ana", "analyst-pass-1"),
body,
});
assert.equal(ana.status, 403);
const mia = await call("POST", "/api/users", {
cookie: await login("mia", "member-pass-1"),
body: { ...body, currentPassword: "member-pass-1" },
});
assert.equal(mia.status, 403);
assert.equal(auth.findUser("eve"), undefined);
});
it("refuses a write from another origin", async () => {
const r = await call("POST", "/api/users", {
cookie: await login("boss", BOSS_PASS),
origin: "http://evil.example",
body: { username: "eve", password: "eve-pass-123", currentPassword: BOSS_PASS },
});
assert.equal(r.status, 403);
assert.equal(auth.findUser("eve"), undefined);
});
it("changes a role, disables and enables, and resets a password", async () => {
const boss = await login("boss", BOSS_PASS);
const neo = auth.findUser("neo");
const role = await call("PATCH", `/api/users/${neo.id}`, {
cookie: boss,
body: { role: "analyst", currentPassword: BOSS_PASS },
});
assert.equal(role.status, 200);
assert.equal(auth.findUser("neo").role, "analyst");
const neoLogin = await login("neo", "neo-pass-123");
const off = await call("PATCH", `/api/users/${neo.id}`, {
cookie: boss,
body: { disabled: true, currentPassword: BOSS_PASS },
});
assert.equal(off.status, 200);
assert.equal(off.body.user.disabled, true);
assert.equal((await call("GET", "/api/sessions", { cookie: neoLogin })).status, 401);
assert.equal(await login("neo", "neo-pass-123"), null, "a disabled user cannot log in");
await call("PATCH", `/api/users/${neo.id}`, {
cookie: boss,
body: { disabled: false, currentPassword: BOSS_PASS },
});
const pw = await call("POST", `/api/users/${neo.id}/password`, {
cookie: boss,
body: { password: "neo-new-pass-9", currentPassword: BOSS_PASS },
});
assert.equal(pw.status, 200);
assert.equal(await login("neo", "neo-pass-123"), null);
assert.ok(await login("neo", "neo-new-pass-9"));
const bad = await call("PATCH", `/api/users/${neo.id}`, {
cookie: boss,
body: { role: "root", currentPassword: BOSS_PASS },
});
assert.equal(bad.status, 400);
const none = await call("PATCH", "/api/users/9999", {
cookie: boss,
body: { role: "member", currentPassword: BOSS_PASS },
});
assert.equal(none.status, 404);
});
it("never lets an admin demote or disable themselves", async () => {
const boss = await login("boss", BOSS_PASS);
for (const body of [{ role: "member" }, { disabled: true }]) {
const r = await call("PATCH", `/api/users/${ids.boss}`, {
cookie: boss,
body: { ...body, currentPassword: BOSS_PASS },
});
assert.equal(r.status, 409);
assert.equal(r.body.error.code, "ESELF");
}
assert.equal(auth.findUser("boss").role, "admin");
});
it("gives a workspace to a user", async () => {
const boss = await login("boss", BOSS_PASS);
const r = await call("POST", `/api/users/${ids.mia}/workspaces`, {
cookie: boss,
body: { laneId: ids.lane, currentPassword: BOSS_PASS },
});
assert.equal(r.status, 200);
assert.equal(
db.prepare("SELECT owner_id FROM lanes WHERE id = ?").get(ids.lane).owner_id,
ids.mia
);
const none = await call("POST", `/api/users/${ids.mia}/workspaces`, {
cookie: boss,
body: { laneId: 9999, currentPassword: BOSS_PASS },
});
assert.equal(none.status, 404);
});
it("writes every change to the audit log", async () => {
const boss = await login("boss", BOSS_PASS);
const entries = (await call("GET", "/api/users/audit", { cookie: boss })).body.entries;
const mine = entries.filter((e) => e.reader === "boss" && e.method !== "GET");
const paths = mine.map((e) => e.path).join("\n");
assert.match(paths, /create neo/);
assert.match(paths, /role analyst/);
assert.match(paths, /disable/);
assert.match(paths, /password reset/);
assert.match(paths, new RegExp(`workspace ${ids.lane}`));
assert.ok(!paths.includes("neo-new-pass-9"), "never the password itself");
});
it("throttles wrong confirmation passwords", async () => {
const boss = await login("boss", BOSS_PASS);
let last;
for (let i = 0; i < 12; i++) {
last = await call("PATCH", `/api/users/${ids.mia}`, {
cookie: boss,
body: { role: "analyst", currentPassword: "guess-" + i },
});
}
assert.equal(last.status, 429);
assert.equal(last.body.error.code, "ETOOMANY");
assert.equal(auth.findUser("mia").role, "member");
});
});
describe("in local mode", () => {
it("refuses every account change — there is no login to check", async () => {
delete process.env.AGENTHUB_MODE;
try {
const r = await call("POST", "/api/users", {
body: { username: "eve", password: "eve-pass-123", currentPassword: "x" },
});
assert.equal(r.status, 403);
assert.equal(r.body.error.code, "ELOCALMODE");
assert.equal(auth.findUser("eve"), undefined);
} finally {
process.env.AGENTHUB_MODE = "server";
}
});
});
+9
View File
@@ -76,6 +76,15 @@ describe("systemd-service — unit content", linuxOnly, () => {
assert.doesNotMatch(body, /^Restart=on-failure$/m);
});
it("stops only the server process, so an update never takes the lane panes with it", () => {
// tmux forks its server from whichever process first asks for a session —
// the dashboard, after a boot with autostart on — and that server sits in
// this unit's cgroup. The default KillMode=control-group would kill it,
// and every running agent, on each update's restart.
svc.writeUserService(UNIT);
assert.match(unitFile(), /^KillMode=process$/m);
});
it("carries the ownership marker, so a hand-written unit is never clobbered", () => {
svc.writeUserService(UNIT);
assert.match(unitFile(), /agenthub-managed-unit/);
+47
View File
@@ -57,6 +57,53 @@ describe("tmux wrapper", () => {
assert.equal(tmux.liveSessions(), null);
});
describe(
"the tmux server outlives the dashboard (Linux)",
{ skip: process.platform !== "linux" },
() => {
// Fake tmux: no server until a new-session, then every call succeeds.
const fake =
(calls, { running = false } = {}) =>
(args) => {
calls.push(["tmux", ...args].join(" "));
if (args[0] === "display-message" && !running)
throw Object.assign(new Error("no server"), { status: 1 });
if (args[0] === "has-session" && !running)
throw Object.assign(new Error("no session"), { status: 1 });
return args[0] === "display-message" ? "4242\n" : "";
};
it("starts the FIRST session in its own systemd scope, not the dashboard's cgroup", () => {
const calls = [];
tmux.__setExecImpl(fake(calls));
tmux.__setScopeImpl((args) => calls.push(["systemd-run", "tmux", ...args].join(" ")));
tmux.newSession({ name: "agenthub-lane-1", cwd: "/w", argv: ["claude"], env: {} });
assert.ok(
calls.includes("systemd-run tmux new-session -d -s agenthub-lane-1 -c /w -- claude")
);
assert.ok(!calls.some((c) => c.startsWith("tmux new-session")));
});
it("adds a session to a running server directly", () => {
const calls = [];
tmux.__setExecImpl(fake(calls, { running: true }));
tmux.__setScopeImpl(() => assert.fail("a running server needs no scope"));
tmux.newSession({ name: "agenthub-lane-1", cwd: "/w", argv: ["claude"], env: {} });
assert.ok(calls.includes("tmux new-session -d -s agenthub-lane-1 -c /w -- claude"));
});
it("starts it plainly when no systemd user manager answers", () => {
const calls = [];
tmux.__setExecImpl(fake(calls));
tmux.__setScopeImpl(() => {
throw Object.assign(new Error("Failed to connect to bus"), { status: 1 });
});
tmux.newSession({ name: "agenthub-lane-1", cwd: "/w", argv: ["claude"], env: {} });
assert.ok(calls.includes("tmux new-session -d -s agenthub-lane-1 -c /w -- claude"));
});
}
);
it("newSession names a command that exited at once instead of tmux's set-option error", () => {
tmux.__setExecImpl((args) => {
if (args[0] === "new-session") return "";
+37 -1
View File
@@ -1,5 +1,6 @@
/**
* @file Tests for replaceTokenUsage's compaction baseline semantics.
* @file Tests for replaceTokenUsage's compaction baseline semantics, and for
* the long-context subsets written beside it (high-water mark, exact reset).
*
* The effective total for a bucket is `live + baseline`. It must behave as a
* monotonic HIGH-WATER MARK: never decrease (so a compaction that shrinks the
@@ -121,3 +122,38 @@ describe("replaceTokenUsage — high-water-mark baseline", () => {
assert.equal(rawRow("hw-recover").baseline_cache_read, 0);
});
});
describe("long-context subsets", () => {
const { writeSessionTokens } = require("../../scripts/import-history");
const { emptyBucket, accumulateBucket, extractUsageFields } = require("../lib/token-usage");
const longRow = (sid) =>
db
.prepare("SELECT input_tokens, long_input_tokens FROM token_usage WHERE session_id = ?")
.get(sid);
const bucketOf = (...usages) => {
const b = emptyBucket("claude-haiku-5-5", "standard", "global", "standard");
for (const u of usages) accumulateBucket(b, extractUsageFields(u));
return { k: b };
};
before(() => {
db.prepare(
"INSERT OR IGNORE INTO sessions (id, name, status) VALUES ('long-1', 'x', 'active')"
).run();
});
it("stores the long part as a subset of the same row and keeps its high-water mark", () => {
const short = { input_tokens: 1_000 };
const long = { input_tokens: 150_000 };
writeSessionTokens(dbModule, "long-1", bucketOf(short, long));
assert.deepEqual(longRow("long-1"), { input_tokens: 151_000, long_input_tokens: 150_000 });
// A smaller-scope writer never takes the subset back.
writeSessionTokens(dbModule, "long-1", bucketOf(short));
assert.equal(longRow("long-1").long_input_tokens, 150_000);
});
it("lets the exact reconcile path clear a stale subset", () => {
writeSessionTokens(dbModule, "long-1", bucketOf({ input_tokens: 1_000 }), { exact: true });
assert.deepEqual(longRow("long-1"), { input_tokens: 1_000, long_input_tokens: 0 });
});
});
+100
View File
@@ -0,0 +1,100 @@
/**
* @file Tests the status-area icon's keeper (server/lib/tray.js): on by
* default, never started once the user hid it, one icon at a time, a stale
* heartbeat never trusted (its pid may be another program's by now), an icon
* from another install root replaced only at server start, and no retry loop
* for an icon that keeps dying. No real icon is spawned.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, beforeEach, after } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { EventEmitter } = require("node:events");
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-tray-"));
process.env.DASHBOARD_DATA_DIR = TMP;
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
process.env.DISPLAY = process.env.DISPLAY || ":99"; // guiEnv() on Linux
const tray = require("../lib/tray");
after(() => fs.rmSync(TMP, { recursive: true, force: true }));
function fakeSpawn() {
const calls = [];
const spawn = (cmd, args, opts) => {
calls.push({ cmd, args, opts });
const child = new EventEmitter();
child.unref = () => {};
return child;
};
return { spawn, calls };
}
describe("tray keeper", () => {
beforeEach(() => {
fs.rmSync(path.join(TMP, "runtime"), { recursive: true, force: true });
});
it("is on by default and starts scripts/tray.js detached", () => {
assert.equal(tray.isEnabled(), true);
const { spawn, calls } = fakeSpawn();
assert.equal(tray.ensureTray({ spawn }), "started");
assert.equal(calls.length, 1);
assert.match(calls[0].args[0], /scripts[\\/]tray\.js$/);
assert.equal(calls[0].opts.detached, true);
});
it("starts nothing once the user hid it", () => {
fs.mkdirSync(path.dirname(tray.offPath()), { recursive: true });
fs.writeFileSync(tray.offPath(), "x");
const { spawn, calls } = fakeSpawn();
assert.equal(tray.ensureTray({ spawn }), "skipped");
assert.equal(calls.length, 0);
});
it("leaves a live icon alone, and replaces one from another root only when asked", () => {
tray.writePid(process.pid, "/some/other/root");
const { spawn, calls } = fakeSpawn();
assert.equal(tray.ensureTray({ spawn }), "running");
assert.equal(calls.length, 0);
tray.writePid(process.pid); // this root: nothing to replace
assert.equal(tray.ensureTray({ spawn, replace: true }), "running");
assert.equal(calls.length, 0);
});
it("does not trust a heartbeat that went stale", () => {
tray.writePid(process.pid);
const old = new Date(Date.now() - 10 * tray.HEARTBEAT_MS);
fs.utimesSync(tray.pidPath(), old, old);
assert.equal(tray.liveTray(), null);
});
it("writes and clears the off flag", () => {
assert.equal(tray.setEnabled(false).enabled, false);
assert.equal(fs.existsSync(tray.offPath()), true);
tray.writePid(999_999_999); // no such process: nothing to signal, nothing to trust
const { spawn, calls } = fakeSpawn();
assert.equal(tray.setEnabled(true, { spawn }).enabled, true);
assert.equal(fs.existsSync(tray.offPath()), false);
assert.equal(calls.length, 1, "turning it on shows the icon at once");
});
it("gives up on an icon that keeps dying at once", () => {
const spawned = [];
const spawn = () => {
const child = new EventEmitter();
child.unref = () => {};
spawned.push(child);
return child;
};
for (let i = 0; i < 5; i++) {
if (tray.ensureTray({ spawn }) === "started") spawned.at(-1).emit("exit", 1);
}
assert.ok(spawned.length <= 4, `retried ${spawned.length} times`);
assert.equal(tray.ensureTray({ spawn }), "skipped");
});
});
-36
View File
@@ -1,36 +0,0 @@
{
"id": "gemini",
"label": "Gemini",
"detect": [
"gemini"
],
"prompt": {
"mode": "flag",
"flag": "--prompt-interactive"
},
"model": {
"flag": "--model",
"vendor": "google"
},
"bypass": [
"--yolo"
],
"homepageUrl": "https://github.com/google-gemini/gemini-cli",
"faviconDomain": "gemini.google.com",
"install": {
"kind": "npm",
"package": "@google/gemini-cli"
},
"update": {
"kind": "npm"
},
"config": [
{
"path": "~/.gemini/settings.json",
"format": "jsonc"
},
{
"path": "~/.gemini/GEMINI.md"
}
]
}

Some files were not shown because too many files have changed in this diff Show More