Compare commits

...

13 Commits

Author SHA1 Message Date
nntrivi2001 4999325210 chore: v2.8.28 — System Health grouped, a health score that sees lag 2026-10-27 16:40:01 +07:00
nntrivi2001 1c6dfea5b5 feat(health): System Health in three groups, a score that sees lag; admin user detail
Dashboard → System Health is grouped into AgentHub, Machine and Agent
activity. The machine (GET /api/host, every signed-in user) is drawn by
components/health/HostHealth.tsx: host card beside the CPU/memory history,
then CPU (threads as compact cells + temperatures/GPUs), disks & network,
busiest processes, and per-user tiles. Concurrency bars each show their own
agent type on hover.

The Health Score (client/src/lib/healthScore.ts) is now 100 minus what each
check costs, between an amber and a red threshold: event-loop p99
(server/lib/loop-delay.js, shared with /api/metrics), Linux PSI pressure for
CPU, memory and disk I/O (/api/host `pressure`; load per core without PSI),
RAM, the database's disk, V8 heap, hooks silent while agents work, and agent
errors in the last 24 h. All-time success rate, API errors and the transcript
cache hit rate no longer count. /api/settings/info adds server.event_loop and
activity {last_event_at, working_agents, agents_24h}.

Administration: a user row opens its detail in place (GET
/api/users/:id/details — spend, account pool share, live panes, workspaces,
newest sessions); the server panel is gone, member tmux folded into the table;
the Sessions list shows each row's owner in server mode.
2026-10-27 16:39:38 +07:00
nntrivi2001 df15789982 fix(9router): a sudo update gives root's npm its own cache
The AgentHub unit makes /root inaccessible (InaccessiblePaths=), so root's
npm failed at mkdir /root/.npm with ENOENT (exit 254). A sudo run now passes
--cache /var/cache/agenthub-9router-npm; the sudoers rule names that argv.
2026-10-26 17:12:02 +07:00
nntrivi2001 272a3e7d6e chore: v2.8.27 — Update 9router from Settings → AI Proxy 2026-10-26 17:08:09 +07:00
nntrivi2001 569b68ec98 feat(ai-proxy): Update 9router button — shut down, npm update, relaunch hidden
Settings → AI Proxy gains an admin-only Update 9router button beside
Config 9router. One in-memory job (server/lib/nine-router-update.js) stops
9router (its user unit, else this account's own 9router processes), runs
npm install -g --prefix <its prefix> 9router@latest (sudo -n only when
sudoers already allows it, probed before anything stops), then relaunches
it hidden only if it was running. A failed update still relaunches the old
version. Nothing in the request names a command, path or package.
2026-10-26 17:00:53 +07:00
nntrivi2001 631ea96f19 chore: v2.8.26 — React 19 / Express 5 / xterm 6, bigger file drops, hidden-tab terminal size fix 2026-10-26 16:36:49 +07:00
nntrivi2001 dce32b96b7 fix(drop): stream every dropped file to disk; no local size cap
- POST /api/run/drop-file pipes the body to its file as it arrives in every
  case — a member's (S3) through `write-as … replace` with the bytes on
  stdin — instead of buffering up to 20 MB in memory for a remote browser.
- Remote limit (server mode, a LAN browser) 20 MB → 500 MB, as a Files upload.
- A loopback browser in local mode has no cap any more, only the data dir's
  free space less a 1 GB reserve; ETOOBIG's max_bytes is what still fits, so
  the UI's "at most N MB" stays true.
- drop-image keeps its 20 MB in-memory limit, now checked on its own.
- fake-sudo gains a spawn for run-as.__setSpawnImpl.
2026-10-26 16:36:29 +07:00
nntrivi2001 a0cd808456 fix(terminal): a pane on a hidden tab never shrinks its tmux window
A terminal mounted on a hidden tab sits in a 0x0 box; its first fit shrank
xterm to 12x6 and the attach carried that size to tmux, so every background
pane was squeezed (Claude's TUI redrawn at 12 columns). It now neither fits
nor reports a size until shown, attaches with no size, and the server spawns
such an attach at the window's own size (tmux.windowSize). A shown tab
reports its size even when the fit did not change xterm's.

Settings > Agents shows the host's login profiles to staff only: a developer
got two 403s from reads that were never theirs.
2026-10-26 14:53:29 +07:00
nntrivi2001 e3745d7504 fix(build): target ES2021 so esbuild keeps xterm 6's requestMode variable
Vite's es2020 default made esbuild 0.25 lower `let r; …(r||={})` to
`void 0||(i={})` without the declaration, so every DECRQM query tmux sends
threw "i is not defined" inside the terminal's parser. Found by a headless
smoke run of the preview build.
2026-10-26 14:43:01 +07:00
nntrivi2001 8546748759 chore: maintenance — dependency majors, rename step 4, member Claude config, OpenAPI coverage
Dependencies
- Security: body-parser and multer DoS advisories fixed (npm audit: 0).
- Server: Express 5 (query parser kept "extended", req.body defaults to {},
  SPA catch-all /{*splat}, send-file helper restores Express 4's dot-folder
  rule for sendFile/download, alerts ack route without inline regex),
  better-sqlite3 13, js-yaml 5 (server/lib/yaml.js keeps js-yaml 4's default
  schema), uuid dropped for crypto.randomUUID, node-pty beta.18, multer 2.5.
- Client: React 19, xterm 6 (WebGL renderer replaces the removed canvas one,
  DOM fallback on context loss; Alt+arrow word moves re-mapped), lucide-react 1,
  TypeScript 7, i18next/react-i18next/codemirror minors.

Workspace rename step 4
- `agenthub lanes` and `--lane` exit 1 with "renamed: use …"; client api.ts,
  the CLI and the MCP server call /api/workspaces; /api/lanes stays mounted.

Server mode (S3): members manage their own Claude Code config
- server/lib/cc-target.js resolves whose config a /cc-config request touches
  and gates the router by allowlist; /profiles* and /plugins/update-all stay
  admin-only. Member writes go through write-as, CLI calls through sudo as the
  member (never -y), reads through openSafe; cc_config_changed reaches only
  its owner. Not verified live on an S3 install yet.

Other
- Chat: an HTML file is an artifact only when written whole and not a project
  page (index.html, or under src/, public/, templates/, …).
- OpenAPI covers every /api route; openapi-coverage.test.js guards it.
- read-pool test keeps its event loop alive (its worker is unref'd).
- Docs: plan statuses, checklists, CLI.md, LANES.md, SERVER-MODE.md, API.md.
2026-10-26 14:22:41 +07:00
nntrivi2001 cc2ece94c3 fix(ui): drop the git clone hints from Git identity and the background toast on closing the update dialog 2026-10-26 14:22:18 +07:00
nntrivi2001 6b602c76b8 chore: v2.8.25 — per-user Today, Alt+V image paste on Windows, local paste/drop by path 2026-10-26 11:18:05 +07:00
nntrivi2001 f2f1cd9b6c fix: per-user Today, native paste and drop on a local machine
- Today's spend in the status bar and the Claude Code statusline is the
  caller's own (`GET /api/pricing/cost?mine=1`, scope.ownSessionWhere), not
  every user's sum for an admin or analyst.
- A screenshot pasted into a local claude pane is sent as Claude Code's own
  image-paste key: Alt+V on Windows (Ctrl+V did nothing there), ^V elsewhere,
  and only when the machine can read its clipboard (wl-paste/xclip + a
  display on Linux), else it is uploaded (GET /api/run/native-paste {key}).
- A file manager's files pasted or dropped on a local browser go in by their
  own paths, read from the OS clipboard or the file manager's selection
  (server/lib/file-manager.js, POST /api/run/clipboard-files and
  /selected-files): no upload, no size limit. A copied image file is pasted
  by path, text that comes with a picture of it (spreadsheet cells) is
  pasted as text, Windows paths go in as `C:/…` mentions.
- Tab titles for Snapshot, Artifacts, Files and Administration.
- An npm tool AgentHub installed under ~/.local is updated there, not by its
  own updater into npm's global prefix.
2026-10-26 11:18:05 +07:00
183 changed files with 12040 additions and 4174 deletions
+2 -2
View File
@@ -7,7 +7,7 @@ Architectural overview and technical reference for the Agent Dashboard system, c
![Model Context Protocol](https://img.shields.io/badge/Model_Context_Protocol-1.0-0f766e?style=flat-square&logo=modelcontextprotocol&logoColor=white) ![Model Context Protocol](https://img.shields.io/badge/Model_Context_Protocol-1.0-0f766e?style=flat-square&logo=modelcontextprotocol&logoColor=white)
![Node.js](https://img.shields.io/badge/Node.js-%3E%3D20-339933?style=flat-square&logo=node.js&logoColor=white) ![Node.js](https://img.shields.io/badge/Node.js-%3E%3D20-339933?style=flat-square&logo=node.js&logoColor=white)
![Python](https://img.shields.io/badge/Python-%3E%3D3.6-3776AB?style=flat-square&logo=python&logoColor=white) ![Python](https://img.shields.io/badge/Python-%3E%3D3.6-3776AB?style=flat-square&logo=python&logoColor=white)
![Express](https://img.shields.io/badge/Express-4.21-000000?style=flat-square&logo=express&logoColor=white) ![Express](https://img.shields.io/badge/Express-5-000000?style=flat-square&logo=express&logoColor=white)
![ws](https://img.shields.io/badge/ws-WebSocket_server-010101?style=flat-square&logo=socketdotio&logoColor=white) ![ws](https://img.shields.io/badge/ws-WebSocket_server-010101?style=flat-square&logo=socketdotio&logoColor=white)
![web-push](https://img.shields.io/badge/web--push-VAPID-3b82f6?style=flat-square&logo=javascript&logoColor=white) ![web-push](https://img.shields.io/badge/web--push-VAPID-3b82f6?style=flat-square&logo=javascript&logoColor=white)
![swagger-ui-express](https://img.shields.io/badge/swagger--ui--express-5.0-85EA2D?style=flat-square&logo=swagger&logoColor=white) ![swagger-ui-express](https://img.shields.io/badge/swagger--ui--express-5.0-85EA2D?style=flat-square&logo=swagger&logoColor=white)
@@ -2557,7 +2557,7 @@ Components that consume this: SessionDetail, Analytics (donut chart + breakdown)
| **SQLite** (via `better-sqlite3` or built-in `node:sqlite`) | Zero-config, embedded, no server process. WAL mode gives concurrent reads. Synchronous API is simpler than async alternatives for this use case. Falls back to Node.js built-in `node:sqlite` when `better-sqlite3` cannot be compiled | | **SQLite** (via `better-sqlite3` or built-in `node:sqlite`) | Zero-config, embedded, no server process. WAL mode gives concurrent reads. Synchronous API is simpler than async alternatives for this use case. Falls back to Node.js built-in `node:sqlite` when `better-sqlite3` cannot be compiled |
| **Express** | Battle-tested, minimal, well-understood. Overkill would be Fastify for this scale; underkill would be raw `http` module | | **Express** | Battle-tested, minimal, well-understood. Overkill would be Fastify for this scale; underkill would be raw `http` module |
| **ws** | Fastest, most lightweight WebSocket library for Node. No Socket.IO overhead needed since we only push JSON messages | | **ws** | Fastest, most lightweight WebSocket library for Node. No Socket.IO overhead needed since we only push JSON messages |
| **React 18** | Stable, widely known, strong TypeScript support. No need for Server Components or RSC given this is a client-rendered SPA | | **React 19** | Stable, widely known, strong TypeScript support. No need for Server Components or RSC given this is a client-rendered SPA |
| **Vite** | Fast builds, native ESM, excellent dev experience. Proxy config handles the dev server split cleanly | | **Vite** | Fast builds, native ESM, excellent dev experience. Proxy config handles the dev server split cleanly |
| **Tailwind CSS** | Utility-first approach keeps styles colocated with markup. No CSS module boilerplate. Colors are CSS-variable-backed tokens (`darkMode: "class"`) so the Dark/Light/System switch re-themes the app with one class flip rather than per-component `dark:` variants, and a named theme (Settings → Appearance) is one `data-theme` attribute selecting an override block of the same variables | | **Tailwind CSS** | Utility-first approach keeps styles colocated with markup. No CSS module boilerplate. Colors are CSS-variable-backed tokens (`darkMode: "class"`) so the Dark/Light/System switch re-themes the app with one class flip rather than per-component `dark:` variants, and a named theme (Settings → Appearance) is one `data-theme` attribute selecting an override block of the same variables |
| **React Router 6** | Standard routing for React SPAs. Layout routes with `<Outlet>` give clean shell composition | | **React Router 6** | Standard routing for React SPAs. Layout routes with `<Outlet>` give clean shell composition |
+3 -3
View File
File diff suppressed because one or more lines are too long
+67 -69
View File
@@ -1281,7 +1281,7 @@ async function cmdDoctor() {
// Asked of the SERVER, not of this shell: a run is a tmux session the server // Asked of the SERVER, not of this shell: a run is a tmux session the server
// spawns, so the only PATH that decides whether Start Run works is its own — // spawns, so the only PATH that decides whether Start Run works is its own —
// a systemd --user service never reads your shell profile. Missing tmux is a // a systemd --user service never reads your shell profile. Missing tmux is a
// warning, not a failure: it costs the run console and `lanes shell`, nothing // warning, not a failure: it costs the run console and `workspace shell`, nothing
// else. // else.
try { try {
const { available, upgrade } = await get("/api/run/tmux"); const { available, upgrade } = await get("/api/run/tmux");
@@ -1693,7 +1693,7 @@ function cmdOpen() {
} }
/** /**
* `agenthub lanes add` — adopt a working directory or provision a managed worktree. * `agenthub workspace add` — adopt a working directory or provision a managed worktree.
*/ */
async function cmdLanesAdd(args) { async function cmdLanesAdd(args) {
const flag = (name) => { const flag = (name) => {
@@ -1719,12 +1719,12 @@ async function cmdLanesAdd(args) {
if (base) body.base = base; if (base) body.base = base;
if (slug) body.slug = slug; if (slug) body.slug = slug;
if (tool) body.tool = tool; if (tool) body.tool = tool;
const { lane: provisioning } = await post("/api/lanes/worktree", body); const { lane: provisioning } = await post("/api/workspaces/worktree", body);
const deadline = Date.now() + 30_000; const deadline = Date.now() + 30_000;
let lane = provisioning; let lane = provisioning;
while (lane.status === "provisioning" && Date.now() < deadline) { while (lane.status === "provisioning" && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 250)); await new Promise((resolve) => setTimeout(resolve, 250));
({ lane } = await get(`/api/lanes/${provisioning.id}`)); ({ lane } = await get(`/api/workspaces/${provisioning.id}`));
} }
if (lane.status === "provisioning") { if (lane.status === "provisioning") {
console.error( console.error(
@@ -1755,12 +1755,12 @@ async function cmdLanesAdd(args) {
const body = { cwd, title }; const body = { cwd, title };
if (pipeline) body.pipeline = pipeline; if (pipeline) body.pipeline = pipeline;
if (tool) body.tool = tool; if (tool) body.tool = tool;
const { lane } = await post("/api/lanes", body); const { lane } = await post("/api/workspaces", body);
console.log(`${c.green("✔")} Created workspace #${lane.id}: ${lane.title}`); console.log(`${c.green("✔")} Created workspace #${lane.id}: ${lane.title}`);
} }
/** /**
* `agenthub lanes gc [--dry-run]` — reap orphaned Playwright MCP processes * `agenthub workspace gc [--dry-run]` — reap orphaned Playwright MCP processes
* (owning session died) and cap oversized hook logs, across every lane on * (owning session died) and cap oversized hook logs, across every lane on
* this machine. Machine-wide, not one lane's; pure local process/file * this machine. Machine-wide, not one lane's; pure local process/file
* action, no HTTP round-trip. * action, no HTTP round-trip.
@@ -1790,7 +1790,7 @@ function cmdLanesGc(args) {
} }
/** /**
* `agenthub lanes profile init <repo>` — detect a Node.js project and scaffold * `agenthub workspace profile init <repo>` — detect a Node.js project and scaffold
* `.agenthub/profile/`. Pure filesystem action against the SOURCE repo; does not * `.agenthub/profile/`. Pure filesystem action against the SOURCE repo; does not
* talk to the dashboard server at all. * talk to the dashboard server at all.
*/ */
@@ -1844,9 +1844,9 @@ function cmdLanesProfileInit(args) {
} }
/** /**
* `agenthub lanes profile check [<path>]` — validate a profile without needing a * `agenthub workspace profile check [<path>]` — validate a profile without needing a
* lane to exist for it yet. Defaults to the current directory, NOT lane-id * lane to exist for it yet. Defaults to the current directory, NOT lane-id
* resolution (unlike every other `lanes` subcommand) — this is meant to run * resolution (unlike every other `workspace` subcommand) — this is meant to run
* against a bare repo right after `profile init`. * against a bare repo right after `profile init`.
*/ */
async function cmdLanesProfileCheck(args) { async function cmdLanesProfileCheck(args) {
@@ -1883,7 +1883,7 @@ function printLaneFacts(title, facts, fields) {
} }
} }
/** `agenthub lanes reset|remove|purge` — destructive work only after a fact check. */ /** `agenthub workspace reset|remove|purge` — destructive work only after a fact check. */
async function cmdLanesLifecycle(action, args) { async function cmdLanesLifecycle(action, args) {
const id = args.find((arg) => !arg.startsWith("--")); const id = args.find((arg) => !arg.startsWith("--"));
const fields = LANE_PREFLIGHT_FIELDS[action]; const fields = LANE_PREFLIGHT_FIELDS[action];
@@ -1894,7 +1894,7 @@ async function cmdLanesLifecycle(action, args) {
return; return;
} }
const preflight = await get(`/api/lanes/${id}/preflight?action=${action}`); const preflight = await get(`/api/workspaces/${id}/preflight?action=${action}`);
printLaneFacts(`Preflight for ${action} workspace #${id}:`, preflight, fields); printLaneFacts(`Preflight for ${action} workspace #${id}:`, preflight, fields);
const keepDb = action === "reset" && args.includes("--keep-db"); const keepDb = action === "reset" && args.includes("--keep-db");
if (preflight.database) { if (preflight.database) {
@@ -1938,7 +1938,7 @@ async function cmdLanesLifecycle(action, args) {
const body = { confirm: true, expect }; const body = { confirm: true, expect };
if (args.includes("--force")) body.force = true; if (args.includes("--force")) body.force = true;
if (keepDb) body.keepDb = true; if (keepDb) body.keepDb = true;
const result = await post(`/api/lanes/${id}/${action}`, body, { allowError: true }); const result = await post(`/api/workspaces/${id}/${action}`, body, { allowError: true });
if (result.status) { if (result.status) {
const error = result.data?.error || {}; const error = result.data?.error || {};
console.error(`✖ ${action} workspace #${id} → ${error.message || `HTTP ${result.status}`}`); console.error(`✖ ${action} workspace #${id} → ${error.message || `HTTP ${result.status}`}`);
@@ -1961,7 +1961,7 @@ async function cmdLanesLifecycle(action, args) {
} }
/** /**
* `agenthub lanes` — one row per lane: what it is, where it is in its pipeline, and * `agenthub workspaces` — one row per lane: what it is, where it is in its pipeline, and
* whether the driving session is still breathing. * whether the driving session is still breathing.
*/ */
/** /**
@@ -1993,7 +1993,7 @@ function detectionLeadsDeclaration(l) {
} }
async function cmdLanes() { async function cmdLanes() {
const { lanes, counts } = await get("/api/lanes"); const { lanes, counts } = await get("/api/workspaces");
if (!lanes.length) { if (!lanes.length) {
console.log( console.log(
"no workspaces yet — create one with: agenthub workspace add --cwd <path> --title <text>" "no workspaces yet — create one with: agenthub workspace add --cwd <path> --title <text>"
@@ -2040,7 +2040,7 @@ function toolFromEnv() {
* *
* Only the FIRST positional counts as an id, and only when it is all digits. * Only the FIRST positional counts as an id, and only when it is all digits.
* Scanning the whole argv for a number would swallow flag values — * Scanning the whole argv for a number would swallow flag values —
* `agenthub lanes logs web --tail 4096` would have addressed lane 4096. * `agenthub workspace logs web --tail 4096` would have addressed lane 4096.
* *
* @param {string[]} args - Arguments after the subcommand. * @param {string[]} args - Arguments after the subcommand.
* @returns {Promise<{laneId: string|number, rest: string[]}|null>} null once an error is printed. * @returns {Promise<{laneId: string|number, rest: string[]}|null>} null once an error is printed.
@@ -2051,13 +2051,13 @@ async function resolveLaneArg(args) {
return i > -1 ? args[i + 1] : undefined; return i > -1 ? args[i + 1] : undefined;
}; };
const explicit = flagValue("lane") || flagValue("workspace"); const explicit = flagValue("workspace");
if (explicit) return { laneId: explicit, rest: args }; if (explicit) return { laneId: explicit, rest: args };
if (args.length && /^\d+$/.test(args[0])) return { laneId: args[0], rest: args.slice(1) }; if (args.length && /^\d+$/.test(args[0])) return { laneId: args[0], rest: args.slice(1) };
const tool = flagValue("tool") || toolFromEnv(); const tool = flagValue("tool") || toolFromEnv();
const cwd = require("path").resolve(flagValue("cwd") || process.cwd()); const cwd = require("path").resolve(flagValue("cwd") || process.cwd());
const { lanes } = await get("/api/lanes"); const { lanes } = await get("/api/workspaces");
// A pane's AGENTHUB_LANE_ID beats cwd matching, but only after it is checked // A pane's AGENTHUB_LANE_ID beats cwd matching, but only after it is checked
// against the live lane list AND the directory: a pane outlives the lane it // against the live lane list AND the directory: a pane outlives the lane it
@@ -2146,7 +2146,7 @@ function printRuntime(runtime) {
} }
/** /**
* `agenthub lanes up|down|runtime|logs|hook` — a lane's own application stack, as * `agenthub workspace up|down|runtime|logs|hook` — a lane's own application stack, as
* opposed to `start`/`stop`, which drive its Claude run. Two lifecycles, one * opposed to `start`/`stop`, which drive its Claude run. Two lifecycles, one
* lane id. * lane id.
* *
@@ -2160,7 +2160,7 @@ async function cmdLanesRuntime(sub, args) {
const { laneId, rest: laneArgs } = resolved; const { laneId, rest: laneArgs } = resolved;
if (sub === "runtime") { if (sub === "runtime") {
printRuntime(await get(`/api/lanes/${laneId}/runtime`)); printRuntime(await get(`/api/workspaces/${laneId}/runtime`));
return; return;
} }
@@ -2168,7 +2168,7 @@ async function cmdLanesRuntime(sub, args) {
const body = {}; const body = {};
if (laneArgs.includes("--no-build")) body.build = false; if (laneArgs.includes("--no-build")) body.build = false;
if (laneArgs.includes("--qc")) body.qc = true; if (laneArgs.includes("--qc")) body.qc = true;
const result = await post(`/api/lanes/${laneId}/up`, body, { allowError: true }); const result = await post(`/api/workspaces/${laneId}/up`, body, { allowError: true });
if (result.status) { if (result.status) {
console.error(`✖ up workspace #${laneId} → ${result.data?.error?.message || result.status}`); console.error(`✖ up workspace #${laneId} → ${result.data?.error?.message || result.status}`);
process.exitCode = 1; process.exitCode = 1;
@@ -2181,7 +2181,7 @@ async function cmdLanesRuntime(sub, args) {
const deadline = Date.now() + 15 * 60 * 1000; const deadline = Date.now() + 15 * 60 * 1000;
for (;;) { for (;;) {
await new Promise((r) => setTimeout(r, 2000)); await new Promise((r) => setTimeout(r, 2000));
const runtime = await get(`/api/lanes/${laneId}/runtime`); const runtime = await get(`/api/workspaces/${laneId}/runtime`);
if (runtime.healthy) { if (runtime.healthy) {
printRuntime(runtime); printRuntime(runtime);
return; return;
@@ -2202,7 +2202,7 @@ async function cmdLanesRuntime(sub, args) {
} }
if (sub === "down") { if (sub === "down") {
const result = await post(`/api/lanes/${laneId}/down`, {}, { allowError: true }); const result = await post(`/api/workspaces/${laneId}/down`, {}, { allowError: true });
if (result.status) { if (result.status) {
console.error( console.error(
`✖ down workspace #${laneId} → ${result.data?.error?.message || result.status}` `✖ down workspace #${laneId} → ${result.data?.error?.message || result.status}`
@@ -2223,7 +2223,7 @@ async function cmdLanesRuntime(sub, args) {
} }
const i = laneArgs.indexOf("--tail"); const i = laneArgs.indexOf("--tail");
const query = i > -1 && laneArgs[i + 1] ? `?tail=${encodeURIComponent(laneArgs[i + 1])}` : ""; const query = i > -1 && laneArgs[i + 1] ? `?tail=${encodeURIComponent(laneArgs[i + 1])}` : "";
const log = await get(`/api/lanes/${laneId}/logs/${encodeURIComponent(svc)}${query}`); const log = await get(`/api/workspaces/${laneId}/logs/${encodeURIComponent(svc)}${query}`);
if (!log.available) { if (!log.available) {
console.log("no logs for this workspace yet."); console.log("no logs for this workspace yet.");
return; return;
@@ -2241,7 +2241,7 @@ async function cmdLanesRuntime(sub, args) {
return; return;
} }
const result = await post( const result = await post(
`/api/lanes/${laneId}/hook/${encodeURIComponent(name)}`, `/api/workspaces/${laneId}/hook/${encodeURIComponent(name)}`,
{ args: laneArgs.slice(1) }, { args: laneArgs.slice(1) },
{ allowError: true } { allowError: true }
); );
@@ -2263,7 +2263,7 @@ async function cmdLanesRuntime(sub, args) {
: "merge"; : "merge";
const branch = laneArgs.find((arg) => !arg.startsWith("--")); const branch = laneArgs.find((arg) => !arg.startsWith("--"));
const result = await post( const result = await post(
`/api/lanes/${laneId}/sync-base`, `/api/workspaces/${laneId}/sync-base`,
{ mode, branch }, { mode, branch },
{ allowError: true } { allowError: true }
); );
@@ -2342,7 +2342,7 @@ async function defaultHolder(argsAfterName) {
const resolved = await resolveLaneArg(argsAfterName); const resolved = await resolveLaneArg(argsAfterName);
if (!resolved) return null; if (!resolved) return null;
const { lane } = await get(`/api/lanes/${resolved.laneId}`); const { lane } = await get(`/api/workspaces/${resolved.laneId}`);
return lane.slot ? `lane${lane.slot}` : `lane${lane.id}`; return lane.slot ? `lane${lane.slot}` : `lane${lane.id}`;
} }
@@ -2474,7 +2474,7 @@ function fmtFeatureRow(f) {
async function cmdFeatureList(args) { async function cmdFeatureList(args) {
const resolved = await resolveLaneArg(args); const resolved = await resolveLaneArg(args);
if (!resolved) return; if (!resolved) return;
const { features } = await get(`/api/lanes/${resolved.laneId}/features`); const { features } = await get(`/api/workspaces/${resolved.laneId}/features`);
if (!features.length) { if (!features.length) {
console.log("no features activated yet — start one with: agenthub feature activate <slug>"); console.log("no features activated yet — start one with: agenthub feature activate <slug>");
return; return;
@@ -2496,7 +2496,7 @@ async function cmdFeatureActivate(args) {
}; };
const resolved = await resolveLaneArg(args.filter((a) => a !== slug)); const resolved = await resolveLaneArg(args.filter((a) => a !== slug));
if (!resolved) return; if (!resolved) return;
const { lane, feature } = await post(`/api/lanes/${resolved.laneId}/features/activate`, { const { lane, feature } = await post(`/api/workspaces/${resolved.laneId}/features/activate`, {
slug, slug,
title: flag("title"), title: flag("title"),
}); });
@@ -2506,13 +2506,13 @@ async function cmdFeatureActivate(args) {
} }
/** `agenthub feature show <slug> [<id>] [--cwd path]` — one feature's saved pipeline. */ /** `agenthub feature show <slug> [<id>] [--cwd path]` — one feature's saved pipeline. */
/** `agenthub lanes proof-link [<id>] [--cwd path]` — converge the clone-root /** `agenthub workspace proof-link [<id>] [--cwd path]` — converge the clone-root
* `proof/` onto `.playwright-mcp/proof` (idempotent). Never run automatically * `proof/` onto `.playwright-mcp/proof` (idempotent). Never run automatically
* by anything else in this codebase; a session or hook calls it explicitly. */ * by anything else in this codebase; a session or hook calls it explicitly. */
async function cmdLanesProofLink(args) { async function cmdLanesProofLink(args) {
const resolved = await resolveLaneArg(args); const resolved = await resolveLaneArg(args);
if (!resolved) return; if (!resolved) return;
const { linked } = await post(`/api/lanes/${resolved.laneId}/proof-link`); const { linked } = await post(`/api/workspaces/${resolved.laneId}/proof-link`);
console.log( console.log(
linked linked
? `${c.green("✔")} linked proof/ -> .playwright-mcp/proof` ? `${c.green("✔")} linked proof/ -> .playwright-mcp/proof`
@@ -2520,13 +2520,13 @@ async function cmdLanesProofLink(args) {
); );
} }
/** `agenthub lanes mcp sync [<id>] [--cwd path]` — relocate the source repo's /** `agenthub workspace mcp sync [<id>] [--cwd path]` — relocate the source repo's
* already-configured MCP servers into <lane>/.mcp.json and seed Chromium * already-configured MCP servers into <lane>/.mcp.json and seed Chromium
* profiles. Never run automatically; a session calls it explicitly. */ * profiles. Never run automatically; a session calls it explicitly. */
async function cmdLanesMcpSync(args) { async function cmdLanesMcpSync(args) {
const resolved = await resolveLaneArg(args); const resolved = await resolveLaneArg(args);
if (!resolved) return; if (!resolved) return;
const result = await post(`/api/lanes/${resolved.laneId}/mcp/sync`, {}, { allowError: true }); const result = await post(`/api/workspaces/${resolved.laneId}/mcp/sync`, {}, { allowError: true });
if (result.status) { if (result.status) {
console.error(`✖ mcp sync → ${result.data?.error?.message || result.status}`); console.error(`✖ mcp sync → ${result.data?.error?.message || result.status}`);
process.exitCode = 1; process.exitCode = 1;
@@ -2538,7 +2538,7 @@ async function cmdLanesMcpSync(args) {
} }
} }
/** `agenthub lanes integration <name> [<id>]` — check whether a named /** `agenthub workspace integration <name> [<id>]` — check whether a named
* integration (tracker, dev_qc, ci_wait) is turned on for this lane, per * integration (tracker, dev_qc, ci_wait) is turned on for this lane, per
* its .agenthub/profile/integrations.env. Exit 0 = on, 1 = off. */ * its .agenthub/profile/integrations.env. Exit 0 = on, 1 = off. */
async function cmdLanesIntegration(args) { async function cmdLanesIntegration(args) {
@@ -2551,13 +2551,13 @@ async function cmdLanesIntegration(args) {
const resolved = await resolveLaneArg(args.filter((a) => a !== name)); const resolved = await resolveLaneArg(args.filter((a) => a !== name));
if (!resolved) return; if (!resolved) return;
const { enabled } = await get( const { enabled } = await get(
`/api/lanes/${resolved.laneId}/integrations/${encodeURIComponent(name)}` `/api/workspaces/${resolved.laneId}/integrations/${encodeURIComponent(name)}`
); );
console.log(enabled ? "on" : "off"); console.log(enabled ? "on" : "off");
process.exitCode = enabled ? 0 : 1; process.exitCode = enabled ? 0 : 1;
} }
/** `agenthub lanes ci status <branch-or-sha> [<lane-id>]` — one-shot GitHub /** `agenthub workspace ci status <branch-or-sha> [<lane-id>]` — one-shot GitHub
* Actions status snapshot for a ref, via GET /:id/ci/status. Never polls; * Actions status snapshot for a ref, via GET /:id/ci/status. Never polls;
* the caller (ship-feature-lane Stage 13) owns the wait loop. Exit code * the caller (ship-feature-lane Stage 13) owns the wait loop. Exit code
* mirrors the derived state: 0 success, 1 failure or any error, 3 pending. */ * mirrors the derived state: 0 success, 1 failure or any error, 3 pending. */
@@ -2576,7 +2576,7 @@ async function cmdLanesCi(args) {
const resolved = await resolveLaneArg(args.slice(2)); const resolved = await resolveLaneArg(args.slice(2));
if (!resolved) return; if (!resolved) return;
const result = await get( const result = await get(
`/api/lanes/${resolved.laneId}/ci/status?ref=${encodeURIComponent(ref)}`, `/api/workspaces/${resolved.laneId}/ci/status?ref=${encodeURIComponent(ref)}`,
undefined, undefined,
{ allowError: true } { allowError: true }
); );
@@ -2590,7 +2590,7 @@ async function cmdLanesCi(args) {
} }
/** /**
* `agenthub lanes node [<node-id>] [extra…] [<id>] [--print]` — run ONE node of the * `agenthub workspace node [<node-id>] [extra…] [<id>] [--print]` — run ONE node of the
* lane's pipeline instead of the whole thing. * lane's pipeline instead of the whole thing.
* *
* Every node already carries the one command that drives it (`command`, added * Every node already carries the one command that drives it (`command`, added
@@ -2599,7 +2599,7 @@ async function cmdLanesCi(args) {
* pasted it into a session by hand. This runs it, in the lane's own cwd: * pasted it into a session by hand. This runs it, in the lane's own cwd:
* *
* - a slash command starts `claude` with it as the prompt; * - a slash command starts `claude` with it as the prompt;
* - anything else (`npm test`, `agenthub lanes hook ci-gate`) is spawned directly. * - anything else (`npm test`, `agenthub workspace hook ci-gate`) is spawned directly.
* *
* With no node id it lists the nodes and their commands, which is also the * With no node id it lists the nodes and their commands, which is also the
* error path's answer. `--print` shows the argv and runs nothing. * error path's answer. `--print` shows the argv and runs nothing.
@@ -2621,10 +2621,10 @@ async function cmdLanesNode(args) {
const { flags, positional } = parseArgs(args); const { flags, positional } = parseArgs(args);
const resolved = await resolveLaneArg(args); const resolved = await resolveLaneArg(args);
if (!resolved) return; if (!resolved) return;
const { lane } = await get(`/api/lanes/${resolved.laneId}`); const { lane } = await get(`/api/workspaces/${resolved.laneId}`);
// The lane payload's `pipeline_nodes` carries render state but not `aliases`; // The lane payload's `pipeline_nodes` carries render state but not `aliases`;
// the template does. Merge, so the listing can still mark the current node. // the template does. Merge, so the listing can still mark the current node.
const { pipelines } = await get("/api/lanes/pipelines"); const { pipelines } = await get("/api/workspaces/pipelines");
const stateOf = new Map((lane.pipeline_nodes || []).map((n) => [n.id, n.state])); const stateOf = new Map((lane.pipeline_nodes || []).map((n) => [n.id, n.state]));
const nodes = (pipelines.find((p) => p.id === lane.pipeline)?.nodes || []).map((n) => ({ const nodes = (pipelines.find((p) => p.id === lane.pipeline)?.nodes || []).map((n) => ({
...n, ...n,
@@ -2699,8 +2699,8 @@ async function cmdLanesNode(args) {
} }
/** /**
* `agenthub lanes pipeline [<template-id>] [<id>]` — show or switch which pipeline * `agenthub workspace pipeline [<template-id>] [<id>]` — show or switch which pipeline
* template a lane renders against. `agenthub lanes add --pipeline` could only set * template a lane renders against. `agenthub workspace add --pipeline` could only set
* this at creation time, so every lane added from the dashboard's "+ Add lane" * this at creation time, so every lane added from the dashboard's "+ Add lane"
* was stuck on `default` with no way back to a 16-node template. * was stuck on `default` with no way back to a 16-node template.
*/ */
@@ -2710,15 +2710,15 @@ async function cmdLanesPipeline(args) {
if (!resolved) return; if (!resolved) return;
if (!target) { if (!target) {
const { lane } = await get(`/api/lanes/${resolved.laneId}`); const { lane } = await get(`/api/workspaces/${resolved.laneId}`);
const { pipelines } = await get("/api/lanes/pipelines"); const { pipelines } = await get("/api/workspaces/pipelines");
console.log(`workspace #${lane.id} → ${lane.pipeline} (${lane.pipeline_nodes.length} nodes)`); console.log(`workspace #${lane.id} → ${lane.pipeline} (${lane.pipeline_nodes.length} nodes)`);
if (lane.pipeline_skill) console.log(`driver: ${lane.pipeline_skill} <requirement>`); if (lane.pipeline_skill) console.log(`driver: ${lane.pipeline_skill} <requirement>`);
console.log(`available: ${pipelines.map((p) => `${p.id} (${p.nodes.length})`).join(", ")}`); console.log(`available: ${pipelines.map((p) => `${p.id} (${p.nodes.length})`).join(", ")}`);
return; return;
} }
const { lane } = await patch(`/api/lanes/${resolved.laneId}`, { pipeline: target }); const { lane } = await patch(`/api/workspaces/${resolved.laneId}`, { pipeline: target });
console.log( console.log(
`${c.green("✔")} workspace #${lane.id} → pipeline ${lane.pipeline} ` + `${c.green("✔")} workspace #${lane.id} → pipeline ${lane.pipeline} ` +
`(${lane.pipeline_nodes.length} nodes, stage ${lane.stage}, ${lane.progress}%)` `(${lane.pipeline_nodes.length} nodes, stage ${lane.stage}, ${lane.progress}%)`
@@ -2746,7 +2746,7 @@ async function cmdLanesPipeline(args) {
async function cmdLanesShell(args) { async function cmdLanesShell(args) {
const resolved = await resolveLaneArg(args); const resolved = await resolveLaneArg(args);
if (!resolved) return; if (!resolved) return;
const { lane } = await get(`/api/lanes/${resolved.laneId}`); const { lane } = await get(`/api/workspaces/${resolved.laneId}`);
const sessionName = `agenthub-lane-${lane.id}`; const sessionName = `agenthub-lane-${lane.id}`;
const child = spawn("tmux", ["new-session", "-A", "-s", sessionName, "-c", lane.cwd], { const child = spawn("tmux", ["new-session", "-A", "-s", sessionName, "-c", lane.cwd], {
stdio: "inherit", stdio: "inherit",
@@ -2771,7 +2771,7 @@ async function cmdFeatureShow(args) {
const resolved = await resolveLaneArg(args.filter((a) => a !== slug)); const resolved = await resolveLaneArg(args.filter((a) => a !== slug));
if (!resolved) return; if (!resolved) return;
const result = await get( const result = await get(
`/api/lanes/${resolved.laneId}/features/${encodeURIComponent(slug)}`, `/api/workspaces/${resolved.laneId}/features/${encodeURIComponent(slug)}`,
undefined, undefined,
{ allowError: true } { allowError: true }
); );
@@ -2806,7 +2806,7 @@ async function cmdStage(args) {
if (!resolved) return; if (!resolved) return;
const laneId = resolved.laneId; const laneId = resolved.laneId;
const { lane } = await post(`/api/lanes/${laneId}/stage`, { const { lane } = await post(`/api/workspaces/${laneId}/stage`, {
stage, stage,
status: flag("status"), status: flag("status"),
evidence: flag("evidence"), evidence: flag("evidence"),
@@ -3101,8 +3101,8 @@ falling back to http://127.0.0.1:4820.
${c.bold("Output:")} colors auto-enable on a TTY and turn off when piped. ${c.bold("Output:")} colors auto-enable on a TTY and turn off when piped.
Disable with --no-color or NO_COLOR=1; force with FORCE_COLOR=1 / AGENTHUB_COLOR=1. Disable with --no-color or NO_COLOR=1; force with FORCE_COLOR=1 / AGENTHUB_COLOR=1.
${c.bold("Old name:")} ${c.bold("agenthub lanes …")} and ${c.bold("--lane")} still work — the same ${c.bold("Old name:")} ${c.bold("agenthub lanes …")} and ${c.bold("--lane")} were removed — use
commands as ${c.bold("agenthub workspace …")} and ${c.bold("--workspace")}. ${c.bold("agenthub workspace …")} and ${c.bold("--workspace")}.
${c.bold("Note:")} agenthub talks to the local dashboard server — API-backed commands ${c.bold("Note:")} agenthub talks to the local dashboard server — API-backed commands
require it to be running (${c.bold("agenthub start")} brings one up in the background). require it to be running (${c.bold("agenthub start")} brings one up in the background).
@@ -3512,8 +3512,8 @@ function replHelp() {
/** Detailed help for one command (or command prefix): every catalog entry /** Detailed help for one command (or command prefix): every catalog entry
* whose invocation starts with the token. Returns false if none matched. */ * whose invocation starts with the token. Returns false if none matched. */
function commandHelp(token) { function commandHelp(token) {
// `lanes`, `workspace` and `workspaces` are one command (COMMAND_ALIASES), // `workspace` and `workspaces` are one command (COMMAND_ALIASES), so
// so `help lanes` finds the `workspace …` rows and vice versa. // `help workspaces` finds the `workspace …` rows and vice versa.
const canon = (words) => { const canon = (words) => {
const [head, ...tail] = words.split(" "); const [head, ...tail] = words.split(" ");
return [COMMAND_ALIASES[head] || head, ...tail].join(" "); return [COMMAND_ALIASES[head] || head, ...tail].join(" ");
@@ -3774,23 +3774,22 @@ async function cmdRepl() {
// prompt. Handlers that need the server throw ServerDownError; the caller // prompt. Handlers that need the server throw ServerDownError; the caller
// decides whether to fall back to an offline reader or report the refusal. // decides whether to fall back to an offline reader or report the refusal.
/** Command names that are another command under the user-facing word. /** Command names that are another command under the user-facing word. */
* `workspace` is the name to use; `lanes` (the handler's own name) and const COMMAND_ALIASES = { workspaces: "workspace" };
* `--lane` are the OLD names, still working but deprecated. Removing them is
* step 4 of docs/plans/workspace-rename/README.md — not before the third
* release after the first one that shipped these aliases. */
const COMMAND_ALIASES = { workspace: "lanes", workspaces: "lanes" };
const canonicalArgv = (argv) => const canonicalArgv = (argv) =>
COMMAND_ALIASES[argv[0]] ? [COMMAND_ALIASES[argv[0]], ...argv.slice(1)] : argv; COMMAND_ALIASES[argv[0]] ? [COMMAND_ALIASES[argv[0]], ...argv.slice(1)] : argv;
/** One stderr line when an old name is typed, so people and agents move over /** The old names `agenthub workspace` and `--lane` were removed in step 4 of
* before step 4 removes it. stderr only: scripts reading stdout see nothing. */ * docs/plans/workspace-rename/README.md. They exit 1 naming the new spelling
function warnOldNames(argv) { * — never "Unknown command" or help, so a script written before says why. */
const old = []; function refuseOldNames(argv) {
if (argv[0] === "lanes") old.push("`agenthub lanes` → `agenthub workspace`"); if (argv[0] === "lanes") {
if (argv.includes("--lane")) old.push("`--lane` → `--workspace`"); console.error(c.red("✖ renamed: use agenthub workspace …"));
if (old.length) { process.exit(1);
console.error(c.dim(`note: old name, still works for now — use ${old.join(", ")}`)); }
if (argv.includes("--lane")) {
console.error(c.red("✖ renamed: use --workspace <id>"));
process.exit(1);
} }
} }
@@ -3803,7 +3802,6 @@ const COMMANDS = (() => {
for (const [name] of items) set.add(name.split(" ")[0]); for (const [name] of items) set.add(name.split(" ")[0]);
} }
set.add("help"); set.add("help");
// The old names stay typeable (and completable) for scripts written before.
for (const [alias, canonical] of Object.entries(COMMAND_ALIASES)) set.add(alias).add(canonical); for (const [alias, canonical] of Object.entries(COMMAND_ALIASES)) set.add(alias).add(canonical);
return [...set]; return [...set];
})(); })();
@@ -4498,7 +4496,7 @@ async function cmdAccounts(sub, positional, flags = {}) {
* for usage/validation failures. `cmd === "repl"` is handled by the entry * for usage/validation failures. `cmd === "repl"` is handled by the entry
* points, not here, to avoid recursive REPLs. */ * points, not here, to avoid recursive REPLs. */
async function runCommand(argv) { async function runCommand(argv) {
warnOldNames(argv); refuseOldNames(argv);
const [cmd, ...rest] = canonicalArgv(argv); const [cmd, ...rest] = canonicalArgv(argv);
const { flags, positional } = parseArgs(rest); const { flags, positional } = parseArgs(rest);
switch (cmd) { switch (cmd) {
@@ -4571,7 +4569,7 @@ async function runCommand(argv) {
return cmdReinstallHooks(); return cmdReinstallHooks();
case "update-check": case "update-check":
return cmdUpdateCheck(); return cmdUpdateCheck();
case "lanes": case "workspace":
if (rest[0] === "add") { if (rest[0] === "add") {
return cmdLanesAdd(rest.slice(1)); return cmdLanesAdd(rest.slice(1));
} }
+3 -3
View File
@@ -49,8 +49,8 @@ Enterprise-grade React + TypeScript dashboard for real-time Claude Code agent mo
The client is a single-page application (SPA) built with modern web technologies: The client is a single-page application (SPA) built with modern web technologies:
- **React 18.3** - Component-based UI with hooks and concurrent features - **React 19** - Component-based UI with hooks and concurrent features
- **TypeScript 5.7** - Full type safety across components, utilities, and API contracts - **TypeScript 7** - Full type safety across components, utilities, and API contracts
- **Vite 6.1** - Lightning-fast HMR during development, optimized production builds - **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. The workspace terminal's font is the same kind of preference: `agenthub.terminal.font` = `jetbrains|maple|system` (`lib/terminalFont.ts`, default `jetbrains`) and `agenthub.terminal.fontSize` = 11|12|13|14|15|16|18|20 px (`lib/fontSize.ts`, default 13), picked in Settings → Appearance; a change fires `agenthub-font-size` on `window`, so an open terminal re-fits at once. Settings → Appearance → **Text size** (`#text-size`) sets `agenthub.ui.fontSize` = 14|16|17|18 (Small/Default/Large/Larger), the ROOT font size in px (`lib/fontSize.ts`; the index.html script applies it before first paint too); Tailwind's sizes are rem and `index.css` re-states every `text-[Npx]` class in rem, so all text scales with it. All of these keys are synced to the account like the theme (`PREF_KEYS` in `lib/userPrefs.ts` and `server/routes/me.js`) - **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 preference: `agenthub.terminal.font` = `jetbrains|maple|system` (`lib/terminalFont.ts`, default `jetbrains`) and `agenthub.terminal.fontSize` = 11|12|13|14|15|16|18|20 px (`lib/fontSize.ts`, default 13), picked in Settings → Appearance; a change fires `agenthub-font-size` on `window`, so an open terminal re-fits at once. Settings → Appearance → **Text size** (`#text-size`) sets `agenthub.ui.fontSize` = 14|16|17|18 (Small/Default/Large/Larger), the ROOT font size in px (`lib/fontSize.ts`; the index.html script applies it before first paint too); Tailwind's sizes are rem and `index.css` re-states every `text-[Npx]` class in rem, so all text scales with it. All of these keys are synced to the account like the theme (`PREF_KEYS` in `lib/userPrefs.ts` and `server/routes/me.js`)
@@ -1215,7 +1215,7 @@ graph TB
The client is a production-ready React application with: The client is a production-ready React application with:
- 🚀 **Modern Stack** - React 18, TypeScript, Vite, Tailwind - 🚀 **Modern Stack** - React 19, TypeScript, Vite, Tailwind
- ⚡ **Real-time** - WebSocket integration for live updates - ⚡ **Real-time** - WebSocket integration for live updates
- 🧪 **Tested** - Vitest + React Testing Library - 🧪 **Tested** - Vitest + React Testing Library
- 📦 **Optimized** - Code splitting, tree shaking, lazy loading - 📦 **Optimized** - Code splitting, tree shaking, lazy loading
+427 -139
View File
@@ -15,18 +15,18 @@
"@fontsource/inter": "^5.2.8", "@fontsource/inter": "^5.2.8",
"@fontsource/jetbrains-mono": "^5.2.8", "@fontsource/jetbrains-mono": "^5.2.8",
"@fontsource/maple-mono": "^5.3.0", "@fontsource/maple-mono": "^5.3.0",
"@xterm/addon-canvas": "^0.7.0", "@xterm/addon-clipboard": "^0.2.0",
"@xterm/addon-clipboard": "^0.1.0", "@xterm/addon-fit": "^0.11.0",
"@xterm/addon-fit": "^0.10.0", "@xterm/addon-search": "^0.16.0",
"@xterm/addon-search": "^0.15.0", "@xterm/addon-web-links": "^0.12.0",
"@xterm/addon-web-links": "^0.11.0", "@xterm/addon-webgl": "^0.19.0",
"@xterm/xterm": "^5.5.0", "@xterm/xterm": "^6.0.0",
"d3": "^7.9.0", "d3": "^7.9.0",
"i18next": "^26.0.8", "i18next": "^26.0.8",
"i18next-browser-languagedetector": "^8.2.1", "i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^0.474.0", "lucide-react": "^1.61.0",
"react": "^18.3.1", "react": "^19.3.1",
"react-dom": "^18.3.1", "react-dom": "^19.3.1",
"react-i18next": "^17.0.4", "react-i18next": "^17.0.4",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-router-dom": "^7.18.4", "react-router-dom": "^7.18.4",
@@ -34,17 +34,17 @@
}, },
"devDependencies": { "devDependencies": {
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.3",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
"@types/d3": "^7.4.3", "@types/d3": "^7.4.3",
"@types/react": "^18.3.18", "@types/react": "^19.3.0",
"@types/react-dom": "^18.3.5", "@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"jsdom": "^27.0.1", "jsdom": "^27.0.1",
"postcss": "^8.5.1", "postcss": "^8.5.1",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"typescript": "^5.7.3", "typescript": "^7.0.2",
"vite": "^6.1.0", "vite": "^6.1.0",
"vitest": "^3.2.4" "vitest": "^3.2.4"
} }
@@ -450,10 +450,9 @@
} }
}, },
"node_modules/@codemirror/merge": { "node_modules/@codemirror/merge": {
"version": "6.12.2", "version": "6.12.3",
"resolved": "https://registry.npmjs.org/@codemirror/merge/-/merge-6.12.2.tgz", "resolved": "https://registry.npmjs.org/@codemirror/merge/-/merge-6.12.3.tgz",
"integrity": "sha512-V8JvyAPjHbPupqP7BeMcsdsYCbyPij74jxIbaIJDORI+VZzW44zFmon8bF+oxGWvOKhcRmkiUMXd8MxHr3YA2w==", "integrity": "sha512-Wq9wXrp41cW/nIpi1CQPyNwD+36kg8KIX676ym0Hyfn3Dpr+XQHNAIsZYVNlaivuVB4SAASbreG3V0VXinbqZA==",
"license": "MIT",
"dependencies": { "dependencies": {
"@codemirror/language": "^6.0.0", "@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0", "@codemirror/state": "^6.0.0",
@@ -478,10 +477,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@codemirror/view": { "node_modules/@codemirror/view": {
"version": "6.43.14", "version": "6.43.15",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.14.tgz", "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.15.tgz",
"integrity": "sha512-hC45YnmJNR1rz42OF57BLoQNFMV12/13drnvibDsR2quNDmldcb4WvYVV81hFQg56i9lcqQMEDqilhX2QDcGQQ==", "integrity": "sha512-HHsfpqFeHyXLs983pSRe38Xajs0vFWZQ2BuX0m7Ki8w3jZ5iD8cuR3lBz2/5/X53Dhf32SBHKh6rtSTB6tKeGw==",
"license": "MIT",
"dependencies": { "dependencies": {
"@codemirror/state": "^6.7.0", "@codemirror/state": "^6.7.0",
"crelt": "^1.0.6", "crelt": "^1.0.6",
@@ -1661,7 +1659,6 @@
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz", "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz",
"integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.12.5" "@babel/runtime": "^7.12.5"
}, },
@@ -2108,30 +2105,21 @@
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"license": "MIT"
},
"node_modules/@types/react": { "node_modules/@types/react": {
"version": "18.3.31", "version": "19.3.0",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
"integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==",
"license": "MIT",
"dependencies": { "dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2" "csstype": "^3.2.2"
} }
}, },
"node_modules/@types/react-dom": { "node_modules/@types/react-dom": {
"version": "18.3.7", "version": "19.3.0",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz",
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==",
"dev": true, "dev": true,
"license": "MIT",
"peerDependencies": { "peerDependencies": {
"@types/react": "^18.0.0" "@types/react": "^19.3.0"
} }
}, },
"node_modules/@types/unist": { "node_modules/@types/unist": {
@@ -2140,6 +2128,326 @@
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@typescript/typescript-aix-ppc64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
"integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
"cpu": [
"ppc64"
],
"dev": true,
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-darwin-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
"integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-darwin-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
"integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-freebsd-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz",
"integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-freebsd-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz",
"integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-arm": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz",
"integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz",
"integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-loong64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz",
"integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",
"cpu": [
"loong64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-mips64el": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz",
"integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",
"cpu": [
"mips64el"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-ppc64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz",
"integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",
"cpu": [
"ppc64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-riscv64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz",
"integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",
"cpu": [
"riscv64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-s390x": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz",
"integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",
"cpu": [
"s390x"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz",
"integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-netbsd-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz",
"integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-netbsd-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz",
"integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-openbsd-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz",
"integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-openbsd-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz",
"integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-sunos-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz",
"integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-win32-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz",
"integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-win32-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz",
"integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@ungap/structured-clone": { "node_modules/@ungap/structured-clone": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz",
@@ -2282,59 +2590,38 @@
"url": "https://opencollective.com/vitest" "url": "https://opencollective.com/vitest"
} }
}, },
"node_modules/@xterm/addon-canvas": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-canvas/-/addon-canvas-0.7.0.tgz",
"integrity": "sha512-LF5LYcfvefJuJ7QotNRdRSPc9YASAVDeoT5uyXS/nZshZXjYplGXRECBGiznwvhNL2I8bq1Lf5MzRwstsYQ2Iw==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^5.0.0"
}
},
"node_modules/@xterm/addon-clipboard": { "node_modules/@xterm/addon-clipboard": {
"version": "0.1.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.1.0.tgz", "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.2.0.tgz",
"integrity": "sha512-zdoM7p53T5sv/HbRTyp4hY0kKmEQ3MZvAvEtiXqNIHc/JdpqwByCtsTaQF5DX2n4hYdXRPO4P/eOS0QEhX1nPw==", "integrity": "sha512-Dl31BCtBhLaUEECUbEiVcCLvLBbaeGYdT7NofB8OJkGTD3MWgBsaLjXvfGAD4tQNHhm6mbKyYkR7XD8kiZsdNg==",
"license": "MIT",
"dependencies": { "dependencies": {
"js-base64": "^3.7.5" "js-base64": "^3.7.5"
},
"peerDependencies": {
"@xterm/xterm": "^5.4.0"
} }
}, },
"node_modules/@xterm/addon-fit": { "node_modules/@xterm/addon-fit": {
"version": "0.10.0", "version": "0.11.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz", "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
"integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==", "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g=="
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^5.0.0"
}
}, },
"node_modules/@xterm/addon-search": { "node_modules/@xterm/addon-search": {
"version": "0.15.0", "version": "0.16.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.15.0.tgz", "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.16.0.tgz",
"integrity": "sha512-ZBZKLQ+EuKE83CqCmSSz5y1tx+aNOCUaA7dm6emgOX+8J9H1FWXZyrKfzjwzV+V14TV3xToz1goIeRhXBS5qjg==", "integrity": "sha512-9OeuBFu0/uZJPu+9AHKY6g/w0Czyb/Ut0A5t79I4ULoU4IfU5BEpPFVGQxP4zTTMdfZEYkVIRYbHBX1xWwjeSA=="
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^5.0.0"
}
}, },
"node_modules/@xterm/addon-web-links": { "node_modules/@xterm/addon-web-links": {
"version": "0.11.0", "version": "0.12.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.11.0.tgz", "resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz",
"integrity": "sha512-nIHQ38pQI+a5kXnRaTgwqSHnX7KE6+4SVoceompgHL26unAxdfP6IPqUTSYPQgSwM56hsElfoNrrW5V7BUED/Q==", "integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw=="
"license": "MIT", },
"peerDependencies": { "node_modules/@xterm/addon-webgl": {
"@xterm/xterm": "^5.0.0" "version": "0.19.0",
} "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.19.0.tgz",
"integrity": "sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A=="
}, },
"node_modules/@xterm/xterm": { "node_modules/@xterm/xterm": {
"version": "5.5.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg=="
"license": "MIT"
}, },
"node_modules/agent-base": { "node_modules/agent-base": {
"version": "7.1.4", "version": "7.1.4",
@@ -3706,9 +3993,9 @@
} }
}, },
"node_modules/i18next": { "node_modules/i18next": {
"version": "26.4.2", "version": "26.5.11",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.2.tgz", "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.5.11.tgz",
"integrity": "sha512-RX+R0VLg13IbvRuJSxnqykUFS9vQZTl8wYpWPCIUDWVrSGjsQywB5Y+pjzrkboxGAuYfJZVH1InFTdgBdxq6ug==", "integrity": "sha512-Wc0klBPKxqZ2tL2qdAnF8xvvnWgr1IMGSY2ikTiAvaFVbz7xiFARtkoU4RidBDw1SyziGROp0qxu6qnJ7l2Wug==",
"funding": [ "funding": [
{ {
"type": "individual", "type": "individual",
@@ -3723,7 +4010,6 @@
"url": "https://www.locize.com" "url": "https://www.locize.com"
} }
], ],
"license": "MIT",
"peerDependencies": { "peerDependencies": {
"typescript": "^5 || ^6 || ^7" "typescript": "^5 || ^6 || ^7"
}, },
@@ -3734,12 +4020,11 @@
} }
}, },
"node_modules/i18next-browser-languagedetector": { "node_modules/i18next-browser-languagedetector": {
"version": "8.2.1", "version": "8.2.3",
"resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz", "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.3.tgz",
"integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==", "integrity": "sha512-0jSTK3ncpXj9MFdxRz+TVTKDkSvYH8ifB/OCmUdEMprvXC3vy7kEvdKpj0yYB72B0UKEHJLPMEeRVC/UXroWnw==",
"license": "MIT",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.23.2" "@babel/runtime": "^7.29.2"
} }
}, },
"node_modules/iconv-lite": { "node_modules/iconv-lite": {
@@ -3924,6 +4209,7 @@
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/jsdom": { "node_modules/jsdom": {
@@ -4022,18 +4308,6 @@
"url": "https://github.com/sponsors/wooorm" "url": "https://github.com/sponsors/wooorm"
} }
}, },
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
"bin": {
"loose-envify": "cli.js"
}
},
"node_modules/loupe": { "node_modules/loupe": {
"version": "3.2.1", "version": "3.2.1",
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
@@ -4052,12 +4326,17 @@
} }
}, },
"node_modules/lucide-react": { "node_modules/lucide-react": {
"version": "0.474.0", "version": "1.61.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.474.0.tgz", "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.61.0.tgz",
"integrity": "sha512-CmghgHkh0OJNmxGKWc0qfPJCYHASPMVSyGY8fj3xgk4v84ItqDg64JNKFZn5hC6E0vHi6gxnbCgwhyVB09wQtA==", "integrity": "sha512-v2SQPPPNjnr+BlhL9Spuzmo5yR+hLkftfuE4E3jaxdNPgSH0hJKPcLJBP7yAta87jQNPsSERS59PJjCJ/CorBA==",
"license": "ISC",
"peerDependencies": { "peerDependencies": {
"@types/react": "*",
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
} }
}, },
"node_modules/lz-string": { "node_modules/lz-string": {
@@ -5378,35 +5657,28 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/react": { "node_modules/react": {
"version": "18.3.1", "version": "19.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "integrity": "sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0"
},
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/react-dom": { "node_modules/react-dom": {
"version": "18.3.1", "version": "19.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "integrity": "sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ==",
"license": "MIT",
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0", "scheduler": "^0.28.0"
"scheduler": "^0.23.2"
}, },
"peerDependencies": { "peerDependencies": {
"react": "^18.3.1" "react": "^19.3.1"
} }
}, },
"node_modules/react-i18next": { "node_modules/react-i18next": {
"version": "17.0.16", "version": "17.1.9",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.16.tgz", "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.1.9.tgz",
"integrity": "sha512-7uImVD9hG5yUOaP5KHVied1x0A004Di/KU031ULR35Ma+9e9H5GdnNZHgQEIByyAz+IAXBkRDTedNVzjPUH8ZA==", "integrity": "sha512-OgYqIH13XHPlWTaaI795Dz+k2AdKnXcOxNQVnf750T8Ij0TYWp1nYvXRpoJBulPqlyk2OAbn1PM4tFKiIj/byA==",
"license": "MIT",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.29.7", "@babel/runtime": "^7.29.7",
"html-parse-stringify": "^4.0.1", "html-parse-stringify": "^4.0.1",
@@ -5755,13 +6027,9 @@
} }
}, },
"node_modules/scheduler": { "node_modules/scheduler": {
"version": "0.23.2", "version": "0.28.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz",
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0"
}
}, },
"node_modules/semver": { "node_modules/semver": {
"version": "6.3.1", "version": "6.3.1",
@@ -5774,9 +6042,9 @@
} }
}, },
"node_modules/set-cookie-parser": { "node_modules/set-cookie-parser": {
"version": "2.7.2", "version": "2.7.3",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.3.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==" "integrity": "sha512-irZHtTqurLna57KQG/s+fzOwv/J6XSH4cs8M/vsrY3uCaQ3S6+QJguAH0WdN47SXlk82voMZ4naWMQf8fdEoIg=="
}, },
"node_modules/siginfo": { "node_modules/siginfo": {
"version": "2.0.0", "version": "2.0.0",
@@ -6183,17 +6451,37 @@
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/typescript": { "node_modules/typescript": {
"version": "5.9.3", "version": "7.0.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0",
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc"
"tsserver": "bin/tsserver"
}, },
"engines": { "engines": {
"node": ">=14.17" "node": ">=16.20.0"
},
"optionalDependencies": {
"@typescript/typescript-aix-ppc64": "7.0.2",
"@typescript/typescript-darwin-arm64": "7.0.2",
"@typescript/typescript-darwin-x64": "7.0.2",
"@typescript/typescript-freebsd-arm64": "7.0.2",
"@typescript/typescript-freebsd-x64": "7.0.2",
"@typescript/typescript-linux-arm": "7.0.2",
"@typescript/typescript-linux-arm64": "7.0.2",
"@typescript/typescript-linux-loong64": "7.0.2",
"@typescript/typescript-linux-mips64el": "7.0.2",
"@typescript/typescript-linux-ppc64": "7.0.2",
"@typescript/typescript-linux-riscv64": "7.0.2",
"@typescript/typescript-linux-s390x": "7.0.2",
"@typescript/typescript-linux-x64": "7.0.2",
"@typescript/typescript-netbsd-arm64": "7.0.2",
"@typescript/typescript-netbsd-x64": "7.0.2",
"@typescript/typescript-openbsd-arm64": "7.0.2",
"@typescript/typescript-openbsd-x64": "7.0.2",
"@typescript/typescript-sunos-x64": "7.0.2",
"@typescript/typescript-win32-arm64": "7.0.2",
"@typescript/typescript-win32-x64": "7.0.2"
} }
}, },
"node_modules/unified": { "node_modules/unified": {
+13 -13
View File
@@ -18,18 +18,18 @@
"@fontsource/inter": "^5.2.8", "@fontsource/inter": "^5.2.8",
"@fontsource/jetbrains-mono": "^5.2.8", "@fontsource/jetbrains-mono": "^5.2.8",
"@fontsource/maple-mono": "^5.3.0", "@fontsource/maple-mono": "^5.3.0",
"@xterm/addon-canvas": "^0.7.0", "@xterm/addon-clipboard": "^0.2.0",
"@xterm/addon-clipboard": "^0.1.0", "@xterm/addon-fit": "^0.11.0",
"@xterm/addon-fit": "^0.10.0", "@xterm/addon-search": "^0.16.0",
"@xterm/addon-search": "^0.15.0", "@xterm/addon-web-links": "^0.12.0",
"@xterm/addon-web-links": "^0.11.0", "@xterm/addon-webgl": "^0.19.0",
"@xterm/xterm": "^5.5.0", "@xterm/xterm": "^6.0.0",
"d3": "^7.9.0", "d3": "^7.9.0",
"i18next": "^26.0.8", "i18next": "^26.0.8",
"i18next-browser-languagedetector": "^8.2.1", "i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^0.474.0", "lucide-react": "^1.61.0",
"react": "^18.3.1", "react": "^19.3.1",
"react-dom": "^18.3.1", "react-dom": "^19.3.1",
"react-i18next": "^17.0.4", "react-i18next": "^17.0.4",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-router-dom": "^7.18.4", "react-router-dom": "^7.18.4",
@@ -37,17 +37,17 @@
}, },
"devDependencies": { "devDependencies": {
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.3",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
"@types/d3": "^7.4.3", "@types/d3": "^7.4.3",
"@types/react": "^18.3.18", "@types/react": "^19.3.0",
"@types/react-dom": "^18.3.5", "@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"jsdom": "^27.0.1", "jsdom": "^27.0.1",
"postcss": "^8.5.1", "postcss": "^8.5.1",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"typescript": "^5.7.3", "typescript": "^7.0.2",
"vite": "^6.1.0", "vite": "^6.1.0",
"vitest": "^3.2.4" "vitest": "^3.2.4"
} }
+8
View File
@@ -24,10 +24,18 @@ export function DocumentTitle() {
title = `${t("sessions")} · ${sessionId.slice(0, 8)}`; title = `${t("sessions")} · ${sessionId.slice(0, 8)}`;
} else if (path.startsWith("/sessions")) { } else if (path.startsWith("/sessions")) {
title = t("sessions"); title = t("sessions");
} else if (path.startsWith("/snapshot/")) {
title = t("snapshot");
} else if (path.startsWith("/workspace")) { } else if (path.startsWith("/workspace")) {
title = t("workspace"); title = t("workspace");
} else if (path.startsWith("/settings")) { } else if (path.startsWith("/settings")) {
title = t("settings"); title = t("settings");
} else if (path.startsWith("/artifacts")) {
title = t("artifacts");
} else if (path.startsWith("/files")) {
title = t("files");
} else if (path.startsWith("/admin")) {
title = t("admin");
} else if (path !== "/") { } else if (path !== "/") {
title = t("notFound"); title = t("notFound");
} }
+1 -1
View File
@@ -93,7 +93,7 @@ export const SagiAvatar = memo(function SagiAvatar({
moment = null, moment = null,
}: SagiAvatarProps) { }: SagiAvatarProps) {
const rootRef = useRef<SVGSVGElement | null>(null); const rootRef = useRef<SVGSVGElement | null>(null);
const rafRef = useRef<number>(); const rafRef = useRef<number | undefined>(undefined);
// Pupils follow the cursor whenever motion is allowed and the eyes are open. // Pupils follow the cursor whenever motion is allowed and the eyes are open.
// (Only `sleeping` closes them by intent; `disconnected` hides the open-eye // (Only `sleeping` closes them by intent; `disconnected` hides the open-eye
+3 -3
View File
@@ -197,11 +197,11 @@ export function useSagiBrain(): SagiBrain {
const [bubble, setBubbleState] = useState<SagiBubble | null>(null); const [bubble, setBubbleState] = useState<SagiBubble | null>(null);
const [moment, setMoment] = useState<SagiBrain["moment"]>(null); const [moment, setMoment] = useState<SagiBrain["moment"]>(null);
const [reaction, setReaction] = useState<SagiBrain["reaction"]>(null); const [reaction, setReaction] = useState<SagiBrain["reaction"]>(null);
const reactionTimer = useRef<ReturnType<typeof setTimeout>>(); const reactionTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const [muted, setMuted] = useState<boolean>(() => sagiPrefs.getMuted()); const [muted, setMuted] = useState<boolean>(() => sagiPrefs.getMuted());
const bubbleTimer = useRef<ReturnType<typeof setTimeout>>(); const bubbleTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const momentTimer = useRef<ReturnType<typeof setTimeout>>(); const momentTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const lastBubbleAt = useRef(0); const lastBubbleAt = useRef(0);
const burst = useRef<Burst | null>(null); const burst = useRef<Burst | null>(null);
const momentKey = useRef(0); const momentKey = useRef(0);
+2 -2
View File
@@ -1,8 +1,8 @@
/** /**
* @file SessionToolBadge — names the AI coding CLI that produced a session * @file SessionToolBadge — names the AI coding CLI that produced a session
* when it is not Claude Code (`sessions.tool` = `codex`, `antigravity`, …, by * when it is not Claude Code (`sessions.tool` = `codex`, `antigravity`, …, by
* its AI Tools label). Renders nothing for a Claude session, so the Sessions * its AI Tools label). Renders nothing for a Claude session, so the session
* list and session header look as before for every Claude row. * header looks as before for every Claude row.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
+4 -3
View File
@@ -5,7 +5,8 @@
* — with a green dot while a newer release is out —, how many SESSIONS are * — with a green dot while a newer release is out —, how many SESSIONS are
* running and how many need the user (`GET /api/sessions?status=active` and * running and how many need the user (`GET /api/sessions?status=active` and
* `?awaiting=1` — the Dashboard's Needs-you rule —, re-read on session * `?awaiting=1` — the Dashboard's Needs-you rule —, re-read on session
* messages), and today's spend — `GET /api/pricing/cost?date=<local date>` — and, on the * messages), and today's spend — `GET /api/pricing/cost?date=<local date>&mine=1`, the
* user's OWN spend even for an admin — and, on the
* Workspace page, the open workspace's total (`?lane=` on the same call: the * Workspace page, the open workspace's total (`?lane=` on the same call: the
* sessions it owns) and the shown session's (`GET /api/pricing/cost/:id` for * sessions it owns) and the shown session's (`GET /api/pricing/cost/:id` for
* the URL's `session`, or the focused pane's session from the page's run poll, * the URL's `session`, or the focused pane's session from the page's run poll,
@@ -278,7 +279,7 @@ function QuotaChips() {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
// Leaving the chips closes the panel only after a beat, and entering the // Leaving the chips closes the panel only after a beat, and entering the
// panel cancels that: the pointer crosses the gap to read it. // panel cancels that: the pointer crosses the gap to read it.
const closeTimer = useRef<number>(); const closeTimer = useRef<number | undefined>(undefined);
const show = () => { const show = () => {
window.clearTimeout(closeTimer.current); window.clearTimeout(closeTimer.current);
setOpen(true); setOpen(true);
@@ -403,7 +404,7 @@ export function StatusBar({
const gen = costGen.current; const gen = costGen.current;
const live = () => gen === costGen.current; const live = () => gen === costGen.current;
api.pricing api.pricing
.totalCost({ date: localDate() }) .totalCost({ date: localDate(), mine: true })
.then((r) => live() && setToday(r)) .then((r) => live() && setToday(r))
.catch(() => undefined); .catch(() => undefined);
api.budgets api.budgets
+1 -2
View File
@@ -260,9 +260,8 @@ export function UpdateNotifier() {
// poll that reloads the page keep running, and a failure re-opens it. // poll that reloads the page keep running, and a failure re-opens it.
const closeForNow = useCallback(() => { const closeForNow = useCallback(() => {
if (!dismissKey) return; if (!dismissKey) return;
if (applying || restarting) toast.info(t("backgroundNote"));
setClosedKey(dismissKey); setClosedKey(dismissKey);
}, [applying, restarting, dismissKey, t]); }, [dismissKey]);
const dismiss = useCallback(() => { const dismiss = useCallback(() => {
if (busy || !dismissKey) return; if (busy || !dismissKey) return;
@@ -13,7 +13,7 @@ import { AgentCard } from "../AgentCard";
import type { Agent } from "../../lib/types"; import type { Agent } from "../../lib/types";
import { formatModelName, fmtCost } from "../../lib/format"; import { formatModelName, fmtCost } from "../../lib/format";
function renderCard(element: JSX.Element) { function renderCard(element: React.JSX.Element) {
return render(<MemoryRouter>{element}</MemoryRouter>); return render(<MemoryRouter>{element}</MemoryRouter>);
} }
@@ -34,5 +34,13 @@ describe("DocumentTitle", () => {
expect(titleFor("/")).toMatch(/^Dashboard · /); expect(titleFor("/")).toMatch(/^Dashboard · /);
expect(titleFor("/sessions")).toMatch(/^Sessions · /); expect(titleFor("/sessions")).toMatch(/^Sessions · /);
expect(titleFor("/settings")).toMatch(/^Settings · /); expect(titleFor("/settings")).toMatch(/^Settings · /);
expect(titleFor("/artifacts")).toMatch(/^Artifacts · /);
expect(titleFor("/files")).toMatch(/^Files · /);
expect(titleFor("/admin")).toMatch(/^Administration · /);
expect(titleFor("/snapshot/abc")).toMatch(/^Snapshot · /);
});
it("titles an unknown route as not found", () => {
expect(titleFor("/nope")).toMatch(/^Not found · /);
}); });
}); });
@@ -151,7 +151,7 @@ import { api } from "../../lib/api";
import { StatusBar } from "../StatusBar"; import { StatusBar } from "../StatusBar";
import { LIVE_RUNS_EVENT } from "../WorkspaceTree"; import { LIVE_RUNS_EVENT } from "../WorkspaceTree";
const at = (url: string, ui: JSX.Element) => const at = (url: string, ui: React.JSX.Element) =>
render(<MemoryRouter initialEntries={[url]}>{ui}</MemoryRouter>); render(<MemoryRouter initialEntries={[url]}>{ui}</MemoryRouter>);
describe("StatusBar", () => { describe("StatusBar", () => {
@@ -162,7 +162,7 @@ describe("StatusBar", () => {
const conn = screen.getByRole("button", { name: /Live/ }); const conn = screen.getByRole("button", { name: /Live/ });
expect(conn).toHaveTextContent(`v${__APP_VERSION__}`); expect(conn).toHaveTextContent(`v${__APP_VERSION__}`);
const cost = await screen.findByText("Today $1.23 (12.3M Tokens)"); const cost = await screen.findByText("Today $1.23 (12.3M Tokens)");
expect(api.pricing.totalCost).toHaveBeenCalledWith({ date: today() }); expect(api.pricing.totalCost).toHaveBeenCalledWith({ date: today(), mine: true });
expect(cost).toHaveAttribute( expect(cost).toHaveAttribute(
"title", "title",
"Claude Opus 4.8: 12.0M · $1.20\nClaude Haiku 4.5: 300.0K · $0.03" "Claude Opus 4.8: 12.0M · $1.20\nClaude Haiku 4.5: 300.0K · $0.03"
+165
View File
@@ -0,0 +1,165 @@
/**
* @file MiniChart.tsx
* @description The Administration page's small single-series charts: a bar
* chart (a user's daily spend) and a line chart (System Health's CPU and
* memory history), plain SVG in the app's theme tokens. One series each — the
* card's title names it, so there is no legend — thin marks, rounded bar ends
* on the baseline, a recessive grid with a max label, and a hover tooltip
* (crosshair for the line, per bar for the bars) whose text is the caller's.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useRef, useState, type MouseEvent } from "react";
export type ChartPoint = { x: string; y: number };
const H = 120;
const PAD_TOP = 14;
const PAD_BOTTOM = 18;
/**
* @param props.points - In x order; `x` is the axis/tooltip label.
* @param props.kind - `bar` or `line`.
* @param props.format - Formats a value for the max label and the tooltip.
* @param props.max - A fixed top (e.g. 100 for a percentage); else the data's max.
* @param props.label - The chart's accessible name.
*/
export function MiniChart({
points,
kind,
format,
max,
label,
emptyText,
}: {
points: ChartPoint[];
kind: "bar" | "line";
format: (v: number) => string;
max?: number;
label: string;
emptyText?: string;
}) {
const ref = useRef<SVGSVGElement>(null);
const [hover, setHover] = useState<number | null>(null);
if (points.length === 0)
return (
<div className="flex h-[120px] items-center justify-center text-xs text-fg-muted">
{emptyText ?? "—"}
</div>
);
const W = 600;
const top = Math.max(max ?? 0, ...points.map((p) => p.y), 1e-9);
const plotH = H - PAD_TOP - PAD_BOTTOM;
const yOf = (v: number) => PAD_TOP + plotH - (v / top) * plotH;
const step = W / points.length;
const xOf = (i: number) =>
kind === "bar" ? i * step + step / 2 : (i / Math.max(1, points.length - 1)) * W;
const onMove = (e: MouseEvent<SVGSVGElement>) => {
const r = ref.current?.getBoundingClientRect();
if (!r || r.width === 0) return;
const fx = ((e.clientX - r.left) / r.width) * W;
const i =
kind === "bar"
? Math.floor(fx / step)
: Math.round((fx / W) * Math.max(1, points.length - 1));
setHover(Math.max(0, Math.min(points.length - 1, i)));
};
const h = hover != null ? points[hover] : null;
const barW = Math.max(2, Math.min(24, step - 2));
const line = points
.map((p, i) => `${i ? "L" : "M"}${xOf(i).toFixed(1)},${yOf(p.y).toFixed(1)}`)
.join("");
return (
<div className="relative">
<svg
ref={ref}
viewBox={`0 0 ${W} ${H}`}
preserveAspectRatio="none"
className="block h-[120px] w-full"
role="img"
aria-label={label}
onMouseMove={onMove}
onMouseLeave={() => setHover(null)}
>
{[0, 0.5, 1].map((f) => (
<line
key={f}
x1={0}
x2={W}
y1={yOf(top * f)}
y2={yOf(top * f)}
className="stroke-border"
strokeWidth={1}
vectorEffect="non-scaling-stroke"
strokeDasharray={f === 0 ? undefined : "3 3"}
/>
))}
{kind === "bar" ? (
points.map((p, i) => {
const y = yOf(p.y);
const bh = Math.max(0, PAD_TOP + plotH - y);
const r = Math.min(4, barW / 2, bh);
const x = xOf(i) - barW / 2;
const base = PAD_TOP + plotH;
// Rounded top only; the base sits flat on the axis.
const d =
bh <= 0
? ""
: `M${x},${base}V${y + r}Q${x},${y} ${x + r},${y}H${x + barW - r}Q${x + barW},${y} ${x + barW},${y + r}V${base}Z`;
return (
<path key={i} d={d} className={hover === i ? "fill-accent" : "fill-accent-solid"} />
);
})
) : (
<>
<path
d={`${line}L${W},${PAD_TOP + plotH}L0,${PAD_TOP + plotH}Z`}
className="fill-accent-solid"
opacity={0.12}
/>
<path
d={line}
fill="none"
className="stroke-accent-solid"
strokeWidth={2}
vectorEffect="non-scaling-stroke"
strokeLinejoin="round"
/>
{hover != null && (
<line
x1={xOf(hover)}
x2={xOf(hover)}
y1={PAD_TOP}
y2={PAD_TOP + plotH}
className="stroke-fg-muted"
strokeWidth={1}
vectorEffect="non-scaling-stroke"
/>
)}
</>
)}
</svg>
<span className="pointer-events-none absolute left-0 top-0 text-[10px] text-fg-muted tabular-nums">
{format(top)}
</span>
<div className="pointer-events-none flex justify-between text-[10px] text-fg-muted">
<span>{points[0]!.x}</span>
<span>{points[points.length - 1]!.x}</span>
</div>
{h && hover != null && (
<div
className="pointer-events-none absolute top-0 z-10 -translate-x-1/2 whitespace-nowrap rounded-md border border-border bg-surface-1 px-2 py-1 text-[11px] shadow-lg"
style={{ left: `${Math.min(92, Math.max(8, (xOf(hover) / W) * 100))}%` }}
role="status"
>
<div className="text-fg-muted">{h.x}</div>
<div className="font-medium text-fg-primary tabular-nums">{format(h.y)}</div>
</div>
)}
</div>
);
}
-417
View File
@@ -1,417 +0,0 @@
/**
* @file ServerPanel.tsx
* @description The Administration page's Server tab: the machine AgentHub
* runs on — hostname, OS, uptime, CPU and its load, memory and swap, every
* real disk (HDD/SSD, how full), any NVIDIA GPU — and each user's live share
* of it (`GET /api/host`, server/lib/host-stats.js): CPU % of the whole
* machine, resident memory, process count, the busiest processes by name, and
* the disk their home takes (`GET /api/users/storage`, as a share of the disk
* that holds the homes). Re-read every `POLL_MS` while shown; Refresh re-reads
* at once. Read-only — the only action here is the members' tmux card
* (`MemberTmuxCard`, an admin's restart).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { Fragment, useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
AlertCircle,
ChevronDown,
ChevronRight,
Cpu,
HardDrive,
MemoryStick,
MonitorCog,
RefreshCw,
Server,
} from "lucide-react";
import {
api,
type HostStats,
type HostUsageGroup,
type ServerUserUsage,
type UserStorage,
} from "../../lib/api";
import { formatBytes } from "../../lib/format";
import { useSpin } from "../../hooks/useSpin";
import { StatCard } from "../StatCard";
import { UserAvatar } from "../users/UserAvatar";
import { MemberTmuxCard } from "../users/MemberTmuxCard";
const POLL_MS = 5000;
const pct = (part: number, whole: number) => (whole > 0 ? (part / whole) * 100 : 0);
const fmtPct = (n: number) => `${n < 10 ? n.toFixed(1) : Math.round(n)}%`;
/** A thin bar, amber past 75 %, red past 90 %. */
function Meter({ value, label }: { value: number; label: string }) {
const v = Math.max(0, Math.min(100, value));
const tone = v >= 90 ? "bg-status-danger" : v >= 75 ? "bg-status-warning" : "bg-accent-solid";
return (
<div
role="meter"
aria-label={label}
aria-valuenow={Math.round(v)}
aria-valuemin={0}
aria-valuemax={100}
className="h-1.5 w-full rounded-full bg-surface-4 overflow-hidden"
>
<div className={`h-full rounded-full ${tone}`} style={{ width: `${v}%` }} />
</div>
);
}
function uptimeText(t: (k: string, o?: Record<string, unknown>) => string, sec: number) {
const d = Math.floor(sec / 86400);
const h = Math.floor((sec % 86400) / 3600);
const m = Math.floor((sec % 3600) / 60);
return d > 0 ? t("server.uptimeDays", { d, h }) : t("server.uptimeHours", { h, m });
}
export function ServerPanel({ isAdmin }: { isAdmin: boolean }) {
const { t } = useTranslation("admin");
const [stats, setStats] = useState<HostStats | null>(null);
const [error, setError] = useState<string | null>(null);
const [users, setUsers] = useState<ServerUserUsage[] | null>(null);
const [storage, setStorage] = useState<Record<string, UserStorage> | null>(null);
const [open, setOpen] = useState<Set<string>>(new Set());
const load = useCallback(
() =>
api.host
.stats()
.then((s) => {
setStats(s);
setError(null);
})
.catch((e: Error) => setError(e.message)),
[]
);
const [spinning, refresh] = useSpin(load);
useEffect(() => {
void load();
const id = setInterval(() => {
if (document.visibilityState === "visible") void load();
}, POLL_MS);
return () => clearInterval(id);
}, [load]);
// Names, pictures and home sizes: read once, they change slowly.
useEffect(() => {
api.users
.list()
.then((r) => setUsers(r.users))
.catch(() => setUsers([]));
api.users
.storage()
.then((r) => setStorage(r.storage))
.catch(() => setStorage({}));
}, []);
const byId = new Map((users ?? []).map((u) => [u.id, u]));
const homesDisk = stats?.disks.find((d) => d.mount === stats.homesMount) ?? null;
const loading = !stats && !error;
const mem = stats?.memory;
const toggle = (key: string) =>
setOpen((s) => {
const n = new Set(s);
if (n.has(key)) n.delete(key);
else n.add(key);
return n;
});
const groupName = (g: HostUsageGroup) => {
if (g.kind === "host") return t("server.hostGroup");
if (g.kind === "system") return t("server.systemGroup");
const u = g.userId != null ? byId.get(g.userId) : undefined;
return u?.displayName || g.username || "";
};
const th = "px-5 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider";
return (
<div className="space-y-6" data-testid="server-panel">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h2 className="flex items-center gap-2 text-sm font-semibold text-fg-primary">
<Server className="w-4 h-4 text-accent" />
<span className="truncate">{stats?.hostname ?? t("server.title")}</span>
</h2>
{stats && (
<p className="mt-0.5 text-xs text-fg-muted">
{[stats.os.distro ?? stats.os.platform, stats.os.release, stats.os.arch].join(" · ")}
{" · "}
{t("server.uptime", { v: uptimeText(t, stats.uptime) })}
</p>
)}
</div>
<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>
</div>
{error && (
<div
role="alert"
className="card flex flex-wrap items-center gap-3 p-3 border-status-danger/40 text-sm text-status-danger"
>
<AlertCircle className="w-4 h-4 flex-shrink-0" />
<span className="flex-1 min-w-0">{error}</span>
<button type="button" onClick={refresh} className="btn-ghost text-xs">
{t("common:retry")}
</button>
</div>
)}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard
label={t("server.cpu")}
value={stats?.usage ? fmtPct(stats.usage.cpu) : "—"}
icon={Cpu}
trend={
stats
? t("server.cores", {
n: stats.cpu.cores,
load: stats.cpu.load.map((l) => l.toFixed(2)).join(" "),
})
: undefined
}
raw={stats?.cpu.model ?? undefined}
help={t("server.cpuHelp")}
loading={loading}
/>
<StatCard
label={t("server.memory")}
value={mem ? fmtPct(pct(mem.used, mem.total)) : "—"}
icon={MemoryStick}
trend={mem ? `${formatBytes(mem.used)} / ${formatBytes(mem.total)}` : undefined}
loading={loading}
/>
<StatCard
label={t("server.swap")}
value={mem && mem.swapTotal ? fmtPct(pct(mem.swapUsed, mem.swapTotal)) : "—"}
icon={MonitorCog}
trend={mem ? `${formatBytes(mem.swapUsed)} / ${formatBytes(mem.swapTotal)}` : undefined}
loading={loading}
/>
<StatCard
label={t("server.homesDisk")}
value={homesDisk ? fmtPct(pct(homesDisk.used, homesDisk.total)) : "—"}
icon={HardDrive}
trend={
homesDisk
? `${formatBytes(homesDisk.free)} ${t("server.free")} · ${homesDisk.mount}`
: undefined
}
help={t("server.homesDiskHelp")}
loading={loading}
/>
</div>
<div className="card overflow-x-auto">
<div className="px-5 pt-4 pb-2">
<h3 className="text-sm font-semibold text-fg-primary">{t("server.usageTitle")}</h3>
<p className="text-xs text-fg-muted">
{t("server.usageHelp", { s: (stats?.usage?.sampleMs ?? 1000) / 1000 })}
</p>
</div>
{stats && !stats.usage ? (
<p className="px-5 pb-4 text-sm text-fg-muted">{t("server.usageUnavailable")}</p>
) : (
<table className="w-full min-w-[760px]" data-testid="server-usage-table">
<thead>
<tr className="border-b border-border text-left">
<th className={th}>{t("server.who")}</th>
<th className={`${th} w-[18%]`}>{t("server.cpu")}</th>
<th className={`${th} w-[22%]`}>{t("server.memory")}</th>
<th className={`${th} text-right`}>{t("server.processes")}</th>
<th className={`${th} w-[18%]`}>{t("server.home")}</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{loading && (
<tr>
<td colSpan={5} className="px-5 py-6 text-sm text-fg-muted">
{t("server.sampling")}
</td>
</tr>
)}
{stats?.usage?.groups.map((g) => {
const u = g.userId != null ? byId.get(g.userId) : undefined;
const home = g.userId != null && storage ? storage[g.userId] : undefined;
const isOpen = open.has(g.key);
const name = groupName(g);
return (
<Fragment key={g.key}>
<tr className="hover:bg-surface-4/40 transition-colors">
<td className="px-5 py-3">
<button
type="button"
onClick={() => toggle(g.key)}
disabled={!g.top.length}
aria-expanded={isOpen}
className="flex items-center gap-2 text-left disabled:cursor-default"
>
{g.top.length ? (
isOpen ? (
<ChevronDown className="w-3.5 h-3.5 text-fg-muted" />
) : (
<ChevronRight className="w-3.5 h-3.5 text-fg-muted" />
)
) : (
<span className="w-3.5" />
)}
{g.kind === "user" ? (
<UserAvatar
name={name}
avatarAt={u?.avatarAt}
userId={g.userId}
className="h-6 w-6 text-[10px]"
/>
) : (
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-surface-4">
<Server className="w-3.5 h-3.5 text-fg-muted" />
</span>
)}
<span className="min-w-0">
<span className="block text-sm text-fg-primary truncate">{name}</span>
{g.kind === "user" && u?.displayName && (
<span className="block text-[11px] text-fg-muted">{g.username}</span>
)}
</span>
</button>
</td>
<td className="px-5 py-3">
<div className="text-sm text-fg-primary tabular-nums">{fmtPct(g.cpu)}</div>
<Meter value={g.cpu} label={`${name} ${t("server.cpu")}`} />
</td>
<td className="px-5 py-3">
<div className="text-sm text-fg-primary tabular-nums">
{formatBytes(g.memory)}{" "}
<span className="text-fg-muted">({fmtPct(g.memoryPercent)})</span>
</div>
<Meter value={g.memoryPercent} label={`${name} ${t("server.memory")}`} />
</td>
<td className="px-5 py-3 text-right text-sm text-fg-secondary tabular-nums">
{g.processes}
</td>
<td className="px-5 py-3">
{home ? (
<>
<div className="text-sm text-fg-primary tabular-nums">
{formatBytes(home.bytes)}
{homesDisk && (
<span className="text-fg-muted">
{" "}
({fmtPct(pct(home.bytes, homesDisk.total))})
</span>
)}
</div>
{homesDisk && (
<Meter
value={pct(home.bytes, homesDisk.total)}
label={`${name} ${t("server.home")}`}
/>
)}
</>
) : (
<span className="text-sm text-fg-muted">—</span>
)}
</td>
</tr>
{isOpen &&
g.top.map((p) => (
<tr key={`${g.key}:${p.pid}`} className="bg-surface-2/40">
<td className="pl-14 pr-5 py-1.5 text-xs text-fg-secondary font-mono truncate">
{p.name} <span className="text-fg-muted">#{p.pid}</span>
</td>
<td className="px-5 py-1.5 text-xs text-fg-secondary tabular-nums">
{fmtPct(p.cpu)}
</td>
<td className="px-5 py-1.5 text-xs text-fg-secondary tabular-nums">
{formatBytes(p.memory)}
</td>
<td colSpan={2} />
</tr>
))}
</Fragment>
);
})}
</tbody>
</table>
)}
</div>
{stats && stats.disks.length > 0 && (
<div className="card overflow-x-auto">
<h3 className="px-5 pt-4 pb-2 text-sm font-semibold text-fg-primary">
{t("server.disks")}
</h3>
<table className="w-full min-w-[640px]" data-testid="server-disks-table">
<thead>
<tr className="border-b border-border text-left">
<th className={th}>{t("server.mount")}</th>
<th className={th}>{t("server.device")}</th>
<th className={`${th} w-[30%]`}>{t("server.used")}</th>
<th className={`${th} text-right`}>{t("server.free")}</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{stats.disks.map((d) => (
<tr key={d.device}>
<td className="px-5 py-3 text-sm text-fg-primary">
<span className="font-mono">{d.mount}</span>
{d.mount === stats.homesMount && (
<span className="badge ml-2 py-0 text-[10px]">{t("server.homesBadge")}</span>
)}
</td>
<td className="px-5 py-3 text-xs text-fg-secondary">
<span className="font-mono">{d.device}</span> · {d.type}
{d.kind && <span className="badge ml-2 py-0 text-[10px]">{d.kind}</span>}
</td>
<td className="px-5 py-3">
<div className="text-sm text-fg-primary tabular-nums">
{formatBytes(d.used)} / {formatBytes(d.total)}{" "}
<span className="text-fg-muted">({fmtPct(pct(d.used, d.total))})</span>
</div>
<Meter value={pct(d.used, d.total)} label={d.mount} />
</td>
<td className="px-5 py-3 text-right text-sm text-fg-secondary tabular-nums">
{formatBytes(d.free)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{stats && stats.gpus.length > 0 && (
<div className="card p-5 space-y-3">
<h3 className="text-sm font-semibold text-fg-primary">{t("server.gpus")}</h3>
{stats.gpus.map((g, i) => (
<div key={i} className="space-y-1">
<div className="flex justify-between text-sm">
<span className="text-fg-primary">{g.name}</span>
<span className="text-fg-muted tabular-nums">
{fmtPct(g.utilization)} · {formatBytes(g.memoryUsed)} /{" "}
{formatBytes(g.memoryTotal)}
</span>
</div>
<Meter value={g.utilization} label={g.name} />
</div>
))}
</div>
)}
<MemberTmuxCard isAdmin={isAdmin} />
</div>
);
}
+707
View File
@@ -0,0 +1,707 @@
/**
* @file HostHealth.tsx
* @description The machine AgentHub runs on, as part of the Dashboard's
* System Health tab (`GET /api/host`, server/lib/host-stats.js — every
* signed-in user sees all of it). In the tab's own card style:
* `HostCard` — hostname, OS, uptime, CPU model and load, the AgentHub process
* (pid, uptime, Node RSS) and bars for CPU, memory, swap and the V8 heap;
* `HostSections` — the tab's whole "machine" group: `HostCard` beside the
* CPU / memory history (`GET /api/host/history`, kept in memory since the
* server started); then CPU (every thread as a compact cell, temperatures,
* GPUs), disks (how full, read / write rate) with network interfaces, and the
* busiest processes with who each counts for; and — server mode — each user's
* live CPU / RAM share (a tile per user; click for their busiest processes). Also exports `useHostStats` (a poll every `pollMs` while
* the tab is visible; 0 = read once), `Meter`, `pct` and `fmtPct`, which the
* Administration users table shares for each home's share of the homes disk.
* Read-only.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState, type ComponentType, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import {
Activity,
ArrowDown,
ArrowUp,
Cpu,
HardDrive,
ListTree,
Network,
Server,
Thermometer,
Users,
} from "lucide-react";
import {
api,
type HostHistory,
type HostHistoryRange,
type HostStats,
type HostUsageGroup,
} from "../../lib/api";
import { formatBytes, formatDateTime } from "../../lib/format";
import { Tip } from "../Tip";
import { MiniChart } from "../admin/MiniChart";
import { UserAvatar } from "../users/UserAvatar";
const POLL_MS = 5000;
const HISTORY_POLL_MS = 30000;
const RANGES: HostHistoryRange[] = ["1h", "6h", "24h"];
export const pct = (part: number, whole: number) => (whole > 0 ? (part / whole) * 100 : 0);
export const fmtPct = (n: number) => `${n < 10 ? n.toFixed(1) : Math.round(n)}%`;
/** `GET /api/host`, re-read every `POLL_MS` while the tab is visible. */
export type HostState = {
stats: HostStats | null;
error: string | null;
reload: () => Promise<void>;
};
export function useHostStats(enabled = true, pollMs = POLL_MS): HostState {
const [stats, setStats] = useState<HostStats | null>(null);
const [error, setError] = useState<string | null>(null);
const reload = useCallback(
() =>
api.host
.stats()
.then((s) => {
setStats(s);
setError(null);
})
.catch((e: Error) => setError(e.message)),
[]
);
useEffect(() => {
if (!enabled) return;
void reload();
if (!pollMs) return;
const id = setInterval(() => {
if (document.visibilityState === "visible") void reload();
}, pollMs);
return () => clearInterval(id);
}, [enabled, pollMs, reload]);
return { stats, error, reload };
}
/** A thin bar, amber past 75 %, red past 90 %. */
export function Meter({ value, label }: { value: number; label: string }) {
const v = Math.max(0, Math.min(100, value));
const tone = v >= 90 ? "bg-status-danger" : v >= 75 ? "bg-status-warning" : "bg-accent-solid";
return (
<div
role="meter"
aria-label={label}
aria-valuenow={Math.round(v)}
aria-valuemin={0}
aria-valuemax={100}
className="h-1.5 w-full rounded-full bg-surface-4 overflow-hidden"
>
<div className={`h-full rounded-full ${tone}`} style={{ width: `${v}%` }} />
</div>
);
}
/** The System Health tab's own bar: h-2, green / amber / red by level. */
function HealthBar({
label,
value,
tip,
warn = 70,
bad = 90,
}: {
label: string;
value: number | null;
tip?: string;
warn?: number;
bad?: number;
}) {
const v = value == null ? 0 : Math.max(0, Math.min(100, value));
const tone = v > bad ? "bg-status-danger" : v > warn ? "bg-status-warning" : "bg-status-success";
return (
<Tip block raw={tip}>
<div className="space-y-1">
<div className="flex justify-between text-[10px]">
<span className="text-fg-muted">{label}</span>
<span className="text-fg-secondary font-mono">
{value == null ? "—" : `${Math.round(v)}%`}
</span>
</div>
<div
role="meter"
aria-label={label}
aria-valuenow={Math.round(v)}
aria-valuemin={0}
aria-valuemax={100}
className="w-full bg-surface-3 rounded-full h-2"
>
<div
className={`h-2 rounded-full transition-all duration-700 ${tone}`}
style={{ width: `${v}%` }}
/>
</div>
</div>
</Tip>
);
}
/** One CPU thread in the CPU card's compact grid: its number, % and a thin bar. */
function CoreCell({ n, value, label }: { n: number; value: number; label: string }) {
const v = Math.max(0, Math.min(100, value));
const tone = v > 90 ? "text-status-danger" : v > 70 ? "text-status-warning" : "text-fg-secondary";
return (
<div className="space-y-0.5">
<div className="flex justify-between text-[10px] font-mono">
<span className="text-fg-muted">{n}</span>
<span className={tone}>{Math.round(v)}%</span>
</div>
<Meter value={v} label={label} />
</div>
);
}
function CardHead({
icon: Icon,
tone,
title,
right,
}: {
icon: ComponentType<{ className?: string }>;
tone: string;
title: string;
right?: ReactNode;
}) {
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3 min-w-0">
<Icon className={`w-4 h-4 flex-shrink-0 ${tone}`} />
<span className="text-xs text-fg-muted uppercase tracking-wider truncate">{title}</span>
</div>
{right}
</div>
);
}
function Row({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="flex items-center gap-3">
<span className="text-xs text-fg-secondary w-28 flex-shrink-0">{label}</span>
<span className="ml-auto min-w-0 truncate text-right text-xs text-fg-secondary font-mono">
{children}
</span>
</div>
);
}
function uptimeText(t: (k: string, o?: Record<string, unknown>) => string, sec: number) {
const d = Math.floor(sec / 86400);
const h = Math.floor((sec % 86400) / 3600);
const m = Math.floor((sec % 3600) / 60);
return d > 0 ? t("host.uptimeDays", { d, h }) : t("host.uptimeHours", { h, m });
}
/** The process's own heap, from `GET /api/settings/info` (the host read has none). */
export type HeapInfo = {
heapUsed: number;
heapTotal: number;
heapLimit?: number;
external: number;
};
/**
* The tab's first card: the machine and the AgentHub process on it.
* `heap` comes from the tab's settings read.
*/
export function HostCard({ host, heap }: { host: HostState; heap?: HeapInfo }) {
const { t } = useTranslation("dashboard");
const s = host.stats;
const mem = s?.memory;
const heapMax = heap ? heap.heapLimit || heap.heapTotal : 0;
const heapPct = heap && heapMax > 0 ? (heap.heapUsed / heapMax) * 100 : null;
return (
<div className="card p-5 flex flex-col gap-4" data-testid="server-facts">
<CardHead
icon={Server}
tone="text-status-success"
title={t("host.title")}
right={
s && (
<span className="text-[10px] font-mono text-fg-muted truncate" title={s.hostname}>
{s.hostname}
</span>
)
}
/>
{!s ? (
<p className="text-xs text-fg-muted">{host.error ?? t("host.sampling")}</p>
) : (
<>
<div className="space-y-2.5">
<Row label={t("host.os")}>
{s.os.distro ?? s.os.platform} · {s.os.release}
</Row>
<Row label={t("host.uptime")}>{uptimeText(t, s.uptime)}</Row>
<Row label={t("host.cpuModel")}>
<Tip raw={s.cpu.model ?? undefined}>
<span>
{s.cpu.physicalCores
? `${t("host.physical", { n: s.cpu.physicalCores })} · `
: ""}
{t("host.threads", { n: s.cpu.cores })} · {s.os.arch}
</span>
</Tip>
</Row>
<div className="flex items-center gap-3">
<span className="text-xs text-fg-secondary w-28 flex-shrink-0">{t("host.load")}</span>
<div className="flex gap-1 ml-auto">
{s.cpu.load.slice(0, 3).map((load, i) => (
<span
key={i}
className={`text-[10px] font-mono px-1.5 py-0.5 rounded ${i === 0 && load > s.cpu.cores ? "bg-status-danger/20 text-status-danger" : "bg-surface-3 text-fg-secondary"}`}
>
{load.toFixed(2)}
</span>
))}
</div>
</div>
{s.process && (
<Row label={t("host.agenthub")}>
<Tip raw={t("host.agenthubTip", { pid: s.process.pid, node: s.process.node })}>
<span>
{uptimeText(t, s.process.uptime)} · {formatBytes(s.process.rss)}
</span>
</Tip>
</Row>
)}
</div>
<div className="border-t border-border/40 pt-3 space-y-3">
<HealthBar
label={t("host.cpu")}
value={s.usage ? s.usage.cpu : null}
tip={t("host.cpuHelp")}
/>
<HealthBar
label={t("host.memory")}
value={mem ? pct(mem.used, mem.total) : null}
tip={mem ? `${formatBytes(mem.used)} / ${formatBytes(mem.total)}` : undefined}
/>
{mem && mem.swapTotal > 0 && (
<HealthBar
label={t("host.swap")}
value={pct(mem.swapUsed, mem.swapTotal)}
tip={`${formatBytes(mem.swapUsed)} / ${formatBytes(mem.swapTotal)}`}
/>
)}
{heap && (
<HealthBar
label={t("health.heap")}
value={heapPct}
warn={60}
bad={85}
tip={t("health.heapTip", {
pct: (heapPct ?? 0).toFixed(1),
used: formatBytes(heap.heapUsed),
limit: formatBytes(heapMax),
allocated: formatBytes(heap.heapTotal),
external: formatBytes(heap.external),
})}
/>
)}
</div>
</>
)}
</div>
);
}
/** The tab's machine group: host + history, CPU / disks / processes, users. */
export function HostSections({ host, heap }: { host: HostState; heap?: HeapInfo }) {
const { t } = useTranslation("dashboard");
const { stats } = host;
const [range, setRange] = useState<HostHistoryRange>("1h");
const [history, setHistory] = useState<HostHistory | null>(null);
// The user tile whose busiest processes are shown.
const [open, setOpen] = useState<string | null>(null);
const loadHistory = useCallback(
() =>
api.host
.history(range)
.then(setHistory)
.catch(() => setHistory(null)),
[range]
);
useEffect(() => {
void loadHistory();
const id = setInterval(() => {
if (document.visibilityState === "visible") void loadHistory();
}, HISTORY_POLL_MS);
return () => clearInterval(id);
}, [loadHistory]);
// Same tree as the full render below, so the card is not remounted once stats arrive.
if (!stats)
return (
<>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<HostCard host={host} heap={heap} />
</div>
</>
);
const groups = stats.usage?.groups ?? [];
const groupName = (g: HostUsageGroup) => {
if (g.kind === "host") return t("host.hostGroup");
if (g.kind === "system") return t("host.systemGroup");
return g.displayName || g.username || "";
};
const groupByKey = new Map(groups.map((g) => [g.key, g]));
const ownerOf = (key: string) => {
const g = groupByKey.get(key);
if (g) return groupName(g);
if (key === "host") return t("host.hostGroup");
if (key === "system") return t("host.systemGroup");
return key;
};
const perSec = (v: number) => t("host.perSecond", { v: formatBytes(v) });
const timeLabel = (iso: string) =>
new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
const histPoints = (key: "cpu" | "memory") =>
(history?.points ?? [])
.filter((p) => p[key] != null)
.map((p) => ({ x: timeLabel(p.at), y: p[key] as number }));
const perCore = stats.cpu.perCore ?? [];
const network = stats.network ?? [];
const temps = stats.temperatures ?? [];
const topProcs = stats.topProcesses ?? [];
const others = groups.filter((g) => g.kind !== "user");
const people = groups.filter((g) => g.kind === "user");
// A whole disk's I/O goes on the first mounted file system that lives on it
// (`/dev/sda1` → `sda`, `/dev/nvme0n1p2` → `nvme0n1`); a disk with nothing
// mounted gets a row of its own.
const ioLeft = new Map((stats.diskIo ?? []).map((d) => [d.name, d]));
const diskRows = stats.disks.map((d) => {
const base = d.device.split("/").pop() ?? "";
const key = [base, base.replace(/p?\d+$/, "")].find((k) => ioLeft.has(k));
const io = key ? ioLeft.get(key) : undefined;
if (key) ioLeft.delete(key);
return { disk: d, io };
});
const loneIo = [...ioLeft.values()];
const rate = (down?: number, up?: number) => (
<span className="flex items-center gap-2 text-[10px] font-mono text-fg-muted">
<span className="inline-flex items-center gap-0.5">
<ArrowDown className="w-2.5 h-2.5" />
{down == null ? "—" : perSec(down)}
</span>
<span className="inline-flex items-center gap-0.5">
<ArrowUp className="w-2.5 h-2.5" />
{up == null ? "—" : perSec(up)}
</span>
</span>
);
return (
<>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<HostCard host={host} heap={heap} />
{/* CPU and memory over time. */}
<div className="card p-5 flex flex-col gap-4 lg:col-span-2" data-testid="server-history">
<CardHead
icon={Activity}
tone="text-accent"
title={t("host.historyTitle")}
right={
<div
role="group"
aria-label={t("host.range")}
className="flex gap-1 rounded-lg border border-border bg-surface-1 p-0.5"
>
{RANGES.map((r) => (
<button
key={r}
type="button"
onClick={() => setRange(r)}
aria-pressed={range === r}
className={`rounded-md px-2 py-0.5 text-[11px] font-medium transition-colors ${
range === r
? "bg-surface-4 text-fg-secondary"
: "text-fg-muted hover:text-fg-secondary"
}`}
>
{t(`host.range${r}`)}
</button>
))}
</div>
}
/>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div>
<div className="mb-1 text-[10px] text-fg-muted">{t("host.historyCpu")}</div>
<MiniChart
kind="line"
points={histPoints("cpu")}
max={100}
format={fmtPct}
label={t("host.historyCpu")}
emptyText={t("host.historyNone")}
/>
</div>
<div>
<div className="mb-1 text-[10px] text-fg-muted">{t("host.historyMemory")}</div>
<MiniChart
kind="line"
points={histPoints("memory")}
max={100}
format={fmtPct}
label={t("host.historyMemory")}
emptyText={t("host.historyNone")}
/>
</div>
</div>
{history?.startedAt && (
<p className="text-[10px] text-fg-muted">
{t("host.historyHelp", {
s: history.intervalMs / 1000,
at: formatDateTime(history.startedAt),
})}
</p>
)}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{/* CPU: every thread as a compact cell, then temperatures and GPUs. */}
<div className="card p-5 flex flex-col gap-4" data-testid="server-cores">
<CardHead
icon={Cpu}
tone="text-syntax-keyword"
title={t("host.cpu")}
right={
<span className="text-[10px] font-mono text-fg-muted">
{t("host.threads", { n: perCore.length || stats.cpu.cores })}
</span>
}
/>
{perCore.length > 0 && (
<div className="grid grid-cols-4 gap-x-3 gap-y-2">
{perCore.map((v, i) => (
<CoreCell key={i} n={i} value={v} label={t("host.coreN", { n: i })} />
))}
</div>
)}
{temps.length > 0 && (
<div
className="border-t border-border/40 pt-3 space-y-2"
data-testid="server-temperatures"
>
<div className="flex items-center gap-1.5 text-[10px] text-fg-muted uppercase tracking-wider">
<Thermometer className="w-3 h-3 text-status-warning" />
{t("host.temperatures")}
</div>
{temps.map((s, i) => (
<div key={i} className="flex items-center gap-3">
<span className="text-xs text-fg-secondary min-w-0 truncate" title={s.chip}>
{s.label} <span className="text-fg-muted">· {s.chip}</span>
</span>
<span
className={`ml-auto text-[10px] font-mono px-1.5 py-0.5 rounded ${
s.celsius >= 90
? "bg-status-danger/20 text-status-danger"
: s.celsius >= 75
? "bg-status-warning/20 text-status-warning"
: "bg-surface-3 text-fg-secondary"
}`}
>
{s.celsius.toFixed(0)} °C
</span>
</div>
))}
</div>
)}
{stats.gpus.length > 0 && (
<div className="border-t border-border/40 pt-3 space-y-3">
{stats.gpus.map((g, i) => (
<HealthBar
key={i}
label={g.name}
value={g.utilization}
tip={`${formatBytes(g.memoryUsed)} / ${formatBytes(g.memoryTotal)}`}
/>
))}
</div>
)}
</div>
{/* Disks (with their I/O) and network interfaces. */}
{(diskRows.length > 0 || loneIo.length > 0 || network.length > 0) && (
<div className="card p-5 flex flex-col gap-4" data-testid="server-disks-table">
<CardHead
icon={HardDrive}
tone="text-syntax-builtin"
title={t("host.storageNetwork")}
/>
<div className="space-y-3" data-testid="server-disk-io">
{diskRows.map(({ disk: d, io }) => (
<div key={d.device} className="space-y-1">
<HealthBar
label={`${d.mount}${d.kind ? ` · ${d.kind}` : ""}${d.mount === stats.homesMount ? ` · ${t("host.homesBadge")}` : ""}`}
value={pct(d.used, d.total)}
warn={75}
tip={`${d.device} · ${d.type}\n${formatBytes(d.used)} / ${formatBytes(d.total)} · ${formatBytes(d.free)} ${t("host.free")}`}
/>
<div className="flex justify-between gap-2">
<span className="text-[10px] font-mono text-fg-muted">
{formatBytes(d.free)} {t("host.free")}
</span>
{io && rate(io.read, io.write)}
</div>
</div>
))}
{loneIo.map((io) => (
<div key={io.name} className="flex items-center gap-3">
<span className="text-xs text-fg-secondary font-mono">{io.name}</span>
<span className="ml-auto">{rate(io.read, io.write)}</span>
</div>
))}
</div>
{network.length > 0 && (
<div
className="border-t border-border/40 pt-3 space-y-2"
data-testid="server-network"
>
{network.map((n) => (
<div key={n.name} className="flex items-center gap-3">
<span className="inline-flex items-center gap-1.5 text-xs text-fg-secondary">
<Network className="w-3 h-3 text-fg-muted" />
<span className="font-mono">{n.name}</span>
</span>
<span className="ml-auto">{rate(n.rx, n.tx)}</span>
</div>
))}
</div>
)}
</div>
)}
{/* The busiest processes, and what belongs to no user. */}
<div className="card p-5 flex flex-col gap-4" data-testid="server-top-processes">
<CardHead
icon={ListTree}
tone="text-syntax-function"
title={t("host.topProcesses")}
right={
<span className="text-[10px] font-mono text-fg-muted">
{t("host.sampled", { s: (stats.usage?.sampleMs ?? 1000) / 1000 })}
</span>
}
/>
{!stats.usage ? (
<p className="text-xs text-fg-muted">{t("host.usageUnavailable")}</p>
) : (
<div className="space-y-2">
{topProcs.map((p) => (
<Tip block key={p.pid} raw={`#${p.pid} · ${ownerOf(p.group)}`}>
<div className="flex items-center gap-3">
<span className="min-w-0 flex-1 truncate text-xs">
<span className="font-mono text-fg-secondary">{p.name}</span>
<span className="ml-1.5 text-[10px] text-fg-muted">{ownerOf(p.group)}</span>
</span>
<span className="text-[10px] font-mono text-fg-muted">
{formatBytes(p.memory)}
</span>
<span
className={`w-12 text-right text-[10px] font-mono px-1.5 py-0.5 rounded ${p.cpu >= 50 ? "bg-status-warning/20 text-status-warning" : "bg-surface-3 text-fg-secondary"}`}
>
{fmtPct(p.cpu)}
</span>
</div>
</Tip>
))}
</div>
)}
{others.length > 0 && (
<div
className="border-t border-border/40 pt-3 space-y-1.5"
data-testid="server-usage-table"
>
{others.map((g) => (
<div key={g.key} className="flex items-center gap-3 text-[10px]">
<span className="min-w-0 flex-1 truncate text-fg-muted" title={groupName(g)}>
{groupName(g)}
</span>
<span className="font-mono text-fg-secondary">
{fmtPct(g.cpu)} · {formatBytes(g.memory)} ·{" "}
{t("host.processCount", { n: g.processes })}
</span>
</div>
))}
</div>
)}
</div>
</div>
{/* Each user's live share of the machine (server mode). */}
{people.length > 0 && (
<div className="card p-5 flex flex-col gap-4" data-testid="server-users">
<CardHead
icon={Users}
tone="text-syntax-string"
title={t("host.usersTitle")}
right={
<span className="text-[10px] font-mono text-fg-muted">
{t("host.usersCount", { n: people.length })}
</span>
}
/>
<div className="grid grid-cols-1 gap-x-6 gap-y-4 md:grid-cols-2 xl:grid-cols-3">
{people.map((g) => {
const name = groupName(g);
const isOpen = open === g.key;
return (
<div key={g.key} className="space-y-2" data-testid="server-user">
<button
type="button"
onClick={() => setOpen(isOpen ? null : g.key)}
disabled={!g.top.length}
aria-expanded={isOpen}
className="flex w-full items-center gap-2 text-left disabled:cursor-default"
>
<UserAvatar name={name} className="h-6 w-6 text-[10px]" />
<span className="min-w-0 flex-1 truncate text-xs text-fg-secondary">
{name}
</span>
<span className="text-[10px] font-mono text-fg-muted">
{t("host.processCount", { n: g.processes })}
</span>
</button>
<HealthBar label="CPU" value={g.cpu} tip={t("host.cpuHelp")} />
<HealthBar label={`RAM · ${formatBytes(g.memory)}`} value={g.memoryPercent} />
{isOpen && (
<ul className="space-y-1 rounded-md bg-surface-2/60 px-2 py-1.5">
{g.top.map((p) => (
<li
key={p.pid}
className="flex justify-between gap-2 text-[10px] font-mono"
>
<span className="truncate text-fg-secondary">
{p.name} <span className="text-fg-muted">#{p.pid}</span>
</span>
<span className="flex-shrink-0 text-fg-muted">
{fmtPct(p.cpu)} · {formatBytes(p.memory)}
</span>
</li>
))}
</ul>
)}
</div>
);
})}
</div>
</div>
)}
</>
);
}
@@ -0,0 +1,182 @@
/**
* @file HostHealth.test.tsx
* @description The machine on the Dashboard's System Health tab
* (`HostHealth.tsx`, `GET /api/host`), its whole machine group: the first card names the host, its OS,
* load and the AgentHub process with bars for CPU, memory, swap and heap; the
* rows under it show every CPU thread, temperatures, each disk with its I/O
* rate and each network interface, the CPU / memory history with its range
* switch (`GET /api/host/history`), and the busiest processes with who each
* counts for, the processes of no user in a short list of their own, and
* each user's live CPU / RAM as a tile (their busiest processes on click).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, within } from "@testing-library/react";
import type { HostStats } from "../../../lib/api";
const GB = 1024 ** 3;
const stats: HostStats = {
at: "2026-10-15T00:00:00Z",
hostname: "box",
os: { platform: "linux", release: "7.0.0", distro: "Ubuntu 26.04", arch: "x64" },
uptime: 90000,
cpu: { model: "i5-7500", cores: 4, load: [1, 0.5, 0.25] },
memory: { total: 16 * GB, available: 8 * GB, used: 8 * GB, swapTotal: 4 * GB, swapUsed: 0 },
disks: [
{
device: "/dev/sdb2",
mount: "/",
type: "ext4",
kind: "SSD",
total: 200 * GB,
used: 50 * GB,
free: 150 * GB,
},
{
device: "/dev/sda1",
mount: "/data",
type: "ext4",
kind: "HDD",
total: 500 * GB,
used: 0,
free: 500 * GB,
},
],
homesMount: "/",
gpus: [],
usage: {
cpu: 40,
sampleMs: 1000,
groups: [
{
key: "user:2",
kind: "user",
userId: 2,
username: "mia",
cpu: 25,
memory: 2 * GB,
memoryPercent: 12.5,
processes: 3,
top: [{ pid: 77, name: "claude", cpu: 20, memory: GB }],
},
{ key: "host", kind: "host", cpu: 5, memory: GB, memoryPercent: 6.3, processes: 90, top: [] },
],
},
};
const api = vi.hoisted(() => ({
host: { stats: vi.fn(), history: vi.fn() },
me: { avatarUrl: () => null },
}));
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return { ...actual, api };
});
import { HostSections, useHostStats } from "../HostHealth";
function Tab() {
const host = useHostStats();
return (
<HostSections
host={host}
heap={{ heapUsed: GB / 4, heapTotal: GB / 2, heapLimit: GB, external: 0 }}
/>
);
}
const full: HostStats = {
...stats,
usage: {
...stats.usage!,
groups: stats.usage!.groups.map((g) => (g.kind === "user" ? { ...g, displayName: "Mia" } : g)),
},
bootTime: "2026-10-14T00:00:00Z",
cpu: { ...stats.cpu, physicalCores: 4, perCore: [10, 20, 30, 40] },
network: [{ name: "enp4s0", rx: 2048, tx: 1024 }],
diskIo: [{ name: "sda", read: 4096, write: 0 }],
temperatures: [{ chip: "coretemp", label: "Package id 0", celsius: 55 }],
topProcesses: [{ pid: 77, name: "claude", cpu: 20, memory: GB, group: "user:2" }],
process: { pid: 9, node: "v24.0.0", uptime: 3600, rss: GB, heapUsed: GB / 2 },
};
beforeEach(() => {
vi.clearAllMocks();
api.host.stats.mockResolvedValue(full);
api.host.history.mockResolvedValue({
range: "1h",
intervalMs: 30000,
running: true,
startedAt: "2026-10-15T00:00:00Z",
points: [
{
at: "2026-10-15T00:00:00Z",
cpu: 30,
memory: 50,
memoryUsed: 0,
swapUsed: 0,
load1: 1,
users: {},
},
{
at: "2026-10-15T00:00:30Z",
cpu: 40,
memory: 52,
memoryUsed: 0,
swapUsed: 0,
load1: 1,
users: {},
},
],
});
});
describe("HostHealth", () => {
it("names the machine and the AgentHub process, with bars", async () => {
render(<Tab />);
const card = await screen.findByTestId("server-facts");
expect(await within(card).findByText("box")).toBeTruthy();
expect(card.textContent).toContain("Ubuntu 26.04");
expect(card.textContent).toContain("1.0 GB");
expect(within(card).getByRole("meter", { name: "CPU" }).getAttribute("aria-valuenow")).toBe(
"40"
);
expect(within(card).getByRole("meter", { name: "Memory" }).getAttribute("aria-valuenow")).toBe(
"50"
);
expect(within(card).getByRole("meter", { name: "V8 Heap" })).toBeTruthy();
});
it("shows every thread, sensors, disks with I/O, network, history and processes", async () => {
render(<Tab />);
expect(await screen.findByTestId("server-history")).toBeTruthy();
expect(await screen.findByRole("img", { name: /CPU/ })).toBeTruthy();
expect(screen.getByTestId("server-cores").textContent).toContain("40%");
expect(screen.getByTestId("server-temperatures").textContent).toContain("55 °C");
const disks = screen.getByTestId("server-disk-io").textContent;
expect(disks).toContain("/data · HDD");
expect(disks).toContain("4.0 KB/s");
expect(screen.getByTestId("server-network").textContent).toContain("enp4s0");
const top = screen.getByTestId("server-top-processes");
expect(await within(top).findByText("Mia")).toBeTruthy();
const other = screen.getByTestId("server-usage-table");
expect(within(other).queryByText("Mia")).toBeNull();
expect(within(other).getByText(/^Host account/)).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "24 h" }));
expect(api.host.history).toHaveBeenLastCalledWith("24h");
});
it("shows each user's live CPU and RAM, their processes on click", async () => {
render(<Tab />);
const tile = await screen.findByTestId("server-user");
expect(tile.textContent).toContain("Mia");
expect(within(tile).getByRole("meter", { name: "CPU" }).getAttribute("aria-valuenow")).toBe(
"25"
);
expect(tile.textContent).toContain("2.0 GB");
expect(within(tile).queryByText("claude")).toBeNull();
fireEvent.click(within(tile).getByRole("button", { expanded: false }));
expect(within(tile).getByText("claude")).toBeTruthy();
});
});
@@ -382,7 +382,8 @@ describe("LaneChangesPanel", () => {
const box = within(folder.parentElement!).getByRole("checkbox", { const box = within(folder.parentElement!).getByRole("checkbox", {
name: "Tick every file in server/lib", name: "Tick every file in server/lib",
}); });
expect(box).toBeChecked(); // The default ticks land in an effect after the list renders.
await waitFor(() => expect(box).toBeChecked());
await user.click(box); await user.click(box);
expect(tickOf("server/lib/git-ops.js")).not.toBeChecked(); expect(tickOf("server/lib/git-ops.js")).not.toBeChecked();
await user.click(folder); await user.click(folder);
+1 -1
View File
@@ -765,7 +765,7 @@ export function Divider({
onRatio, onRatio,
label, label,
}: { }: {
gridRef: React.RefObject<HTMLDivElement>; gridRef: React.RefObject<HTMLDivElement | null>;
dir: SplitDir; dir: SplitDir;
at: number; at: number;
box: Rect; box: Rect;
+29 -17
View File
@@ -12,7 +12,8 @@
* remembered for the + menu's Recent uploads), and a panel row (Files, * remembered for the + menu's Recent uploads), and a panel row (Files,
* Changes, a context file or folder) dropped on it (`lib/promptAttach.ts`) * Changes, a context file or folder) dropped on it (`lib/promptAttach.ts`)
* as an `@path` chip — so is a local file a browser on the dashboard's * as an `@path` chip — so is a local file a browser on the dashboard's
* own machine drops or pastes with its `file://` path, never uploaded — * own machine drops or pastes with its `file://` path, or pastes from a
* file manager's Copy its OS clipboard holds, never uploaded —
* a `bare` one takes a drop anywhere on its box; * a `bare` one takes a drop anywhere on its box;
* its `/command` and `@path` tokens are coloured by a mirror laid over * its `/command` and `@path` tokens are coloured by a mirror laid over
* the textarea (`lib/promptTokens.ts`); * the textarea (`lib/promptTokens.ts`);
@@ -78,6 +79,8 @@ import {
localAttachment, localAttachment,
localCandidates, localCandidates,
sameFilesHere, sameFilesHere,
fileManagerPaths,
isClipboardBitmap,
SKILL_MIME, SKILL_MIME,
rememberUploads, rememberUploads,
} from "../../lib/promptAttach"; } from "../../lib/promptAttach";
@@ -463,21 +466,26 @@ export function PromptEditor({
}; };
/** Dropped or pasted files: by their own paths when the browser is on the /** Dropped or pasted files: by their own paths when the browser is on the
* dashboard's machine and the server finds the same files there (a file * dashboard's machine and the server finds the same files there (a file
* manager hands over `file://` paths beside the bytes), else uploaded. */ * manager hands over `file://` paths beside the bytes) or its file manager
* holds them (a paste: its clipboard; a drop: its selection — no upload,
* no size limit), else uploaded. */
const attachDropped = ( const attachDropped = (
dt: DataTransfer | null | undefined, dt: DataTransfer | null | undefined,
list: FileList | null | undefined list: FileList | File[] | null | undefined,
from: "paste" | "drop"
) => { ) => {
const files = Array.from(list ?? []); const files = Array.from(list ?? []);
if (files.length === 0) return false; if (files.length === 0) return false;
const paths = localCandidates(dt, files); if (!isLocalBrowser()) return attachImages(files);
if (!paths) return attachImages(files); const candidates = localCandidates(dt, files);
setUpload("uploading"); setUpload("uploading");
void sameFilesHere(paths, files).then((same) => { void (async () => {
if (!same) return void attachImages(files); let paths = candidates && (await sameFilesHere(candidates, files)) ? candidates : null;
paths ??= await fileManagerPaths(files, from);
if (!paths) return void attachImages(files);
setUpload(null); setUpload(null);
onImagesChange((prev) => addAttachments(prev, paths.map(localAttachment))); onImagesChange((prev) => addAttachments(prev, paths.map(localAttachment)));
}); })();
return true; return true;
}; };
// Anything attached since — a path, a panel row, a + menu pick — ends a past // Anything attached since — a path, a panel row, a + menu pick — ends a past
@@ -518,7 +526,7 @@ export function PromptEditor({
} else if (skill) { } else if (skill) {
e.preventDefault(); e.preventDefault();
onChange(`/${skill} ${value.replace(/^\s+/, "")}`); onChange(`/${skill} ${value.replace(/^\s+/, "")}`);
} else if (attachDropped(e.dataTransfer, e.dataTransfer?.files)) e.preventDefault(); } else if (attachDropped(e.dataTransfer, e.dataTransfer?.files, "drop")) e.preventDefault();
else { else {
// Only text (a file manager's or an editor's `file://` list, a // Only text (a file manager's or an editor's `file://` list, a
// selection, a link): what the terminal beneath would paste. // selection, a link): what the terminal beneath would paste.
@@ -683,17 +691,21 @@ export function PromptEditor({
onBlur={() => setTrigger(null)} onBlur={() => setTrigger(null)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onPaste={(e) => { onPaste={(e) => {
if (attachDropped(e.clipboardData, e.clipboardData?.files)) e.preventDefault(); const pasted = Array.from(e.clipboardData?.files ?? []);
else if (pastedLocalFiles(e.clipboardData)) e.preventDefault(); const text = e.clipboardData?.getData?.("text/plain") ?? "";
else { // Cells from a spreadsheet come with a picture of them: the text
// is what was copied (isClipboardBitmap).
const textWithPicture =
pasted.length > 0 && pasted.every(isClipboardBitmap) && !!text.trim();
if (!textWithPicture && attachDropped(e.clipboardData, pasted, "paste"))
e.preventDefault();
else if (!textWithPicture && pastedLocalFiles(e.clipboardData)) e.preventDefault();
else if (text.length > PASTE_AS_FILE_CHARS) {
// A very long paste goes in as a text file, like Claude Desktop: // A very long paste goes in as a text file, like Claude Desktop:
// the prompt then carries its path, never the text (#68: a CLI's // the prompt then carries its path, never the text (#68: a CLI's
// argument and a tmux message both have a size limit). // argument and a tmux message both have a size limit).
const text = e.clipboardData?.getData("text/plain") ?? ""; e.preventDefault();
if (text.length > PASTE_AS_FILE_CHARS) { attachImages([new File([text], "pasted-text.txt", { type: "text/plain" })]);
e.preventDefault();
attachImages([new File([text], "pasted-text.txt", { type: "text/plain" })]);
}
} }
}} }}
placeholder={placeholder} placeholder={placeholder}
+99 -28
View File
@@ -20,7 +20,11 @@
* reads them when the prompt is sent — an image as its bare path, which claude * reads them when the prompt is sent — an image as its bare path, which claude
* shows as `[Image #n]` like a native drop — except a PASTED image in a * shows as `[Image #n]` like a native drop — except a PASTED image in a
* `claude` pane of a local-mode dashboard opened on its own machine, which is * `claude` pane of a local-mode dashboard opened on its own machine, which is
* sent as ^V so `claude` reads the desktop clipboard itself, uploading nothing * sent as its image-paste key (^V; Alt+V on Windows) so `claude` reads the
* desktop clipboard itself, uploading nothing — and a file manager's files
* pasted or dropped on that machine go in by the paths it holds them under
* (its clipboard / its selection: `POST /api/run/clipboard-files`,
* `selected-files`), with no upload and no size limit
* (`lib/nativePaste.ts`, a Settings switch); a browser on the dashboard's own * (`lib/nativePaste.ts`, a Settings switch); a browser on the dashboard's own
* machine pastes a file manager's files (dropped, or copied: `file://` text) * machine pastes a file manager's files (dropped, or copied: `file://` text)
* by their own paths instead, uploading nothing. A text drag (a Files/Changes row, a skill) is pasted as its text. The pane also registers with * by their own paths instead, uploading nothing. A text drag (a Files/Changes row, a skill) is pasted as its text. The pane also registers with
@@ -44,7 +48,9 @@
* most every 150 ms. Several browsers on one pane share ONE size: the server * most every 150 ms. Several browsers on one pane share ONE size: the server
* names a driver (see pty-attach.js) and the others FOLLOW — their xterm takes * names a driver (see pty-attach.js) and the others FOLLOW — their xterm takes
* the driver's grid (`size`), scaled down to fit, until a click or a keystroke * the driver's grid (`size`), scaled down to fit, until a click or a keystroke
* here takes the lead back (`resize` with `drive: true`). * here takes the lead back (`resize` with `drive: true`). Cells are drawn by
* the WebGL addon (the DOM renderer after a lost context), and Alt+arrow keeps
* the word-move keys xterm 5 sent (`altArrowSeq`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { import {
@@ -59,7 +65,7 @@ import {
} from "react"; } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { Terminal } from "@xterm/xterm"; import type { Terminal } from "@xterm/xterm";
import type { CanvasAddon } from "@xterm/addon-canvas"; import type { WebglAddon } from "@xterm/addon-webgl";
import type { ClipboardAddon, IClipboardProvider } from "@xterm/addon-clipboard"; import type { ClipboardAddon, IClipboardProvider } from "@xterm/addon-clipboard";
import type { SearchAddon } from "@xterm/addon-search"; import type { SearchAddon } from "@xterm/addon-search";
import { ArrowDown, ArrowUp, X } from "lucide-react"; import { ArrowDown, ArrowUp, X } from "lucide-react";
@@ -72,18 +78,20 @@ import { MENU_CURSOR_ROW, notePaneFocus, notePaneOutput, registerPane } from "..
import { import {
claudePastes, claudePastes,
copiedFilePaths, copiedFilePaths,
isClipboardBitmap,
droppedFilePaths, droppedFilePaths,
fileManagerPaths,
isLocalBrowser, isLocalBrowser,
localCandidates, localCandidates,
sameFilesHere, sameFilesHere,
} from "../../lib/promptAttach"; } from "../../lib/promptAttach";
import { attachImeInput } from "../../lib/imeInput"; import { attachImeInput } from "../../lib/imeInput";
import { readNativePaste } from "../../lib/nativePaste"; import { nativePasteKey, nativePasteWorks, readNativePaste } from "../../lib/nativePaste";
import { usePermissions } from "../AuthGate"; import { usePermissions } from "../AuthGate";
import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont"; import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont";
import { FONT_SIZE_EVENT, readTerminalFontSize } from "../../lib/fontSize"; import { FONT_SIZE_EVENT, readTerminalFontSize } from "../../lib/fontSize";
type DropState = "hint" | "paste" | "uploading" | "failed" | "tooBig" | "tooBigLocal"; type DropState = "hint" | "paste" | "uploading" | "failed" | "tooBig" | "tooBigLocalPaste";
/** localStorage flag for xterm's screen-reader mode (a Settings toggle can set it later). */ /** localStorage flag for xterm's screen-reader mode (a Settings toggle can set it later). */
export const SCREEN_READER_KEY = "agenthub.terminal.screenReader"; export const SCREEN_READER_KEY = "agenthub.terminal.screenReader";
@@ -117,10 +125,10 @@ const clipboardProvider: IClipboardProvider = {
* Workspace chunk. The result is cached, so every later pane (and a test that * Workspace chunk. The result is cached, so every later pane (and a test that
* awaits `loadXterm()` first) sets up synchronously. */ * awaits `loadXterm()` first) sets up synchronously. */
const importXterm = async () => { const importXterm = async () => {
const [xterm, fit, canvas, clipboard, search, links] = await Promise.all([ const [xterm, fit, webgl, clipboard, search, links] = await Promise.all([
import("@xterm/xterm"), import("@xterm/xterm"),
import("@xterm/addon-fit"), import("@xterm/addon-fit"),
import("@xterm/addon-canvas"), import("@xterm/addon-webgl"),
import("@xterm/addon-clipboard"), import("@xterm/addon-clipboard"),
import("@xterm/addon-search"), import("@xterm/addon-search"),
import("@xterm/addon-web-links"), import("@xterm/addon-web-links"),
@@ -128,7 +136,7 @@ const importXterm = async () => {
return { return {
Terminal: xterm.Terminal, Terminal: xterm.Terminal,
FitAddon: fit.FitAddon, FitAddon: fit.FitAddon,
CanvasAddon: canvas.CanvasAddon, WebglAddon: webgl.WebglAddon,
SearchAddon: search.SearchAddon, SearchAddon: search.SearchAddon,
WebLinksAddon: links.WebLinksAddon, WebLinksAddon: links.WebLinksAddon,
// The addon's typings declare one argument, its code takes (base64, provider). // The addon's typings declare one argument, its code takes (base64, provider).
@@ -152,6 +160,25 @@ export function loadXterm(): Promise<Xterm> {
return xtermLoading; return xtermLoading;
} }
const IS_MAC = /^Mac/.test(typeof navigator === "undefined" ? "" : navigator.platform);
/** What xterm 5 sent for a bare Alt+arrow (its Keyboard.ts "HACK"s, removed
* in xterm 6): Ctrl+arrow's sequence, except macOS's left/right, which are
* readline's backward-word / forward-word and whose up/down stay Alt's. */
export function altArrowSeq(key: string, mac = IS_MAC): string | null {
switch (key) {
case "ArrowLeft":
return mac ? "\x1bb" : "\x1b[1;5D";
case "ArrowRight":
return mac ? "\x1bf" : "\x1b[1;5C";
case "ArrowUp":
return mac ? null : "\x1b[1;5A";
case "ArrowDown":
return mac ? null : "\x1b[1;5B";
default:
return null;
}
}
/** Reconnect delay: 1.5 s, doubling per failed attempt up to 30 s. */ /** Reconnect delay: 1.5 s, doubling per failed attempt up to 30 s. */
const RETRY_MIN_MS = 1500; const RETRY_MIN_MS = 1500;
const RETRY_MAX_MS = 30_000; const RETRY_MAX_MS = 30_000;
@@ -245,6 +272,11 @@ export const TerminalView = memo(function TerminalView({
// Ctrl+V was pressed and its paste has not arrived yet (see the key handler). // Ctrl+V was pressed and its paste has not arrived yet (see the key handler).
const ctrlVRef = useRef(false); const ctrlVRef = useRef(false);
const { local: localMode } = usePermissions(); const { local: localMode } = usePermissions();
// Ask early whether `^V` can reach this machine's clipboard, so the first
// pasted screenshot already takes the right way.
useEffect(() => {
if (tool === "claude" && localMode && isLocalBrowser()) nativePasteWorks();
}, [tool, localMode]);
const onDrop = (e: DragEvent) => { const onDrop = (e: DragEvent) => {
if (hasText(e)) { if (hasText(e)) {
@@ -262,7 +294,7 @@ export const TerminalView = memo(function TerminalView({
e.preventDefault(); e.preventDefault();
dragDepth.current = 0; dragDepth.current = 0;
const files = Array.from(e.dataTransfer.files); const files = Array.from(e.dataTransfer.files);
void attachFiles(files, localCandidates(e.dataTransfer, files)); void attachFiles(files, localCandidates(e.dataTransfer, files), "drop");
}; };
// A pasted image (a screenshot) goes the way of a drop: xterm only reads // A pasted image (a screenshot) goes the way of a drop: xterm only reads
@@ -283,6 +315,9 @@ export const TerminalView = memo(function TerminalView({
return; return;
} }
if (files.length === 0 && (!ctrlV || e.clipboardData?.getData("text/plain"))) return; if (files.length === 0 && (!ctrlV || e.clipboardData?.getData("text/plain"))) return;
// Cells from a spreadsheet, a page's selection: text WITH a picture of
// it. A terminal pastes the text there, so does this.
if (files.every(isClipboardBitmap) && e.clipboardData?.getData("text/plain")?.trim()) return;
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
// On the dashboard's own machine the browser's clipboard IS the pane's: // On the dashboard's own machine the browser's clipboard IS the pane's:
@@ -293,13 +328,14 @@ export const TerminalView = memo(function TerminalView({
localMode && localMode &&
isLocalBrowser() && isLocalBrowser() &&
readNativePaste() && readNativePaste() &&
nativePasteWorks() &&
files.length > 0 && files.length > 0 &&
files.every((f) => f.type.startsWith("image/")); files.every(isClipboardBitmap);
if (nativeImage) { if (nativeImage) {
termRef.current?.input("\x16", true); termRef.current?.input(nativePasteKey(), true);
return; return;
} }
if (files.length > 0) void attachFiles(files, localCandidates(e.clipboardData, files)); if (files.length > 0) void attachFiles(files, localCandidates(e.clipboardData, files), "paste");
else termRef.current?.input("\x16", true); else termRef.current?.input("\x16", true);
}; };
@@ -319,11 +355,15 @@ export const TerminalView = memo(function TerminalView({
/** Pastes the files' paths: their own (`local`, the `file://` paths a /** Pastes the files' paths: their own (`local`, the `file://` paths a
* browser on the dashboard's machine is handed beside the bytes) once the * browser on the dashboard's machine is handed beside the bytes) once the
* server finds the same files there — nothing uploaded —, else the paths * server finds the same files there — nothing uploaded —, else the paths
* the uploads were saved at. A refused file does not cost the others. */ * that machine's file manager holds them under (a paste: its clipboard; a
const attachFiles = async (files: File[], local: string[] | null = null) => { * drop: its selection, what the drag carries — no upload, no size limit,
* as a native terminal), else the paths the uploads were saved at. A
* refused file does not cost the others. */
const attachFiles = async (files: File[], local: string[] | null, from: "paste" | "drop") => {
if (files.length === 0) return; if (files.length === 0) return;
setDrop("uploading"); setDrop("uploading");
let paths = local && (await sameFilesHere(local, files)) ? local : null; let paths = local && (await sameFilesHere(local, files)) ? local : null;
paths ??= await fileManagerPaths(files, from);
const errors: unknown[] = []; const errors: unknown[] = [];
if (!paths) { if (!paths) {
const results = await Promise.allSettled(files.map((f) => api.run.dropFile(f, runId))); const results = await Promise.allSettled(files.map((f) => api.run.dropFile(f, runId)));
@@ -341,7 +381,8 @@ export const TerminalView = memo(function TerminalView({
if (tooBig) setLimitMb(Math.round((tooBig.maxBytes ?? 0) / 1024 / 1024)); if (tooBig) setLimitMb(Math.round((tooBig.maxBytes ?? 0) / 1024 / 1024));
if (!errors.length) setDrop(null); if (!errors.length) setDrop(null);
else if (!tooBig) flash("failed"); else if (!tooBig) flash("failed");
else flash(isLocalBrowser() ? "tooBigLocal" : "tooBig", 6000); // a sentence to read // A terminal has no + menu: on this machine, a paste goes by path.
else flash(isLocalBrowser() ? "tooBigLocalPaste" : "tooBig", 6000); // a sentence to read
}; };
useEffect(() => { useEffect(() => {
@@ -349,7 +390,7 @@ export const TerminalView = memo(function TerminalView({
const attach = ({ const attach = ({
Terminal, Terminal,
FitAddon, FitAddon,
CanvasAddon, WebglAddon,
SearchAddon, SearchAddon,
WebLinksAddon, WebLinksAddon,
ClipboardWithProvider, ClipboardWithProvider,
@@ -359,7 +400,7 @@ export const TerminalView = memo(function TerminalView({
// which on Windows lacks Vietnamese precomposed diacritics — the OS then // which on Windows lacks Vietnamese precomposed diacritics — the OS then
// substitutes those specific glyphs from a different face with a // substitutes those specific glyphs from a different face with a
// mismatched advance width, corrupting the cell it's drawn into even // mismatched advance width, corrupting the cell it's drawn into even
// under the canvas renderer (which only stops a glyph from shoving its // under the WebGL renderer (which only stops a glyph from shoving its
// *neighbours*, not from overflowing its own cell). JetBrains Mono is // *neighbours*, not from overflowing its own cell). JetBrains Mono is
// already bundled for the rest of the UI; main.tsx additionally imports // already bundled for the rest of the UI; main.tsx additionally imports
// its `vietnamese` subset so this covers the diacritics too. Settings → // its `vietnamese` subset so this covers the diacritics too. Settings →
@@ -405,17 +446,30 @@ export const TerminalView = memo(function TerminalView({
// face whose advance width differs, so glyphs shove their neighbours along // face whose advance width differs, so glyphs shove their neighbours along
// and the row's tail spills onto the row below. The buffer is correct // and the row's tail spills onto the row below. The buffer is correct
// throughout (copying the line yields clean text); only the painting drifts, // throughout (copying the line yields clean text); only the painting drifts,
// and no CSS can correct it. Best-effort: a browser that cannot give a 2D // and no CSS can correct it. xterm 6 dropped its canvas renderer, so the
// context keeps the DOM renderer rather than losing the terminal. // cells are drawn by the WebGL one. Best-effort: a browser with no WebGL2
let canvas: CanvasAddon | null = null; // keeps the DOM renderer rather than losing the terminal, and so does a
// pane whose context the browser takes back (GPU reset, or past the
// browser's cap of live contexts with many panes open) — disposing the
// addon hands the drawing back to the DOM renderer.
let gpu: WebglAddon | null = null;
try { try {
canvas = new CanvasAddon(); const addon = new WebglAddon();
term.loadAddon(canvas); term.loadAddon(addon);
gpu = addon;
addon.onContextLoss(() => {
addon.dispose();
if (gpu === addon) gpu = null;
});
} catch { } catch {
canvas = null; gpu = null;
} }
fit.fit(); // A terminal mounted on a hidden tab sits in a 0×0 box (`display:none`):
// fitting it there shrinks xterm to its 12×6 minimum, and that size would
// reach tmux with the attach. It stays unfitted until it is shown.
const hasBox = () => !!containerRef.current?.clientWidth && !!containerRef.current.clientHeight;
if (hasBox()) fit.fit();
// Take the keyboard as soon as the pane is on screen: the terminal IS the // Take the keyboard as soon as the pane is on screen: the terminal IS the
// page here, and having to click it first meant the first thing typed went // page here, and having to click it first meant the first thing typed went
// nowhere. A click elsewhere still moves focus away, as in any editor. // nowhere. A click elsewhere still moves focus away, as in any editor.
@@ -502,6 +556,17 @@ export const TerminalView = memo(function TerminalView({
term.input("\x1f", true); term.input("\x1f", true);
return false; return false;
} }
// Alt+arrow moves by word, as it did before xterm 6 dropped its own
// mapping (xterm.js #5346): Ctrl+arrow's sequence, or readline's
// ESC b / ESC f for left/right on macOS — the same keys tmux,
// bash and claude's prompt have always been handed from this pane.
const altArrow =
ev.altKey && !ev.ctrlKey && !ev.metaKey && !ev.shiftKey ? altArrowSeq(ev.key) : null;
if (altArrow) {
ev.preventDefault();
term.input(altArrow, true);
return false;
}
if (ev.key === "Tab" && tabMovesRef.current) return false; if (ev.key === "Tab" && tabMovesRef.current) return false;
if (ev.key === "Tab" && ev.shiftKey) ev.preventDefault(); if (ev.key === "Tab" && ev.shiftKey) ev.preventDefault();
return true; return true;
@@ -544,7 +609,7 @@ export const TerminalView = memo(function TerminalView({
*/ */
const sendSizeNow = (drive = false) => { const sendSizeNow = (drive = false) => {
if (!ws || ws.readyState !== WebSocket.OPEN) return; if (!ws || ws.readyState !== WebSocket.OPEN) return;
if (term.cols < 2 || term.rows < 2) return; if (!hasBox() || term.cols < 2 || term.rows < 2) return;
ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows, drive })); ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows, drive }));
}; };
// This browser is the one in use now: back to its own size, and the // This browser is the one in use now: back to its own size, and the
@@ -581,7 +646,10 @@ export const TerminalView = memo(function TerminalView({
// scrollback as torn fragments along the left margin, visible as soon as // scrollback as torn fragments along the left margin, visible as soon as
// you scroll back (issue #33). Happens on every attach: page load, // you scroll back (issue #33). Happens on every attach: page load,
// lane switch, and each reconnect. // lane switch, and each reconnect.
const size = term.cols > 1 && term.rows > 1 ? `?cols=${term.cols}&rows=${term.rows}` : ""; // A hidden terminal sends none: the server attaches it at the window's
// own size, so a pane on a background tab is never resized by it.
const size =
hasBox() && term.cols > 1 && term.rows > 1 ? `?cols=${term.cols}&rows=${term.rows}` : "";
const sock = new WebSocket(`${wsBaseUrl}/ws-pty/${encodeURIComponent(runId)}${size}`); const sock = new WebSocket(`${wsBaseUrl}/ws-pty/${encodeURIComponent(runId)}${size}`);
ws = sock; ws = sock;
wsRef.current = sock; wsRef.current = sock;
@@ -676,6 +744,9 @@ export const TerminalView = memo(function TerminalView({
if (!el || !el.clientWidth || !el.clientHeight) return; if (!el || !el.clientWidth || !el.clientHeight) return;
if (follow) return scaleToFit(); // the driver's grid, not ours if (follow) return scaleToFit(); // the driver's grid, not ours
fit.fit(); // a real change fires onResize, which reports it fit.fit(); // a real change fires onResize, which reports it
// A tab shown for the first time may fit to the very size xterm already
// had (no onResize), yet tmux has never heard it: report it anyway.
sendSize();
}; };
const resizeObserver = new ResizeObserver(refit); const resizeObserver = new ResizeObserver(refit);
if (containerRef.current) resizeObserver.observe(containerRef.current); if (containerRef.current) resizeObserver.observe(containerRef.current);
@@ -714,7 +785,7 @@ export const TerminalView = memo(function TerminalView({
resizeDisposable.dispose(); resizeDisposable.dispose();
dataDisposable.dispose(); dataDisposable.dispose();
ws?.close(); ws?.close();
canvas?.dispose(); gpu?.dispose();
term.dispose(); term.dispose();
termRef.current = null; termRef.current = null;
}; };
@@ -959,7 +1030,7 @@ export const TerminalView = memo(function TerminalView({
<div <div
data-testid="terminal-drop" data-testid="terminal-drop"
className={`pointer-events-none absolute inset-2 flex items-center justify-center rounded-lg border-2 border-dashed bg-black/50 text-sm font-medium ${ className={`pointer-events-none absolute inset-2 flex items-center justify-center rounded-lg border-2 border-dashed bg-black/50 text-sm font-medium ${
drop === "failed" || drop === "tooBig" || drop === "tooBigLocal" drop === "failed" || drop === "tooBig" || drop === "tooBigLocalPaste"
? "border-red-400 text-red-300" ? "border-red-400 text-red-300"
: "border-sky-400 text-sky-200" : "border-sky-400 text-sky-200"
}`} }`}
@@ -60,6 +60,8 @@ vi.mock("../../../lib/api", async (importOriginal) => {
files: r({ items: [] }), files: r({ items: [] }),
dropFile: r({ path: "/tmp/agenthub-drops-1/a.png" }), dropFile: r({ path: "/tmp/agenthub-drops-1/a.png" }),
localPaths: r({ sizes: [1] }), localPaths: r({ sizes: [1] }),
clipboardFiles: r({ files: [] }),
selectedFiles: r({ files: [] }),
}, },
ccConfig: { ccConfig: {
commands: r({ commands: r({
@@ -377,6 +379,35 @@ describe("PromptEditor — newlines and images", () => {
expect(screen.getByTestId("sent").textContent).toBe("read @/home/u/demo.mp4"); expect(screen.getByTestId("sent").textContent).toBe("read @/home/u/demo.mp4");
}); });
// Chrome on Windows pastes a file manager's copy as bytes only: the path
// comes off the OS clipboard, so a file over the upload limit attaches too.
it("attaches a pasted file manager copy by the path the OS clipboard holds", async () => {
vi.mocked(api.run.clipboardFiles).mockResolvedValueOnce({
files: [{ path: "C:\\Users\\u\\big.iso", size: 3_000_000_000, dir: false }],
});
vi.mocked(api.run.dropFile).mockClear();
const onImagesChange = vi.fn();
render(
<PromptEditor
value=""
onChange={() => {}}
onSubmit={() => {}}
placeholder="Ask anything…"
slash={false}
fileCwd="/home/u"
images={[]}
onImagesChange={onImagesChange}
/>
);
const iso = new File(["x"], "big.iso");
Object.defineProperty(iso, "size", { value: 3_000_000_000 });
fireEvent.paste(box(), { clipboardData: { files: [iso], getData: () => "" } });
await vi.waitFor(() => expect(onImagesChange).toHaveBeenCalled());
const update = onImagesChange.mock.calls[0]![0] as (p: unknown[]) => { path: string }[];
expect(update([]).map((a) => a.path)).toEqual(["@C:/Users/u/big.iso"]);
expect(api.run.dropFile).not.toHaveBeenCalled();
});
it("uploads a dropped file whose path the server does not have (an SSH tunnel's laptop)", async () => { it("uploads a dropped file whose path the server does not have (an SSH tunnel's laptop)", async () => {
vi.mocked(api.run.localPaths).mockResolvedValueOnce({ sizes: [null] }); vi.mocked(api.run.localPaths).mockResolvedValueOnce({ sizes: [null] });
const onImagesChange = vi.fn(); const onImagesChange = vi.fn();
@@ -6,13 +6,24 @@
* included) as outgoing binary frames, and * included) as outgoing binary frames, and
* re-attaches after the socket drops (server restart) with backoff, but not * re-attaches after the socket drops (server restart) with backoff, but not
* after `exit` or a policy close, * after `exit` or a policy close,
* and recolours live on a dark-theme change without re-attaching. * recolours live on a dark-theme change without re-attaching, draws through
* the WebGL addon (DOM renderer again on a lost context) and keeps xterm 5's
* Alt+arrow word moves.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest"; import { describe, it, expect, vi, beforeAll, beforeEach, afterEach, onTestFinished } from "vitest";
import { render, cleanup, act, fireEvent, screen, waitFor } from "@testing-library/react"; import {
import { TerminalView, loadXterm } from "../TerminalView"; render,
cleanup,
act,
createEvent,
fireEvent,
screen,
waitFor,
} from "@testing-library/react";
import { TerminalView, altArrowSeq, loadXterm } from "../TerminalView";
import { pasteToLane, pasteToRun } from "../../../lib/panePaste"; import { pasteToLane, pasteToRun } from "../../../lib/panePaste";
import { __resetNativePaste, nativePasteWorks } from "../../../lib/nativePaste";
const writeMock = vi.fn(); const writeMock = vi.fn();
const onDataHandlers: Array<(d: string) => void> = []; const onDataHandlers: Array<(d: string) => void> = [];
@@ -27,11 +38,18 @@ const inputMock = vi.fn();
let textarea: HTMLTextAreaElement; let textarea: HTMLTextAreaElement;
const dropFileMock = vi.fn(); const dropFileMock = vi.fn();
const localPathsMock = vi.fn(); const localPathsMock = vi.fn();
const nativePasteMock = vi.fn(() => Promise.resolve({ native: true }));
type Held = { files: { path: string; size: number | null; dir: boolean }[] };
const clipboardFilesMock = vi.fn((): Promise<Held> => Promise.resolve({ files: [] }));
const selectedFilesMock = vi.fn((): Promise<Held> => Promise.resolve({ files: [] }));
vi.mock("../../../lib/api", () => ({ vi.mock("../../../lib/api", () => ({
api: { api: {
run: { run: {
dropFile: (f: File, lane?: string) => dropFileMock(f, lane), dropFile: (f: File, lane?: string) => dropFileMock(f, lane),
localPaths: (paths: string[]) => localPathsMock(paths), localPaths: (paths: string[]) => localPathsMock(paths),
nativePaste: () => nativePasteMock(),
clipboardFiles: () => clipboardFilesMock(),
selectedFiles: () => selectedFilesMock(),
}, },
}, },
})); }));
@@ -93,8 +111,24 @@ vi.mock("../../../lib/terminalTheme", () => ({
vi.mock("@xterm/addon-fit", () => ({ vi.mock("@xterm/addon-fit", () => ({
FitAddon: vi.fn().mockImplementation(() => ({ fit: vi.fn() })), FitAddon: vi.fn().mockImplementation(() => ({ fit: vi.fn() })),
})); }));
vi.mock("@xterm/addon-canvas", () => ({ // The newest WebGL addon, with the context-loss listener the view gave it.
CanvasAddon: vi.fn().mockImplementation(() => ({ __canvas: true, dispose: vi.fn() })), const gpu: { last: { dispose: ReturnType<typeof vi.fn>; lose: () => void } | null } = {
last: null,
};
vi.mock("@xterm/addon-webgl", () => ({
WebglAddon: vi.fn().mockImplementation(() => {
let onLoss = () => {};
const addon = {
__webgl: true,
dispose: vi.fn(),
onContextLoss: (fn: () => void) => {
onLoss = fn;
return { dispose: vi.fn() };
},
};
gpu.last = { dispose: addon.dispose, lose: () => onLoss() };
return addon;
}),
})); }));
class MockWebSocket { class MockWebSocket {
@@ -120,6 +154,17 @@ class MockWebSocket {
// @ts-expect-error test override // @ts-expect-error test override
global.WebSocket = MockWebSocket; global.WebSocket = MockWebSocket;
/** Give every element a real box (jsdom's are 0×0, like a terminal on a hidden
* tab); returns the restore. */
function withBox(width = 800, height = 600) {
const w = vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockReturnValue(width);
const h = vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(height);
return () => {
w.mockRestore();
h.mockRestore();
};
}
describe("TerminalView", () => { describe("TerminalView", () => {
// xterm is a lazy chunk; once loaded every terminal sets up synchronously. // xterm is a lazy chunk; once loaded every terminal sets up synchronously.
beforeAll(() => loadXterm()); beforeAll(() => loadXterm());
@@ -151,11 +196,51 @@ describe("TerminalView", () => {
// it: tmux resizes the window to the newest client, so attaching at the // it: tmux resizes the window to the newest client, so attaching at the
// server's default 80x24 makes claude redraw its UI at 80 columns and // server's default 80x24 makes claude redraw its UI at 80 columns and
// leaves that width in the scrollback (issue #33). // leaves that width in the scrollback (issue #33).
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />); const restore = withBox();
expect(MockWebSocket.instances).toHaveLength(1); try {
expect(MockWebSocket.instances[0]!.url).toBe( render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
"ws://localhost:4820/ws-pty/agenthub-lane-1?cols=131&rows=28" expect(MockWebSocket.instances).toHaveLength(1);
); expect(MockWebSocket.instances[0]!.url).toBe(
"ws://localhost:4820/ws-pty/agenthub-lane-1?cols=131&rows=28"
);
} finally {
restore();
}
});
it("a terminal on a hidden tab sends no size until it is shown", () => {
// Its box is 0×0 (display:none): fitting there shrank xterm to 12×6 and
// that size resized the real tmux pane under every other client.
let observed: (() => void) | null = null;
const RO = globalThis.ResizeObserver;
// @ts-expect-error test override
globalThis.ResizeObserver = class {
constructor(cb: () => void) {
observed = cb;
}
observe() {}
disconnect() {}
};
vi.useFakeTimers();
let restore = () => {};
try {
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
const ws = MockWebSocket.instances[0]!;
expect(ws.url).toBe("ws://localhost:4820/ws-pty/agenthub-lane-1");
ws.onopen?.();
expect(ws.sent).toEqual([]);
// The tab is shown: the box gets a size and its size reaches tmux.
restore = withBox();
observed!();
vi.advanceTimersByTime(200);
expect(ws.sent).toEqual([
JSON.stringify({ type: "resize", cols: 131, rows: 28, drive: false }),
]);
} finally {
restore();
vi.useRealTimers();
globalThis.ResizeObserver = RO;
}
}); });
it("omits the size when the terminal has none yet (a collapsed console is 0x0)", () => { it("omits the size when the terminal has none yet (a collapsed console is 0x0)", () => {
@@ -381,34 +466,75 @@ describe("TerminalView", () => {
expect(onExit).toHaveBeenCalledTimes(1); expect(onExit).toHaveBeenCalledTimes(1);
}); });
it("renders through the canvas addon, not xterm's DOM renderer", () => { it("renders through the WebGL addon, not xterm's DOM renderer", () => {
// The DOM renderer aligns a row with one `letter-spacing`, which holds only // The DOM renderer aligns a row with one `letter-spacing`, which holds only
// while every glyph shares a font. Vietnamese diacritics and CJK fall to a // while every glyph shares a font. Vietnamese diacritics and CJK fall to a
// fallback face with a different advance, so glyphs shove their neighbours // fallback face with a different advance, so glyphs shove their neighbours
// and the row's tail spills onto the row below — buffer clean, painting // and the row's tail spills onto the row below — buffer clean, painting
// wrong. The canvas renderer draws each cell at a fixed position instead. // wrong. The WebGL renderer draws each cell at a fixed position instead
// (xterm 6 removed the canvas one).
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />); render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
expect(loadAddonMock.mock.calls.some(([a]) => (a as { __canvas?: boolean }).__canvas)).toBe( expect(loadAddonMock.mock.calls.some(([a]) => (a as { __webgl?: boolean }).__webgl)).toBe(
true true
); );
}); });
it("falls back to the DOM renderer when the browser takes the WebGL context back", () => {
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
const addon = gpu.last!;
expect(addon.dispose).not.toHaveBeenCalled();
addon.lose();
expect(addon.dispose).toHaveBeenCalledTimes(1);
expect(disposeMock).not.toHaveBeenCalled(); // the terminal itself lives on
});
// xterm 6 dropped its own Alt+arrow → word-move mapping; the pane keeps it.
it("sends Alt+arrow as the word-move keys xterm 5 sent", () => {
expect(altArrowSeq("ArrowLeft", false)).toBe("\x1b[1;5D");
expect(altArrowSeq("ArrowRight", false)).toBe("\x1b[1;5C");
expect(altArrowSeq("ArrowUp", false)).toBe("\x1b[1;5A");
expect(altArrowSeq("ArrowDown", false)).toBe("\x1b[1;5B");
expect(altArrowSeq("ArrowLeft", true)).toBe("\x1bb");
expect(altArrowSeq("ArrowRight", true)).toBe("\x1bf");
expect(altArrowSeq("ArrowUp", true)).toBeNull();
expect(altArrowSeq("a", false)).toBeNull();
inputMock.mockClear();
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
const handler = keyHandlerMock.mock.calls[0]![0]! as (e: KeyboardEvent) => boolean;
const ev = new KeyboardEvent("keydown", { key: "ArrowLeft", altKey: true, cancelable: true });
expect(handler(ev)).toBe(false);
expect(ev.defaultPrevented).toBe(true);
expect(inputMock).toHaveBeenCalledWith(altArrowSeq("ArrowLeft"), true);
// Another modifier with it is xterm's own sequence.
expect(
handler(new KeyboardEvent("keydown", { key: "ArrowLeft", altKey: true, shiftKey: true }))
).toBe(true);
});
describe("size reporting (tmux must never be wider than the terminal)", () => { describe("size reporting (tmux must never be wider than the terminal)", () => {
// A tmux pane wider than xterm wraps each over-long row's tail onto the // A tmux pane wider than xterm wraps each over-long row's tail onto the
// next row's left margin, and tmux never repaints a row it believes // next row's left margin, and tmux never repaints a row it believes
// unchanged — the stray characters glued to the left edge after scrolling. // unchanged — the stray characters glued to the left edge after scrolling.
it("reports its size when the socket opens", () => { it("reports its size when the socket opens", () => {
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />); const restore = withBox();
const ws = MockWebSocket.instances[0]!; try {
ws.onopen?.(); render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
expect(ws.sent).toContain( const ws = MockWebSocket.instances[0]!;
JSON.stringify({ type: "resize", cols: 131, rows: 28, drive: false }) ws.onopen?.();
); expect(ws.sent).toContain(
JSON.stringify({ type: "resize", cols: 131, rows: 28, drive: false })
);
} finally {
restore();
}
}); });
it("reports a size change xterm made on its own, with no container resize", async () => { it("reports a size change xterm made on its own, with no container resize", async () => {
// Webfont load and page zoom both move cols without resizing the element // Webfont load and page zoom both move cols without resizing the element
// ResizeObserver watches, which is why onResize is the hook. // ResizeObserver watches, which is why onResize is the hook.
const restore = withBox();
onTestFinished(restore);
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />); render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
const ws = MockWebSocket.instances[0]!; const ws = MockWebSocket.instances[0]!;
ws.onopen?.(); ws.onopen?.();
@@ -590,11 +716,13 @@ describe("TerminalView", () => {
expect(dropFileMock).toHaveBeenCalledWith(img, "r1"); expect(dropFileMock).toHaveBeenCalledWith(img, "r1");
}); });
it("hands a local claude pane's pasted image to claude as ^V, uploading nothing", () => { it("hands a local claude pane's pasted image to claude as ^V, uploading nothing", async () => {
__resetNativePaste();
dropFileMock.mockReset(); dropFileMock.mockReset();
inputMock.mockClear(); inputMock.mockClear();
pasteMock.mockClear(); pasteMock.mockClear();
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />); render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
await waitFor(() => expect(nativePasteWorks()).toBe(true));
const img = new File([new Uint8Array([137, 80])], "image.png", { type: "image/png" }); const img = new File([new Uint8Array([137, 80])], "image.png", { type: "image/png" });
fireEvent.paste(screen.getByTestId("terminal-view"), { fireEvent.paste(screen.getByTestId("terminal-view"), {
clipboardData: { files: [img], getData: () => "" }, clipboardData: { files: [img], getData: () => "" },
@@ -604,6 +732,41 @@ describe("TerminalView", () => {
expect(pasteMock).not.toHaveBeenCalled(); expect(pasteMock).not.toHaveBeenCalled();
}); });
// Claude Code on Windows binds its image paste to Alt+V; a ^V did nothing.
it("sends Alt+V instead of ^V when the dashboard runs on Windows", async () => {
__resetNativePaste();
nativePasteMock.mockResolvedValueOnce({ native: true, key: "alt+v" } as never);
dropFileMock.mockReset();
inputMock.mockClear();
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
await waitFor(() => expect(nativePasteWorks()).toBe(true));
const img = new File([new Uint8Array([137, 80])], "image.png", { type: "image/png" });
fireEvent.paste(screen.getByTestId("terminal-view"), {
clipboardData: { files: [img], getData: () => "" },
});
expect(inputMock).toHaveBeenCalledWith("\x1bv", true);
expect(inputMock).not.toHaveBeenCalledWith("\x16", true);
expect(dropFileMock).not.toHaveBeenCalled();
});
// No wl-paste/xclip (or no display) on the dashboard's machine: ^V would
// paste nothing, so the screenshot is uploaded like a remote browser's.
it("uploads a claude pane's pasted image when the machine cannot read its clipboard", async () => {
__resetNativePaste();
nativePasteMock.mockResolvedValueOnce({ native: false });
dropFileMock.mockReset().mockResolvedValue({ path: "/tmp/agenthub-drops-1/ab-image.png" });
inputMock.mockClear();
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
await waitFor(() => expect(nativePasteMock).toHaveBeenCalled());
await Promise.resolve();
const img = new File([new Uint8Array([137, 80])], "image.png", { type: "image/png" });
fireEvent.paste(screen.getByTestId("terminal-view"), {
clipboardData: { files: [img], getData: () => "" },
});
await waitFor(() => expect(dropFileMock).toHaveBeenCalledWith(img, "r1"));
expect(inputMock).not.toHaveBeenCalledWith("\x16", true);
});
it("uploads a claude pane's pasted image once native paste is turned off", async () => { it("uploads a claude pane's pasted image once native paste is turned off", async () => {
localStorage.setItem("agenthub.terminal.nativePaste", "off"); localStorage.setItem("agenthub.terminal.nativePaste", "off");
dropFileMock.mockReset().mockResolvedValue({ path: "/tmp/agenthub-drops-1/ab-image.png" }); dropFileMock.mockReset().mockResolvedValue({ path: "/tmp/agenthub-drops-1/ab-image.png" });
@@ -621,6 +784,100 @@ describe("TerminalView", () => {
} }
}); });
// A file manager's Copy reaches Chrome on Windows as bytes and a name only:
// the pane gets the file's own path off the OS clipboard — no upload, so
// no size limit (a 3 GB video too), as a native terminal pastes it.
it("pastes a copied file by the path the OS clipboard holds, uploading nothing", async () => {
__resetNativePaste();
dropFileMock.mockReset();
pasteMock.mockClear();
inputMock.mockClear();
clipboardFilesMock.mockResolvedValueOnce({
files: [{ path: "C:\\Users\\u\\Videos\\demo.mp4", size: 3_000_000_000, dir: false }],
});
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
await waitFor(() => expect(nativePasteWorks()).toBe(true));
const video = new File([new Uint8Array([1])], "demo.mp4", { type: "video/mp4" });
Object.defineProperty(video, "size", { value: 3_000_000_000 });
fireEvent.paste(screen.getByTestId("terminal-view"), {
clipboardData: { files: [video], getData: () => "" },
});
await waitFor(() => expect(pasteMock).toHaveBeenCalledWith("@C:/Users/u/Videos/demo.mp4 "));
expect(dropFileMock).not.toHaveBeenCalled();
expect(inputMock).not.toHaveBeenCalledWith("\x16", true);
});
// Chrome hands a file dragged from Explorer no path; the drag carries the
// items selected there, so the pane gets their own paths — as a native
// terminal types them — and nothing is uploaded, whatever the size.
it("drops a file manager's file by the path selected there, uploading nothing", async () => {
dropFileMock.mockReset();
pasteMock.mockClear();
selectedFilesMock.mockResolvedValueOnce({
files: [{ path: "C:\\Users\\u\\Videos\\big.mkv", size: 5_000_000_000, dir: false }],
});
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
const video = new File([new Uint8Array([1])], "big.mkv");
Object.defineProperty(video, "size", { value: 5_000_000_000 });
fireEvent.drop(screen.getByTestId("terminal-view"), {
dataTransfer: { files: [video], types: ["Files"], getData: () => "" },
});
await waitFor(() => expect(pasteMock).toHaveBeenCalledWith("@C:/Users/u/Videos/big.mkv "));
expect(dropFileMock).not.toHaveBeenCalled();
});
it("uploads a dropped file the file manager does not hold selected", async () => {
dropFileMock.mockReset().mockResolvedValue({ path: "/tmp/agenthub-drops-1/ab-notes.txt" });
selectedFilesMock.mockResolvedValueOnce({
files: [{ path: "/home/u/other.txt", size: 1, dir: false }],
});
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="codex" />);
const notes = new File(["x"], "notes.txt");
fireEvent.drop(screen.getByTestId("terminal-view"), {
dataTransfer: { files: [notes], types: ["Files"], getData: () => "" },
});
await waitFor(() => expect(dropFileMock).toHaveBeenCalledWith(notes, "r1"));
});
// A copied image FILE is not a screenshot: claude's own paste finds no
// picture on a clipboard holding a file, so it goes by path too.
it("pastes a copied image file by path instead of claude's image paste", async () => {
__resetNativePaste();
dropFileMock.mockReset();
pasteMock.mockClear();
inputMock.mockClear();
clipboardFilesMock.mockResolvedValueOnce({
files: [{ path: "/home/u/Pictures/logo.png", size: 2, dir: false }],
});
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
await waitFor(() => expect(nativePasteWorks()).toBe(true));
const logo = new File([new Uint8Array([137, 80])], "logo.png", { type: "image/png" });
fireEvent.paste(screen.getByTestId("terminal-view"), {
clipboardData: { files: [logo], getData: () => "" },
});
await waitFor(() => expect(pasteMock).toHaveBeenCalledWith("/home/u/Pictures/logo.png "));
expect(inputMock).not.toHaveBeenCalledWith("\x16", true);
expect(dropFileMock).not.toHaveBeenCalled();
});
// Cells from a spreadsheet come with a picture of them; a terminal pastes
// the text, so the browser's paste is left to xterm.
it("leaves text that comes with a picture of it to xterm's paste", async () => {
__resetNativePaste();
dropFileMock.mockReset();
inputMock.mockClear();
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
await waitFor(() => expect(nativePasteWorks()).toBe(true));
const pic = new File([new Uint8Array([137, 80])], "image.png", { type: "image/png" });
const ev = createEvent.paste(screen.getByTestId("terminal-view"), {
clipboardData: { files: [pic], getData: (t: string) => (t === "text/plain" ? "a\tb" : "") },
});
fireEvent(screen.getByTestId("terminal-view"), ev);
expect(ev.defaultPrevented).toBe(false);
expect(inputMock).not.toHaveBeenCalled();
expect(dropFileMock).not.toHaveBeenCalled();
});
// Ctrl+V is how claude pastes an image; xterm would send ^V, and claude then // Ctrl+V is how claude pastes an image; xterm would send ^V, and claude then
// reads the SERVER's clipboard, not the browser's. Text on the clipboard is // reads the SERVER's clipboard, not the browser's. Text on the clipboard is
// pasted like any browser paste: sending ^V for it pasted nothing. // pasted like any browser paste: sending ^V for it pasted nothing.
@@ -79,14 +79,6 @@ function Step({ n, children }: { n: number; children: ReactNode }) {
); );
} }
function Code({ children }: { children: ReactNode }) {
return (
<code className="mt-1 block break-all rounded bg-surface-2 px-1.5 py-1 font-mono text-[10px] text-fg-primary">
{children}
</code>
);
}
type Key = NonNullable<GitIdentity["sshKeys"]>[number]; type Key = NonNullable<GitIdentity["sshKeys"]>[number];
type SshUser = NonNullable<GitIdentity["sshUsers"]>[number]; type SshUser = NonNullable<GitIdentity["sshUsers"]>[number];
@@ -642,10 +634,6 @@ export function GitIdentitySettings() {
onRemove={() => removeKey("ed25519")} onRemove={() => removeKey("ed25519")}
> >
<Step n={2}>{t("gitIdentity.gitHubStepAdd")}</Step> <Step n={2}>{t("gitIdentity.gitHubStepAdd")}</Step>
<Step n={3}>
{t("gitIdentity.gitHubStepClone")}
<Code>git clone git@github.com:&lt;org&gt;/&lt;repo&gt;.git</Code>
</Step>
</KeyCard> </KeyCard>
{namedOf("ed25519").map((k) => { {namedOf("ed25519").map((k) => {
const rows = rowsOf(otherRows, k); const rows = rowsOf(otherRows, k);
@@ -668,11 +656,6 @@ export function GitIdentitySettings() {
{id.writable && rows.length === 0 && ( {id.writable && rows.length === 0 && (
<GitHostForm busy={busy} onSave={(host) => saveGitHost(host, k)} /> <GitHostForm busy={busy} onSave={(host) => saveGitHost(host, k)} />
)} )}
{rows.map((r) => (
<Code key={r.alias ?? r.host}>
git clone ssh://{r.alias ?? r.host}/&lt;org&gt;/&lt;repo&gt;.git
</Code>
))}
</Step> </Step>
</KeyCard> </KeyCard>
); );
@@ -720,16 +703,6 @@ export function GitIdentitySettings() {
</> </>
)} )}
</Step> </Step>
{rows.length > 0 && (
<div className="rounded border border-status-success/40 bg-status-success/10 p-2 text-[11px] text-fg-primary">
{t("gitIdentity.awsReady")}
{rows.map((r) => (
<Code key={r.alias ?? r.host}>
git clone ssh://{r.alias ?? r.host}/v1/repos/&lt;repo&gt;
</Code>
))}
</div>
)}
</KeyCard> </KeyCard>
); );
})} })}
@@ -0,0 +1,242 @@
/**
* @file NineRouterUpdate.tsx
* @description **Update 9router** on the AI Proxy page (an admin's; beside
* Config 9router): a button — dotted when npm has a newer 9router — and a
* dialog that runs the server's three-step job (`server/lib/nine-router-update.js`):
* disconnect and shut 9router down, `npm install -g 9router@latest`, start it
* again hidden (tray, no browser) — the last only when it was running before.
* The dialog shows why an update cannot run right now (the server's `blocker`,
* with the command to run by hand when sudo needs a password) and follows the
* job by polling until it ends. Closing the dialog does not stop the job.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { ArrowUpCircle, Check, Circle, Copy, Loader2, MinusCircle, X } from "lucide-react";
import { api, type NineRouterUpdateStatus, type NineRouterUpdateStep } from "../../lib/api";
import { copyText } from "../../lib/clipboard";
import { useDialog } from "../../hooks/useDialog";
const POLL_MS = 1000;
function StepIcon({ status }: { status: NineRouterUpdateStep["status"] }) {
if (status === "running") return <Loader2 className="h-3.5 w-3.5 animate-spin text-accent" />;
if (status === "done") return <Check className="h-3.5 w-3.5 text-status-success" />;
if (status === "failed") return <X className="h-3.5 w-3.5 text-status-danger" />;
if (status === "skipped") return <MinusCircle className="h-3.5 w-3.5 text-fg-muted" />;
return <Circle className="h-3.5 w-3.5 text-fg-muted" />;
}
export function NineRouterUpdate() {
const { t } = useTranslation("settings");
const [status, setStatus] = useState<NineRouterUpdateStatus | null>(null);
const [open, setOpen] = useState(false);
const [starting, setStarting] = useState(false);
const [error, setError] = useState<string | null>(null);
const dialogRef = useRef<HTMLDivElement>(null);
useDialog(open, dialogRef, () => setOpen(false));
const load = useCallback(async (fresh = false) => {
try {
setStatus(await api.aiProxies.nineRouterUpdateStatus(fresh));
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
}
}, []);
useEffect(() => {
void load();
}, [load]);
const running = status?.job?.status === "running";
// Follow a running job — also one started from another tab.
useEffect(() => {
if (!running) return;
const timer = window.setInterval(() => void load(), POLL_MS);
return () => window.clearInterval(timer);
}, [running, load]);
const openDialog = () => {
setError(null);
setOpen(true);
void load(true);
};
const start = async () => {
setStarting(true);
setError(null);
try {
const job = await api.aiProxies.nineRouterUpdate();
setStatus((s) => (s ? { ...s, job } : s));
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
void load(true);
} finally {
setStarting(false);
}
};
if (!status?.installed) return null;
const job = status.job;
const blocker = status.blocker;
return (
<>
<button
type="button"
onClick={openDialog}
className="relative inline-flex shrink-0 items-center gap-1.5 rounded-lg border border-border bg-surface-2 px-3 py-1.5 text-xs font-medium text-fg-secondary transition-colors hover:border-accent/30 hover:bg-accent/10 hover:text-accent"
>
{running ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<ArrowUpCircle className="h-3.5 w-3.5" />
)}
{t("aiProxy.update9router")}
{status.updateAvailable && !running && (
<span
className="absolute -right-1 -top-1 h-2 w-2 rounded-full bg-accent-solid"
aria-label={t("aiProxy.update9routerAvailable")}
/>
)}
</button>
{open && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-surface-0/70 p-4"
role="presentation"
>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="nine-router-update-title"
className="max-h-[90vh] w-full max-w-md overflow-y-auto rounded-xl border border-border bg-surface-1 p-5 shadow-xl"
>
<h3
id="nine-router-update-title"
className="mb-3 text-sm font-semibold text-fg-primary"
>
{t("aiProxy.update9router")}
</h3>
<dl className="mb-3 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs">
<dt className="text-fg-muted">{t("aiProxy.update9routerCurrent")}</dt>
<dd className="font-mono text-fg-primary">{status.version ?? "—"}</dd>
<dt className="text-fg-muted">{t("aiProxy.update9routerLatest")}</dt>
<dd className="font-mono text-fg-primary">{status.latest ?? "—"}</dd>
<dt className="text-fg-muted">{t("aiProxy.update9routerState")}</dt>
<dd className="text-fg-primary">
{status.running
? status.managedBy === "systemd"
? t("aiProxy.update9routerRunningUnit")
: t("aiProxy.update9routerRunning")
: t("aiProxy.update9routerStopped")}
</dd>
</dl>
<p className="mb-3 text-[11px] text-fg-muted">
{status.running
? t("aiProxy.update9routerExplain")
: t("aiProxy.update9routerExplainStopped")}
</p>
{job && (
<ol className="mb-3 space-y-1.5 rounded-md border border-border bg-surface-0 p-3">
{job.steps.map((s) => (
<li key={s.id} className="flex items-start gap-2 text-xs">
<span className="mt-0.5">
<StepIcon status={s.status} />
</span>
<span className="min-w-0">
<span className="text-fg-primary">
{t(`aiProxy.update9routerStep.${s.id}`)}
</span>
{s.message && (
<span className="block break-words text-[11px] text-fg-muted">
{s.message}
</span>
)}
</span>
</li>
))}
</ol>
)}
{job && job.status !== "running" && (
<p
className={`mb-3 text-xs ${
job.status === "done" ? "text-status-success" : "text-status-danger"
}`}
>
{job.status !== "done"
? t("aiProxy.update9routerFailed")
: job.steps.find((s) => s.id === "relaunch")?.status === "skipped"
? t("aiProxy.update9routerDoneStopped", { version: job.to ?? "—" })
: t("aiProxy.update9routerDone", { version: job.to ?? "—" })}
</p>
)}
{job?.log && (
<details className="mb-3">
<summary className="cursor-pointer text-[11px] text-fg-muted">
{t("aiProxy.update9routerLog")}
</summary>
<pre className="mt-1 max-h-48 overflow-auto whitespace-pre-wrap rounded-md bg-surface-0 p-2 font-mono text-[10px] text-fg-secondary">
{job.log}
</pre>
</details>
)}
{blocker && !running && blocker.code !== "EUPTODATE" && (
<div className="mb-3 rounded-md border border-status-warning/40 bg-status-warning/10 px-3 py-2 text-xs text-fg-secondary">
<p>{blocker.message}</p>
{blocker.command && (
<div className="mt-2 flex items-center gap-2">
<code className="min-w-0 flex-1 truncate rounded bg-surface-0 px-2 py-1 font-mono text-[11px]">
{blocker.command}
</code>
<button
type="button"
onClick={() => void copyText(blocker.command!)}
className="shrink-0 rounded p-1 text-fg-muted hover:text-accent"
aria-label={t("aiProxy.update9routerCopy")}
title={t("aiProxy.update9routerCopy")}
>
<Copy className="h-3.5 w-3.5" />
</button>
</div>
)}
</div>
)}
{blocker?.code === "EUPTODATE" && !running && (
<p className="mb-3 text-xs text-fg-muted">{t("aiProxy.update9routerUpToDate")}</p>
)}
{error && (
<p className="mb-3 rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-xs text-status-danger">
{error}
</p>
)}
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setOpen(false)}
className="rounded-lg border border-border px-3 py-1.5 text-xs text-fg-secondary hover:bg-surface-2"
>
{t("aiProxy.update9routerClose")}
</button>
<button
type="button"
onClick={() => void start()}
disabled={Boolean(blocker) || running || starting}
className="btn-primary inline-flex items-center gap-1.5 px-3 py-1.5 text-xs disabled:opacity-50"
>
{(running || starting) && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
{t("aiProxy.update9routerStart")}
</button>
</div>
</div>
</div>
)}
</>
);
}
@@ -156,7 +156,6 @@ describe("GitIdentitySettings", () => {
await userEvent.click(within(aws).getByRole("button", { name: "Save" })); await userEvent.click(within(aws).getByRole("button", { name: "Save" }));
expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({ host: H, user: "APKA1" }); expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({ host: H, user: "APKA1" });
expect(await within(aws).findByText("eu-west-1 · APKA1 · Any key")).toBeInTheDocument(); expect(await within(aws).findByText("eu-west-1 · APKA1 · Any key")).toBeInTheDocument();
expect(within(aws).getByText(`git clone ssh://${H}/v1/repos/<repo>`)).toBeInTheDocument();
// A CodeCommit host never shows up again under "other hosts". // A CodeCommit host never shows up again under "other hosts".
expect(screen.queryByText(`APKA1@${H}`)).toBeNull(); expect(screen.queryByText(`APKA1@${H}`)).toBeNull();
await userEvent.click(within(aws).getByRole("button", { name: `Delete ${H}` })); await userEvent.click(within(aws).getByRole("button", { name: `Delete ${H}` }));
@@ -260,7 +259,6 @@ describe("GitIdentitySettings", () => {
expect( expect(
await within(prod).findByText("aws-2 · ap-southeast-1 · APKA2 · aws-2") await within(prod).findByText("aws-2 · ap-southeast-1 · APKA2 · aws-2")
).toBeInTheDocument(); ).toBeInTheDocument();
expect(within(prod).getByText("git clone ssh://aws-2/v1/repos/<repo>")).toBeInTheDocument();
// The default key's card does not list the named key's login. // The default key's card does not list the named key's login.
const def = screen.getByRole("region", { name: "AWS CodeCommit" }); const def = screen.getByRole("region", { name: "AWS CodeCommit" });
expect(within(def).queryByText(/APKA2/)).toBeNull(); expect(within(def).queryByText(/APKA2/)).toBeNull();
@@ -301,7 +299,7 @@ describe("GitIdentitySettings", () => {
key: "id_ed25519_git-2", key: "id_ed25519_git-2",
}); });
expect( expect(
await within(work).findByText("git clone ssh://git-2/<org>/<repo>.git") await within(work).findByText("ssh://git-2/ → git@github.com")
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
}); });
@@ -0,0 +1,95 @@
/**
* @file Tests for **Update 9router** on the AI Proxy page: hidden without a
* 9router, a dialog that shows versions and starts the job, the server's
* blocker (with the manual command) disabling the start, and the job's steps.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { NineRouterUpdate } from "../NineRouterUpdate";
import { api, type NineRouterUpdateJob, type NineRouterUpdateStatus } from "../../../lib/api";
vi.mock("../../../lib/api", () => ({
api: { aiProxies: { nineRouterUpdateStatus: vi.fn(), nineRouterUpdate: vi.fn() } },
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
const statusMock = vi.mocked(api.aiProxies.nineRouterUpdateStatus);
const startMock = vi.mocked(api.aiProxies.nineRouterUpdate);
const base: NineRouterUpdateStatus = {
installed: true,
version: "0.5.104",
latest: "0.5.109",
updateAvailable: true,
running: true,
managedBy: "systemd",
blocker: null,
job: null,
};
const job: NineRouterUpdateJob = {
status: "running",
startedAt: "2026-10-26T00:00:00Z",
finishedAt: null,
from: "0.5.104",
to: null,
managedBy: "systemd",
install: "direct",
steps: [
{ id: "shutdown", status: "done", message: null },
{ id: "update", status: "running", message: null },
{ id: "relaunch", status: "pending", message: null },
],
log: "",
};
beforeEach(() => {
statusMock.mockReset();
startMock.mockReset();
});
describe("NineRouterUpdate", () => {
it("renders nothing when 9router is not installed", async () => {
statusMock.mockResolvedValue({ ...base, installed: false });
const { container } = render(<NineRouterUpdate />);
await waitFor(() => expect(statusMock).toHaveBeenCalled());
expect(container).toBeEmptyDOMElement();
});
it("opens with both versions and starts the three-step job", async () => {
statusMock.mockResolvedValue(base);
startMock.mockResolvedValue(job);
render(<NineRouterUpdate />);
await userEvent.click(await screen.findByRole("button", { name: /aiProxy.update9router/ }));
expect(statusMock).toHaveBeenLastCalledWith(true);
const dialog = await screen.findByRole("dialog");
expect(dialog).toHaveTextContent("0.5.104");
expect(dialog).toHaveTextContent("0.5.109");
await userEvent.click(screen.getByRole("button", { name: "aiProxy.update9routerStart" }));
expect(startMock).toHaveBeenCalledTimes(1);
expect(await screen.findByText("aiProxy.update9routerStep.shutdown")).toBeInTheDocument();
expect(screen.getByText("aiProxy.update9routerStep.update")).toBeInTheDocument();
expect(screen.getByText("aiProxy.update9routerStep.relaunch")).toBeInTheDocument();
});
it("shows the blocker with its manual command and keeps the start disabled", async () => {
statusMock.mockResolvedValue({
...base,
blocker: {
code: "ENOPERM",
message: "/usr/local is not writable by this account and sudo needs a password",
command: "sudo npm install -g 9router@latest",
},
});
render(<NineRouterUpdate />);
await userEvent.click(await screen.findByRole("button", { name: /aiProxy.update9router/ }));
expect(await screen.findByText("sudo npm install -g 9router@latest")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "aiProxy.update9routerStart" })).toBeDisabled();
});
});
@@ -72,15 +72,15 @@ type Row = {
}; };
/** One quota window of an account: % used and when it rolls over. */ /** One quota window of an account: % used and when it rolls over. */
type QuotaWindow = { key: "5h" | "wk"; used: number | null; resetsAt: string | null }; export type QuotaWindow = { key: "5h" | "wk"; used: number | null; resetsAt: string | null };
/** /**
* The 5-hour and weekly windows of an account, from Claude's `fiveHour` / * The 5-hour and weekly windows of an account, from Claude's `fiveHour` /
* `sevenDay` or Codex's `5h` / `wk` rollout windows. A window whose reset has * `sevenDay` or Codex's `5h` / `wk` rollout windows. A window whose reset has
* passed is empty again (the reading predates it), as the balancer treats it. * passed is empty again (the reading predates it), as the balancer treats it.
*/ */
function windowsOf( export function windowsOf(
a: AccountPool["accounts"][number] | AccountPool["codex"][number] a: Pick<AccountPool["accounts"][number] | AccountPool["codex"][number], "quota">
): QuotaWindow[] { ): QuotaWindow[] {
const q = a.quota as const q = a.quota as
| { | {
@@ -558,7 +558,7 @@ function AccountCard({
* One window of an account's quota, shown as what is LEFT (100% = untouched, * One window of an account's quota, shown as what is LEFT (100% = untouched,
* 0% = used up), with when it resets. * 0% = used up), with when it resets.
*/ */
function QuotaBar({ window: w }: { window: QuotaWindow }) { export function QuotaBar({ window: w }: { window: QuotaWindow }) {
const { t } = useTranslation("users"); const { t } = useTranslation("users");
const left = w.used == null ? null : Math.max(0, Math.min(100, 100 - Math.round(w.used))); const left = w.used == null ? null : Math.max(0, Math.min(100, 100 - Math.round(w.used)));
const resets = untilText(w.resetsAt); const resets = untilText(w.resetsAt);
+220
View File
@@ -0,0 +1,220 @@
/**
* @file MemberTmux.tsx
* @description Each member's own tmux server as part of the Administration
* page's users table (one Linux user per account only, `GET /api/users/tmux`,
* `useMemberTmux`): a `TmuxCell` per row — the version it runs, a badge when
* older than the installed one, its sessions, whether an agent of theirs is
* working — and `MemberTmuxBar` above the table, so an admin sees who still
* runs an old tmux after an upgrade.
* An admin restarts one member's server, or every idle outdated one at once
* (`POST /api/users/:id/tmux-restart`, `/api/users/tmux-restart-idle`), each
* time with their own password again; a restart ends that member's panes,
* and the next start runs the installed tmux. An analyst only reads.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { RotateCcw, TerminalSquare } from "lucide-react";
import { api, type MemberTmux, type MemberTmuxRow } from "../../lib/api";
import { formatDateTime } from "../../lib/format";
import { toast } from "../../lib/toast";
import { isSubmitEnter } from "../../lib/keys";
import { ConfirmModal } from "../ConfirmModal";
function errorText(t: TFunction<"users">, e: unknown): string {
const err = e as Error & { code?: string };
return err.code && t(`tmux.errors.${err.code}`, { defaultValue: "" })
? t(`tmux.errors.${err.code}`)
: err.message;
}
const BADGE = "badge ml-1.5 py-0 text-[10px]";
const WARN = `${BADGE} bg-status-warning/10 text-status-warning border-status-warning/20`;
const DANGER = `${BADGE} bg-status-danger/10 text-status-danger border-status-danger/20`;
/** `GET /api/users/tmux`; `available` false without one Linux user per account. */
export function useMemberTmux(enabled = true) {
const [data, setData] = useState<MemberTmux | null>(null);
const reload = useCallback(
() =>
api.users
.tmux()
.then(setData)
.catch(() => setData(null)),
[]
);
useEffect(() => {
if (enabled) void reload();
}, [enabled, reload]);
return { data, reload };
}
/** One member's tmux server as a users-table cell: version, badges, sessions, agents. */
export function TmuxCell({ row }: { row?: MemberTmuxRow }) {
const { t } = useTranslation("users");
if (!row?.running) return <span className="text-sm text-fg-muted">—</span>;
return (
<div className="text-sm text-fg-secondary">
<span className="font-mono">{row.stuck ? "?" : (row.version ?? "?")}</span>
{row.stuck && <span className={DANGER}>{t("tmux.stuck")}</span>}
{row.outdated && <span className={WARN}>{t("tmux.outdated")}</span>}
<div
className="text-[11px] text-fg-muted"
title={
row.startedAt
? t("tmux.since", { at: formatDateTime(new Date(row.startedAt).toISOString()) })
: undefined
}
>
{t("tmux.sessionsCount", { count: row.sessions ?? 0 })} ·{" "}
{row.busy > 0 ? t("tmux.working", { count: row.busy }) : t("tmux.idle")}
</div>
</div>
);
}
/**
* Above the users table: the installed tmux, the not-yet-installed tmux-member
* warning, and (an admin) "Restart N idle members". Nothing without S3.
*/
export function MemberTmuxBar({
data,
isAdmin,
onRestart,
}: {
data: MemberTmux | null;
isAdmin: boolean;
onRestart: (target: "idle") => void;
}) {
const { t } = useTranslation("users");
if (!data?.available) return null;
const idle = data.users.filter((u) => u.running && u.outdated && u.busy === 0);
return (
<div className="flex flex-wrap items-center gap-3 text-xs" data-testid="member-tmux">
<span className="inline-flex items-center gap-1.5 text-fg-muted">
<TerminalSquare className="w-3.5 h-3.5" />
{t("tmux.description", { version: data.installed ?? "?" })}
</span>
{!data.matched && <span className="text-status-warning">{t("tmux.notMatched")}</span>}
{isAdmin && idle.length > 0 && (
<button type="button" onClick={() => onRestart("idle")} className="btn-ghost text-xs">
<RotateCcw className="w-3.5 h-3.5" /> {t("tmux.restartIdle", { count: idle.length })}
</button>
)}
</div>
);
}
/** An admin's restart of one member's tmux, or of every idle outdated one. */
export function TmuxRestartDialog({
target,
data,
onClose,
onDone,
}: {
target: MemberTmuxRow | "idle";
data: MemberTmux | null;
onClose: () => void;
onDone: (message: string) => void;
}) {
const idle = (data?.users ?? []).filter((u) => u.running && u.outdated && u.busy === 0);
return <RestartDialog target={target} idle={idle} onClose={onClose} onDone={onDone} />;
}
function RestartDialog({
target,
idle,
onClose,
onDone,
}: {
target: MemberTmuxRow | "idle";
idle: MemberTmuxRow[];
onClose: () => void;
onDone: (message: string) => void;
}) {
const { t } = useTranslation("users");
const [current, setCurrent] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const one = target === "idle" ? null : target;
const submit = async () => {
if (!current || busy) return;
setBusy(true);
setError(null);
try {
if (one) {
await api.users.tmuxRestart(one.id, current);
onDone(t("tmux.done", { name: one.username }));
} else {
const r = await api.users.tmuxRestartIdle(current);
// A server that would not stop needs root: say which, the rest went.
if (r.failed.length) toast.error(r.failed.map((f) => f.message).join("\n"));
onDone(t("tmux.doneIdle", { names: r.restarted.join(", ") || "—" }));
}
} catch (e) {
setError(errorText(t, e));
} finally {
setBusy(false);
}
};
return (
<ConfirmModal
open
title={one ? t("tmux.restart", { name: one.username }) : t("tmux.restartIdleTitle")}
message={
one
? t(one.busy > 0 ? "tmux.restartHintBusy" : "tmux.restartHint", {
name: one.username,
count: one.sessions ?? 0,
})
: t("tmux.restartIdleHint", { names: idle.map((u) => u.username).join(", ") })
}
confirmLabel={t("tmux.restartButton")}
cancelLabel={t("common:cancel")}
busy={busy}
disabled={!current}
onConfirm={() => void submit()}
onCancel={onClose}
>
<div
className="mt-3 space-y-3"
onKeyDown={(e) => {
if (isSubmitEnter(e)) {
e.preventDefault();
void submit();
}
}}
>
<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="whitespace-pre-line 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>
);
}
@@ -1,267 +0,0 @@
/**
* @file MemberTmuxCard.tsx
* @description The Administration page's Server-tab tmux card (one Linux user per account only,
* `GET /api/users/tmux`): each member's own tmux server — the version it runs
* beside the installed one, its sessions, whether an agent of theirs is
* working — so an admin sees who still runs an old tmux after an upgrade.
* An admin restarts one member's server, or every idle outdated one at once
* (`POST /api/users/:id/tmux-restart`, `/api/users/tmux-restart-idle`), each
* time with their own password again; a restart ends that member's panes,
* and the next start runs the installed tmux. An analyst only reads.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { RefreshCw, RotateCcw, TerminalSquare } from "lucide-react";
import { api, type MemberTmux, type MemberTmuxRow } from "../../lib/api";
import { formatDateTime } from "../../lib/format";
import { toast } from "../../lib/toast";
import { isSubmitEnter } from "../../lib/keys";
import { useSpin } from "../../hooks/useSpin";
import { ConfirmModal } from "../ConfirmModal";
function errorText(t: TFunction<"users">, e: unknown): string {
const err = e as Error & { code?: string };
return err.code && t(`tmux.errors.${err.code}`, { defaultValue: "" })
? t(`tmux.errors.${err.code}`)
: err.message;
}
const BADGE = "badge ml-1.5 py-0 text-[10px]";
const WARN = `${BADGE} bg-status-warning/10 text-status-warning border-status-warning/20`;
const DANGER = `${BADGE} bg-status-danger/10 text-status-danger border-status-danger/20`;
export function MemberTmuxCard({ isAdmin }: { isAdmin: boolean }) {
const { t } = useTranslation("users");
const [data, setData] = useState<MemberTmux | null>(null);
const [error, setError] = useState<string | null>(null);
// A member row = restart that one; "idle" = every idle outdated member.
const [restarting, setRestarting] = useState<MemberTmuxRow | "idle" | null>(null);
const load = useCallback(
() =>
api.users
.tmux()
.then((r) => {
setData(r);
setError(null);
})
.catch((e: Error) => setError(e.message)),
[]
);
const [spinning, refresh] = useSpin(load);
useEffect(() => {
void load();
}, [load]);
if (!data?.available) return null;
const rows = data.users.filter((u) => u.running);
const idleOutdated = rows.filter((u) => u.outdated && u.busy === 0);
const th = "px-5 py-2.5 text-[11px] font-semibold text-fg-muted uppercase tracking-wider";
return (
<section className="card" data-testid="member-tmux">
<div className="flex flex-wrap items-center gap-2 px-5 py-4 border-b border-border">
<TerminalSquare className="w-4 h-4 text-fg-muted" />
<div className="flex-1 min-w-0">
<h2 className="text-sm font-semibold text-fg-primary">{t("tmux.title")}</h2>
<p className="text-xs text-fg-muted">
{t("tmux.description", { version: data.installed ?? "?" })}
</p>
</div>
<button
type="button"
onClick={refresh}
disabled={spinning}
aria-busy={spinning}
title={t("tmux.refresh")}
aria-label={t("tmux.refresh")}
className="btn-icon"
>
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />
</button>
{isAdmin && idleOutdated.length > 0 && (
<button type="button" onClick={() => setRestarting("idle")} className="btn-primary">
<RotateCcw className="w-4 h-4" />{" "}
{t("tmux.restartIdle", { count: idleOutdated.length })}
</button>
)}
</div>
{!data.matched && (
<p className="px-5 py-2.5 text-xs text-status-warning border-b border-border">
{t("tmux.notMatched")}
</p>
)}
{error && (
<p role="alert" className="px-5 py-3 text-xs text-status-danger">
{error}
</p>
)}
{rows.length === 0 ? (
<p className="px-5 py-8 text-center text-sm text-fg-muted">{t("tmux.empty")}</p>
) : (
<div className="overflow-x-auto">
<table className="w-full min-w-[560px]">
<thead>
<tr className="border-b border-border text-left">
<th className={th}>{t("tmux.user")}</th>
<th className={th}>{t("tmux.version")}</th>
<th className={`${th} text-right`}>{t("tmux.sessions")}</th>
<th className={th}>{t("tmux.agents")}</th>
{isAdmin && <th className={`${th} text-right`}>{t("actions")}</th>}
</tr>
</thead>
<tbody className="divide-y divide-border">
{rows.map((u) => (
<tr key={u.id}>
<td className="px-5 py-2.5 text-sm text-fg-primary">{u.username}</td>
<td className="px-5 py-2.5 text-sm text-fg-secondary">
<span className="font-mono">{u.stuck ? "?" : (u.version ?? "?")}</span>
{u.stuck && <span className={DANGER}>{t("tmux.stuck")}</span>}
{u.outdated && <span className={WARN}>{t("tmux.outdated")}</span>}
{u.startedAt && (
<div className="text-[11px] text-fg-muted">
{t("tmux.since", {
at: formatDateTime(new Date(u.startedAt).toISOString()),
})}
</div>
)}
</td>
<td className="px-5 py-2.5 text-right text-sm tabular-nums text-fg-secondary">
{u.sessions ?? "—"}
</td>
<td className="px-5 py-2.5 text-sm text-fg-secondary">
{u.busy > 0 ? t("tmux.working", { count: u.busy }) : t("tmux.idle")}
</td>
{isAdmin && (
<td className="px-5 py-2 text-right">
<button
type="button"
onClick={() => setRestarting(u)}
title={t("tmux.restart", { name: u.username })}
aria-label={t("tmux.restart", { name: u.username })}
className="btn-icon"
>
<RotateCcw className="h-4 w-4" />
</button>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
)}
{restarting && (
<RestartDialog
target={restarting}
idle={idleOutdated}
onClose={() => setRestarting(null)}
onDone={(msg) => {
toast.success(msg);
setRestarting(null);
void load();
}}
/>
)}
</section>
);
}
function RestartDialog({
target,
idle,
onClose,
onDone,
}: {
target: MemberTmuxRow | "idle";
idle: MemberTmuxRow[];
onClose: () => void;
onDone: (message: string) => void;
}) {
const { t } = useTranslation("users");
const [current, setCurrent] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const one = target === "idle" ? null : target;
const submit = async () => {
if (!current || busy) return;
setBusy(true);
setError(null);
try {
if (one) {
await api.users.tmuxRestart(one.id, current);
onDone(t("tmux.done", { name: one.username }));
} else {
const r = await api.users.tmuxRestartIdle(current);
// A server that would not stop needs root: say which, the rest went.
if (r.failed.length) toast.error(r.failed.map((f) => f.message).join("\n"));
onDone(t("tmux.doneIdle", { names: r.restarted.join(", ") || "—" }));
}
} catch (e) {
setError(errorText(t, e));
} finally {
setBusy(false);
}
};
return (
<ConfirmModal
open
title={one ? t("tmux.restart", { name: one.username }) : t("tmux.restartIdleTitle")}
message={
one
? t(one.busy > 0 ? "tmux.restartHintBusy" : "tmux.restartHint", {
name: one.username,
count: one.sessions ?? 0,
})
: t("tmux.restartIdleHint", { names: idle.map((u) => u.username).join(", ") })
}
confirmLabel={t("tmux.restartButton")}
cancelLabel={t("common:cancel")}
busy={busy}
disabled={!current}
onConfirm={() => void submit()}
onCancel={onClose}
>
<div
className="mt-3 space-y-3"
onKeyDown={(e) => {
if (isSubmitEnter(e)) {
e.preventDefault();
void submit();
}
}}
>
<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="whitespace-pre-line 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>
);
}
+331
View File
@@ -0,0 +1,331 @@
/**
* @file UserDetail.tsx
* @description One account, opened IN PLACE under its row of the
* Administration page's users table (`?user=<id>`, `GET /api/users/:id/details`).
* The row already shows identity, sessions, spend, home and tmux, so
* this keeps only what the row cannot: one summary line (last sign-in, last
* activity, active sessions and live panes), then three short columns — spend
* (30-day bars, by AI tool, top models), the account pool (per tool: pin or
* group, each account's quota left beside this user's own 7-day share), and
* what runs now (live panes, owned workspaces) — and the five newest sessions. Read-only; Edit stays on
* the row.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState, type ReactNode } from "react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { AlertCircle, Play, RefreshCw } from "lucide-react";
import { api, type PoolTool, type UserDetails } from "../../lib/api";
import { fmtCost, formatDateTime } from "../../lib/format";
import { effectiveSessionStatus, sessionAwaitingReason } from "../../lib/types";
import { useSpin } from "../../hooks/useSpin";
import { useAiTools } from "../../hooks/useAiTools";
import { AiToolIcon } from "../AiToolIcon";
import { SessionStatusBadge } from "../StatusBadge";
import { MiniChart } from "../admin/MiniChart";
import { QuotaBar, windowsOf } from "./AccountPoolCard";
const POOL_LABEL: Record<PoolTool, string> = {
claude: "Claude Code",
codex: "Codex",
cursor: "Cursor CLI",
};
const pctText = (n: number) => `${n < 10 ? n.toFixed(1) : Math.round(n)}%`;
export function UserDetail({ userId }: { userId: number }) {
const { t } = useTranslation("users");
const { label: toolLabel } = useAiTools();
const [data, setData] = useState<UserDetails | null>(null);
const [error, setError] = useState<string | null>(null);
const load = useCallback(
() =>
api.users
.details(userId)
.then((d) => {
setData(d);
setError(null);
})
.catch((e: Error) => setError(e.message)),
[userId]
);
const [spinning, refresh] = useSpin(load);
useEffect(() => {
setData(null);
void load();
}, [load]);
if (error)
return (
<div
role="alert"
data-testid="user-detail"
className="flex flex-wrap items-center gap-3 text-sm text-status-danger"
>
<AlertCircle className="w-4 h-4 flex-shrink-0" />
<span className="flex-1 min-w-0">
{t("detail.failed")}: {error}
</span>
<button type="button" onClick={refresh} className="btn-ghost text-xs">
{t("common:retry")}
</button>
</div>
);
if (!data)
return (
<p data-testid="user-detail" className="py-4 text-center text-sm text-fg-muted">
{t("common:loading")}
</p>
);
const u = data.user;
const total30 = data.usage.last30d.cost;
const pools = (Object.keys(POOL_LABEL) as PoolTool[]).filter(
(tool) => (data.pool[tool]?.accounts.length ?? 0) > 0
);
return (
<div className="space-y-4" data-testid="user-detail">
{/* One line of what the row does not show. */}
<div className="flex flex-wrap items-center gap-x-6 gap-y-1 text-xs">
<Inline label={t("detail.lastLogin")}>
{u.lastLoginAt ? formatDateTime(u.lastLoginAt) : t("detail.never")}
</Inline>
<Inline label={t("detail.lastSession")}>
{data.sessions.lastAt ? formatDateTime(data.sessions.lastAt) : "—"}
</Inline>
<Inline label={t("detail.activeLabel")}>
{t("detail.activeValue", { sessions: data.sessions.active, panes: data.panes.length })}
</Inline>
<button
type="button"
onClick={refresh}
disabled={spinning}
aria-busy={spinning}
className="btn-ghost ml-auto py-1 text-xs"
>
<RefreshCw className={`w-3.5 h-3.5 ${spinning ? "animate-spin" : ""}`} />{" "}
{t("common:refresh")}
</button>
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
{/* Spend: the month at a glance, then where it went. */}
<Panel title={t("detail.spendDaily")}>
<MiniChart
kind="bar"
label={t("detail.spendDaily")}
points={data.usage.daily.map((d) => ({ x: d.date.slice(5), y: d.cost }))}
format={(v) => fmtCost(v)}
/>
{data.usage.byTool.length === 0 ? (
<p className="text-xs text-fg-muted">{t("detail.noUsage")}</p>
) : (
<ul className="space-y-1.5 pt-1">
{data.usage.byTool.map((r) => (
<ShareRow
key={r.key}
label={
<span className="flex items-center gap-1.5">
<AiToolIcon
tool={{ id: r.key, label: toolLabel(r.key) }}
className="h-4 w-4"
/>
{toolLabel(r.key)}
</span>
}
cost={r.cost}
total={total30}
/>
))}
{data.usage.byModel.slice(0, 3).map((r) => (
<ShareRow
key={`m:${r.key}`}
label={<span className="font-mono text-[11px] text-fg-muted">{r.key}</span>}
cost={r.cost}
total={total30}
/>
))}
</ul>
)}
</Panel>
{/* Pool: what they may run on, and how much of it is theirs. */}
<Panel title={t("detail.pool")}>
{pools.length === 0 ? (
<p className="text-xs text-fg-muted">{t("detail.poolNone")}</p>
) : (
pools.map((tool) => {
const p = data.pool[tool]!;
return (
<div key={tool} className="space-y-2">
<div className="flex flex-wrap items-center gap-1.5 text-xs">
<AiToolIcon tool={{ id: tool, label: POOL_LABEL[tool] }} className="h-4 w-4" />
<span className="font-medium text-fg-primary">{POOL_LABEL[tool]}</span>
<span className="text-fg-muted">
·{" "}
{p.pin
? t("detail.poolPinned", { account: p.pin })
: p.group
? t("detail.poolGroup", { name: p.group.name })
: t("detail.poolShared")}
</span>
</div>
{p.accounts.map((a) => {
const share = a.spend7d > 0 ? (a.userSpend7d / a.spend7d) * 100 : 0;
return (
<div
key={a.name}
className={`rounded-md border border-border px-2.5 py-2 space-y-1 ${a.allowed ? "" : "opacity-60"}`}
data-testid="user-pool-account"
>
<div className="flex items-center justify-between gap-2 text-xs">
<span className="truncate font-medium text-fg-primary">
{a.name}
{a.expired && (
<span className="ml-1.5 text-status-danger">{t("pool.expired")}</span>
)}
{!a.allowed && (
<span className="ml-1.5 text-fg-muted">
{t("detail.poolNotAllowed")}
</span>
)}
</span>
{tool !== "cursor" && (
<span
className="flex-shrink-0 tabular-nums text-fg-secondary"
title={t("detail.poolTotal7d", { cost: fmtCost(a.spend7d) })}
>
{t("detail.poolTheirs7d", { cost: fmtCost(a.userSpend7d) })}
{a.spend7d > 0 && (
<span className="text-fg-muted"> ({pctText(share)})</span>
)}
</span>
)}
</div>
{tool !== "cursor" &&
windowsOf(a).map((w) => <QuotaBar key={w.key} window={w} />)}
</div>
);
})}
</div>
);
})
)}
</Panel>
{/* Now: live panes and the workspaces they own. */}
<Panel title={t("detail.nowTitle")}>
{data.panes.length === 0 ? (
<p className="text-xs text-fg-muted">{t("detail.noPanes")}</p>
) : (
<ul className="space-y-1.5">
{data.panes.map((p) => (
<li key={p.id} className="text-xs">
<Link
to={`/workspace?lane=${p.laneId}&run=${encodeURIComponent(p.id)}`}
className="inline-flex items-center gap-1.5 text-fg-primary hover:text-accent"
>
<Play className="h-3 w-3 text-status-success" />
{p.laneTitle || p.laneCwd}
</Link>
<span className="text-fg-muted">
{" "}
· {toolLabel(p.tool)}
{p.account ? ` · ${p.account}` : ""}
</span>
</li>
))}
</ul>
)}
{data.workspaces.length > 0 && (
<div className="flex flex-wrap gap-1.5 border-t border-border pt-2">
{data.workspaces.map((w) => (
<Link
key={w.id}
to={`/workspace?lane=${w.id}`}
title={w.cwd}
className="badge py-0.5 text-[11px] hover:text-accent"
>
{w.livePanes > 0 && (
<span className="mr-1 inline-block h-1.5 w-1.5 rounded-full bg-status-success" />
)}
{w.title || w.cwd.split("/").pop()}
<span className="ml-1 text-fg-muted">{w.sessions}</span>
</Link>
))}
</div>
)}
</Panel>
</div>
{data.recentSessions.length > 0 && (
<Panel title={t("detail.recentTitle")}>
<ul className="divide-y divide-border">
{data.recentSessions.slice(0, 5).map((s) => (
<li key={s.id} className="flex items-center gap-3 py-1.5 text-xs">
<SessionStatusBadge
status={effectiveSessionStatus(s)}
reason={sessionAwaitingReason(s)}
/>
<Link
to={`/sessions/${s.id}`}
className="min-w-0 flex-1 truncate text-fg-primary hover:text-accent"
>
{s.name || s.id.slice(0, 8)}
</Link>
<span className="flex-shrink-0 text-fg-muted">{toolLabel(s.tool || "claude")}</span>
<span className="w-32 flex-shrink-0 text-right text-fg-muted">
{formatDateTime(s.last_activity || s.started_at)}
</span>
<span className="w-16 flex-shrink-0 text-right font-mono text-fg-secondary">
{s.cost ? fmtCost(s.cost) : "—"}
</span>
</li>
))}
</ul>
</Panel>
)}
</div>
);
}
function Inline({ label, children }: { label: string; children: ReactNode }) {
return (
<span>
<span className="text-fg-muted">{label}: </span>
<span className="text-fg-primary">{children}</span>
</span>
);
}
function Panel({ title, children }: { title: string; children: ReactNode }) {
return (
<section className="card p-3 space-y-2.5 min-w-0">
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-fg-muted">{title}</h3>
{children}
</section>
);
}
/** One slice of the 30-day spend: label, $, and a thin bar of its share. */
function ShareRow({ label, cost, total }: { label: ReactNode; cost: number; total: number }) {
const share = total > 0 ? (cost / total) * 100 : 0;
return (
<li className="space-y-0.5">
<div className="flex items-center justify-between gap-2 text-xs">
<span className="min-w-0 truncate text-fg-secondary">{label}</span>
<span className="flex-shrink-0 tabular-nums text-fg-primary">
{fmtCost(cost)} <span className="text-fg-muted">{Math.round(share)}%</span>
</span>
</div>
<span className="block h-1 overflow-hidden rounded-full bg-surface-4">
<span
className="block h-full rounded-full bg-accent-solid"
style={{ width: `${Math.min(100, share)}%` }}
/>
</span>
</li>
);
}
@@ -1,18 +1,20 @@
/** /**
* @file MemberTmuxCard.test.tsx * @file MemberTmux.test.tsx
* @description The Users page's members' tmux card: hidden without one Linux * @description Each member's tmux server as part of the users table
* user per account, lists each running member server with its version and a * (`MemberTmux.tsx`): the bar above the table is hidden without one Linux
* badge when it is older than the installed tmux, warns until tmux-member is * user per account, names the installed tmux and warns until tmux-member is
* installed, and an admin restarts one member — or every idle outdated one, * installed; a row's cell shows the version with a badge when it is older than
* the working member left out — only with their own password typed; an * the installed tmux, and "—" for a member with no server; an admin restarts
* analyst sees no restart control. * one member — or every idle outdated one, the working member left out — only
* with their own password typed; an analyst gets no "Restart idle".
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { useState } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react"; import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import type { MemberTmux } from "../../../lib/api"; import type { MemberTmux, MemberTmuxRow } from "../../../lib/api";
const users = vi.hoisted(() => ({ const users = vi.hoisted(() => ({
tmux: vi.fn(), tmux: vi.fn(),
@@ -25,7 +27,41 @@ vi.mock("../../../lib/api", async (importOriginal) => {
}); });
vi.mock("../../../lib/toast", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); vi.mock("../../../lib/toast", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
import { MemberTmuxCard } from "../MemberTmuxCard"; import { MemberTmuxBar, TmuxCell, TmuxRestartDialog, useMemberTmux } from "../MemberTmux";
/** The users table's use of it, reduced: the bar, one cell and restart per row. */
function Harness({ isAdmin }: { isAdmin: boolean }) {
const { data, reload } = useMemberTmux();
const [target, setTarget] = useState<MemberTmuxRow | "idle" | null>(null);
return (
<>
<MemberTmuxBar data={data} isAdmin={isAdmin} onRestart={setTarget} />
{data?.available &&
data.users.map((u) => (
<div key={u.id} data-testid={`row-${u.username}`}>
<span>{u.username}</span>
<TmuxCell row={u} />
{isAdmin && u.running && (
<button type="button" onClick={() => setTarget(u)}>
Restart {u.username}'s tmux
</button>
)}
</div>
))}
{target && (
<TmuxRestartDialog
target={target}
data={data}
onClose={() => setTarget(null)}
onDone={() => {
setTarget(null);
void reload();
}}
/>
)}
</>
);
}
const DATA: MemberTmux = { const DATA: MemberTmux = {
available: true, available: true,
@@ -52,33 +88,33 @@ beforeEach(() => {
users.tmux.mockResolvedValue(DATA); users.tmux.mockResolvedValue(DATA);
}); });
describe("MemberTmuxCard", () => { describe("MemberTmux", () => {
it("renders nothing without one Linux user per account", async () => { it("shows no bar without one Linux user per account", async () => {
users.tmux.mockResolvedValue({ available: false, users: [] }); users.tmux.mockResolvedValue({ available: false, users: [] });
const { container } = render(<MemberTmuxCard isAdmin />); render(<Harness isAdmin />);
await waitFor(() => expect(users.tmux).toHaveBeenCalled()); await waitFor(() => expect(users.tmux).toHaveBeenCalled());
expect(container).toBeEmptyDOMElement(); expect(screen.queryByTestId("member-tmux")).toBeNull();
}); });
it("lists running servers and marks the ones older than the installed tmux", async () => { it("marks the servers older than the installed tmux, and a member with none", async () => {
render(<MemberTmuxCard isAdmin={false} />); render(<Harness isAdmin={false} />);
expect(await screen.findByText("mia")).toBeInTheDocument(); expect(await screen.findByText(/Installed: tmux 3\.8/)).toBeInTheDocument();
expect(screen.queryByText("bob")).toBeNull();
expect(screen.getAllByText("older than installed")).toHaveLength(2); expect(screen.getAllByText("older than installed")).toHaveLength(2);
expect(screen.getByText(/Installed: tmux 3\.8/)).toBeInTheDocument(); expect(screen.getByTestId("row-mia").textContent).toContain("2 sessions");
expect(screen.getByTestId("row-bob").textContent).toContain("—");
// An analyst reads only. // An analyst reads only.
expect(screen.queryByRole("button", { name: /Restart/ })).toBeNull(); expect(screen.queryByRole("button", { name: /Restart/ })).toBeNull();
}); });
it("warns until tmux-member is installed", async () => { it("warns until tmux-member is installed", async () => {
users.tmux.mockResolvedValue({ ...DATA, matched: false }); users.tmux.mockResolvedValue({ ...DATA, matched: false });
render(<MemberTmuxCard isAdmin />); render(<Harness isAdmin />);
expect(await screen.findByText(/install-system/)).toBeInTheDocument(); expect(await screen.findByText(/install-system/)).toBeInTheDocument();
}); });
it("restarts one member only with the admin's password", async () => { it("restarts one member only with the admin's password", async () => {
users.tmuxRestart.mockResolvedValue({}); users.tmuxRestart.mockResolvedValue({});
render(<MemberTmuxCard isAdmin />); render(<Harness isAdmin />);
await userEvent.click(await screen.findByRole("button", { name: "Restart mia's tmux" })); await userEvent.click(await screen.findByRole("button", { name: "Restart mia's tmux" }));
expect(screen.getByText(/Every pane of mia closes \(2 sessions\)/)).toBeInTheDocument(); expect(screen.getByText(/Every pane of mia closes \(2 sessions\)/)).toBeInTheDocument();
const go = screen.getByRole("button", { name: "Restart" }); const go = screen.getByRole("button", { name: "Restart" });
@@ -90,7 +126,7 @@ describe("MemberTmuxCard", () => {
it("restarts every idle outdated member at once, the working one left out", async () => { it("restarts every idle outdated member at once, the working one left out", async () => {
users.tmuxRestartIdle.mockResolvedValue({ restarted: ["mia"], skipped: ["zed"], failed: [] }); users.tmuxRestartIdle.mockResolvedValue({ restarted: ["mia"], skipped: ["zed"], failed: [] });
render(<MemberTmuxCard isAdmin />); render(<Harness isAdmin />);
await userEvent.click(await screen.findByRole("button", { name: "Restart 1 idle member" })); await userEvent.click(await screen.findByRole("button", { name: "Restart 1 idle member" }));
expect(screen.getByText(/no agent working: mia\./)).toBeInTheDocument(); expect(screen.getByText(/no agent working: mia\./)).toBeInTheDocument();
await userEvent.type(screen.getByLabelText("Your password"), "pw"); await userEvent.type(screen.getByLabelText("Your password"), "pw");
+41
View File
@@ -0,0 +1,41 @@
/**
* @file useCcTarget — whose Claude Code config the Config Explorer manages for
* this login (GET /api/cc-config/target). Local mode and an admin: the host's,
* answered at once without a request (nothing changes for them). A non-admin
* in server mode asks the server: `own` when they have their own Linux account
* (S3) — Settings then shows "Your Claude Code config" — else `none` and the
* page stays hidden. The server's refusal (403 ENOACCOUNT/EADMIN) is the rule;
* this only hides what would be refused.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { api, type CcTarget } from "../lib/api";
import { usePermissions } from "../components/AuthGate";
const HOST: CcTarget = { kind: "host", readOnly: false, claudeHome: null };
/** `null` while a non-admin's answer is on its way. */
export function useCcTarget(): CcTarget | null {
const { isAdmin } = usePermissions();
const [target, setTarget] = useState<CcTarget | null>(isAdmin ? HOST : null);
useEffect(() => {
if (isAdmin) {
setTarget(HOST);
return;
}
let live = true;
api.ccConfig
.target()
.then((t) => {
if (live) setTarget(t);
})
.catch(() => {
if (live) setTarget({ kind: "none", readOnly: true, claudeHome: null });
});
return () => {
live = false;
};
}, [isAdmin]);
return target;
}
+2 -2
View File
@@ -27,9 +27,9 @@ const FOCUSABLE =
*/ */
export function useDialog( export function useDialog(
open: boolean, open: boolean,
panelRef: RefObject<HTMLElement>, panelRef: RefObject<HTMLElement | null>,
onClose?: () => void, onClose?: () => void,
initialFocus?: RefObject<HTMLElement> initialFocus?: RefObject<HTMLElement | null>
): void { ): void {
// Read through a ref so an inline `onClose` does not re-run the effect, which // Read through a ref so an inline `onClose` does not re-run the effect, which
// would steal focus back to the first control on every parent render. // would steal focus back to the first control on every parent render.
+3 -3
View File
@@ -63,7 +63,7 @@ type MessageHandler = (msg: WSMessage) => void;
* auto-reconnects with capped exponential backoff on close - plus an * auto-reconnects with capped exponential backoff on close - plus an
* immediate reconnect attempt on tab focus/network-online/visibility-change * immediate reconnect attempt on tab focus/network-online/visibility-change
* so the socket recovers quickly after a server restart or laptop sleep. * so the socket recovers quickly after a server restart or laptop sleep.
* Guards against React 18 StrictMode's mount→cleanup→remount cycle opening a * Guards against React StrictMode's mount→cleanup→remount cycle opening a
* duplicate socket (see the inline comment in `connect`). * duplicate socket (see the inline comment in `connect`).
* @param onMessage Called with every message parsed from the socket; the * @param onMessage Called with every message parsed from the socket; the
* latest reference is used even across reconnects (no stale closures). * latest reference is used even across reconnects (no stale closures).
@@ -73,7 +73,7 @@ export function useWebSocket(onMessage: MessageHandler) {
const wsRef = useRef<WebSocket | null>(null); const wsRef = useRef<WebSocket | null>(null);
const handlersRef = useRef<MessageHandler>(onMessage); const handlersRef = useRef<MessageHandler>(onMessage);
const [connected, setConnected] = useState(false); const [connected, setConnected] = useState(false);
const reconnectTimer = useRef<ReturnType<typeof setTimeout>>(); const reconnectTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const mountedRef = useRef(true); const mountedRef = useRef(true);
const reconnectAttempts = useRef(0); const reconnectAttempts = useRef(0);
@@ -82,7 +82,7 @@ export function useWebSocket(onMessage: MessageHandler) {
const connect = useCallback(() => { const connect = useCallback(() => {
if (!mountedRef.current) return; if (!mountedRef.current) return;
// Don't open a second socket if one is already alive or in flight. // Don't open a second socket if one is already alive or in flight.
// Without this, React 18 StrictMode (mount → cleanup → remount in dev) // Without this, React StrictMode (mount → cleanup → remount in dev)
// and the close→reconnect race could leave two sockets connected at the // and the close→reconnect race could leave two sockets connected at the
// same time. Both would receive every server broadcast, producing // same time. Both would receive every server broadcast, producing
// duplicate stream_event deltas (doubled text, duplicate assistant // duplicate stream_event deltas (doubled text, duplicate assistant
+1 -34
View File
@@ -1,37 +1,4 @@
{ {
"title": "Administration", "title": "Administration",
"description": "Accounts and the server AgentHub runs on.", "description": "Accounts and the server AgentHub runs on."
"tabs": {
"users": "Users",
"server": "Server"
},
"server": {
"title": "Server",
"uptime": "up {{v}}",
"uptimeDays": "{{d}} d {{h}} h",
"uptimeHours": "{{h}} h {{m}} min",
"cpu": "CPU",
"cores": "{{n}} cores · load {{load}}",
"cpuHelp": "Share of the whole machine: 100% means every core is busy.",
"memory": "Memory",
"swap": "Swap",
"homesDisk": "Homes disk",
"homesDiskHelp": "The disk that holds the users' home folders.",
"free": "free",
"usageTitle": "Usage by user",
"usageHelp": "Live, sampled over {{s}} s. A process counts for a user when it runs as their Linux account or inside one of their workspaces' sessions. Memory is resident (RSS); shared pages count once per process. Click a row for its busiest processes.",
"usageUnavailable": "Per-user usage is read from /proc and is only available on Linux.",
"who": "User",
"processes": "Processes",
"home": "Home",
"sampling": "Sampling…",
"hostGroup": "Host account (AgentHub and other host processes)",
"systemGroup": "System (root and other accounts)",
"disks": "Disks",
"mount": "Mount",
"device": "Device",
"used": "Used",
"homesBadge": "homes",
"gpus": "GPUs"
}
} }
+5
View File
@@ -1,6 +1,10 @@
{ {
"title": "Claude Config Explorer", "title": "Claude Config Explorer",
"subtitle": "Your Claude Code setup at a glance - plugins, skills, subagents, MCP servers, hooks, and settings.", "subtitle": "Your Claude Code setup at a glance - plugins, skills, subagents, MCP servers, hooks, and settings.",
"own": {
"label": "Your Claude Code config — {{home}}",
"subtitle": "Only your own account's setup: every change here runs as your account and reaches only your own sessions."
},
"refresh": "Refresh", "refresh": "Refresh",
"refreshing": "Refreshing…", "refreshing": "Refreshing…",
"lastUpdated": "Last updated {{time}}", "lastUpdated": "Last updated {{time}}",
@@ -30,6 +34,7 @@
}, },
"overview": { "overview": {
"rootsTitle": "Roots", "rootsTitle": "Roots",
"claudeHome": "Your .claude",
"projectClaudeDir": "Project .claude", "projectClaudeDir": "Project .claude",
"projectRoot": "Project root", "projectRoot": "Project root",
"claudeJson": "claude.json", "claudeJson": "claude.json",
+95 -17
View File
@@ -30,14 +30,6 @@
"since": "waiting {{ago}}" "since": "waiting {{ago}}"
}, },
"health": { "health": {
"runtime": "Runtime",
"cores_one": "{{count}} core",
"cores_other": "{{count}} cores",
"uptime": "Uptime",
"cpu": "CPU (1/5/15m)",
"nodeRss": "Node RSS",
"hostMemory": "Host Memory",
"hostMemoryTip": "Host Memory: {{pct}}% used\n{{used}} / {{total}}",
"heap": "V8 Heap", "heap": "V8 Heap",
"heapTip": "V8 Heap: {{pct}}% used\n{{used}} / {{limit}} limit\nAllocated: {{allocated}}\nExternal: {{external}}", "heapTip": "V8 Heap: {{pct}}% used\n{{used}} / {{limit}} limit\nAllocated: {{allocated}}\nExternal: {{external}}",
"storage": "Storage", "storage": "Storage",
@@ -50,12 +42,7 @@
"events": "Events", "events": "Events",
"score": "Health Score", "score": "Health Score",
"formula": "Formula", "formula": "Formula",
"formulaTip": "Composite index formula:\n0.4 × Success rate ({{success}}%)\n+ 0.25 × Cache hit ({{cache}}%)\n+ 0.25 × (100 − Error rate) ({{noError}}%)\n+ 0.10 × (100 − Heap%) ({{noHeap}}%)\n= {{score}}", "cacheTip": "Transcript cache hit rate: {{pct}}% (shown, not scored — it restarts empty with the server)",
"scoreTip": "Score: {{score}} / 100\n\n• Success rate (40%): {{success}}%\n• Cache hit (25%): {{cache}}%\n• Error avoidance (25%): {{noError}}%\n• Memory health (10%): {{noHeap}}%",
"cache": "Cache",
"cacheTip": "Cache hit rate: {{pct}}%\nHits: {{hits}}\nMisses: {{misses}}",
"errors": "Errors",
"errorsTip": "Error rate: {{pct}}%\n<5% = healthy, 5–15% = warning, >15% = critical",
"compact": "Compact", "compact": "Compact",
"compactTip": "Transcript compactions: {{count}}\nReduces the context window by summarizing turns.", "compactTip": "Transcript compactions: {{count}}\nReduces the context window by summarizing turns.",
"saved": "Saved", "saved": "Saved",
@@ -65,9 +52,10 @@
"modelTip": "{{model}}\nInput: {{input}}\nOutput: {{output}}\nCache read: {{cacheRead}}\nShare: {{pct}}%", "modelTip": "{{model}}\nInput: {{input}}\nOutput: {{output}}\nCache read: {{cacheRead}}\nShare: {{pct}}%",
"noModelData": "No model data", "noModelData": "No model data",
"concurrency": "Concurrency", "concurrency": "Concurrency",
"intervals_one": "{{count}} interval", "intervals_one": "{{count}} agent type",
"intervals_other": "{{count}} intervals", "intervals_other": "{{count}} agent types",
"concurrencyTip": "Peak: {{peak}} parallel sessions\nActive intervals: {{active}}\nAvg: {{avg}}", "concurrencyTip": "Peak: {{peak}} parallel sessions\nActive intervals: {{active}}\nAvg: {{avg}}",
"laneTip": "{{name}}\n{{count}} agents\nRuns {{start}}–{{end}}% of the session (avg)",
"noConcurrencyData": "No concurrency data", "noConcurrencyData": "No concurrency data",
"peak": "Peak", "peak": "Peak",
"peakTip": "Peak: {{count}} sessions running simultaneously.", "peakTip": "Peak: {{count}} sessions running simultaneously.",
@@ -99,11 +87,101 @@
"synchronous": "Synchronous", "synchronous": "Synchronous",
"autoVacuum": "Auto-vacuum", "autoVacuum": "Auto-vacuum",
"foreignKeys": "Foreign keys", "foreignKeys": "Foreign keys",
"busyTimeout": "Busy timeout" "busyTimeout": "Busy timeout",
"groupApp": "AgentHub",
"groupAppHint": "How the dashboard itself is doing: score, database, sessions at once",
"groupMachine": "Machine",
"groupMachineHint": "The computer AgentHub runs on: CPU, memory, disks, processes",
"groupActivity": "Agent activity",
"groupActivityHint": "What the agents used: tokens by model, tools, subagents",
"scoreFormula": "100 minus what each check costs:",
"scoreResult": "= {{score}} / 100",
"check": {
"loop": "AgentHub response",
"cpu": "CPU wait",
"memory": "Memory wait",
"io": "Disk wait",
"ram": "RAM in use",
"disk": "Data disk",
"heap": "V8 heap",
"hooks": "Last hook",
"agents": "Agent errors 24h"
},
"checkTip": {
"loop": "How long AgentHub's event loop was held up (p99 over the last minute). Every page, hook and terminal keystroke waits this long.",
"cpu": "CPU pressure (Linux PSI, last 10 s): share of time tasks waited for a free CPU — the machine feels slow above a few %.",
"cpuLoad": "Load per core (1 min) — this machine has no PSI, so CPU wait is estimated from load.",
"memory": "Memory pressure (Linux PSI, last 10 s): share of time tasks stalled reclaiming or swapping memory.",
"io": "Disk I/O pressure (Linux PSI, last 10 s): share of time tasks waited on the disk — a slow or busy HDD shows here.",
"ram": "Memory in use (total minus available).",
"disk": "How full the disk holding AgentHub's database is ({{mount}}).",
"heap": "AgentHub's V8 heap against its limit.",
"hooks": "Minutes since the last hook event, counted only while an agent is working: silence then means the dashboard stopped seeing it.",
"agents": "Agents that ended in the last 24 h with an error (errors / finished; needs at least 5)."
},
"checkNa": "Not measured here — costs nothing.",
"checkThresholds": "Amber from {{warn}}{{unit}}, red from {{bad}}{{unit}}",
"checkPenalty": "Costs {{n}} of up to {{max}} points",
"hooksMissing": "not installed",
"hooksIdle": "idle",
"minutesAgo": "{{n}} min ago",
"verdict": {
"ok": "Healthy",
"warn": "Degraded",
"bad": "Unhealthy"
},
"allOk": "Every check is fine",
"badCount_one": "{{count}} red",
"badCount_other": "{{count}} red",
"warnCount_one": "{{count}} amber",
"warnCount_other": "{{count}} amber",
"lagGroup": "Lag",
"systemGroup": "Capacity & signals"
}, },
"errorBoundary": { "errorBoundary": {
"title": "Something went wrong", "title": "Something went wrong",
"body": "This page failed to load. Reloading usually fixes it — for example after the dashboard was updated.", "body": "This page failed to load. Reloading usually fixes it — for example after the dashboard was updated.",
"reload": "Reload" "reload": "Reload"
},
"host": {
"uptimeDays": "{{d}} d {{h}} h",
"uptimeHours": "{{h}} h {{m}} min",
"cpu": "CPU",
"cpuHelp": "Share of the whole machine: 100% means every core is busy.",
"memory": "Memory",
"swap": "Swap",
"free": "free",
"usageUnavailable": "Per-user usage is read from /proc and is only available on Linux.",
"hostGroup": "Host account (AgentHub and other host processes)",
"systemGroup": "System (root and other accounts)",
"homesBadge": "homes",
"historyTitle": "History",
"historyHelp": "Kept in memory, one point every {{s}} s, since the server started at {{at}}.",
"historyCpu": "CPU (% of the machine)",
"historyMemory": "Memory used (%)",
"range": "Range",
"range1h": "1 h",
"range6h": "6 h",
"range24h": "24 h",
"coreN": "CPU {{n}}",
"threads": "{{n}} threads",
"physical": "{{n}} cores",
"perSecond": "{{v}}/s",
"temperatures": "Temperatures",
"topProcesses": "Busiest processes",
"cpuModel": "CPU",
"storageNetwork": "Disks & network",
"processCount": "{{n}} processes",
"title": "Server",
"os": "System",
"uptime": "Uptime",
"load": "Load (1/5/15 min)",
"agenthubTip": "pid {{pid}} · Node {{node}}",
"sampling": "Sampling…",
"sampled": "{{s}} s sample",
"historyNone": "No history yet — the server keeps it in memory on Linux, from the moment it starts.",
"agenthub": "AgentHub",
"usersTitle": "Users · CPU / RAM now",
"usersCount": "{{n}} users"
} }
} }
+2 -1
View File
@@ -88,5 +88,6 @@
"now": "now", "now": "now",
"users": "Users", "users": "Users",
"admin": "Administration", "admin": "Administration",
"files": "Files" "files": "Files",
"snapshot": "Snapshot"
} }
+1
View File
@@ -73,6 +73,7 @@
"failed": "Could not attach the file", "failed": "Could not attach the file",
"tooBig": "File too large to upload (max {{mb}} MB) — put it in the workspace folder and attach it from Files", "tooBig": "File too large to upload (max {{mb}} MB) — put it in the workspace folder and attach it from Files",
"tooBigLocal": "File too large to upload (max {{mb}} MB) — attach it by path instead: + → Choose a file on this computer", "tooBigLocal": "File too large to upload (max {{mb}} MB) — attach it by path instead: + → Choose a file on this computer",
"tooBigLocalPaste": "File too large to upload (max {{mb}} MB) — copy it in your file manager and paste it here instead (Ctrl+V): a pasted file goes in by its own path",
"remove": "Remove attachment", "remove": "Remove attachment",
"view": "View image" "view": "View image"
}, },
+6
View File
@@ -18,6 +18,12 @@
"tableAgents": "Agents", "tableAgents": "Agents",
"tableCost": "Cost", "tableCost": "Cost",
"tableDirectory": "Directory", "tableDirectory": "Directory",
"tableUser": "User",
"tableTool": "AI Tool",
"tableActions": "Actions",
"noOwner": "No owner",
"moreActions": "More actions",
"viewDetails": "View details",
"defaultName": "Session ", "defaultName": "Session ",
"detail": { "detail": {
"waitingBanner": { "waitingBanner": {
+28 -3
View File
@@ -759,6 +759,29 @@
"description": "For example 9Router, OpenRouter or a self-hosted gateway. Save one here, then pick it for a session in the workspace prompt box.", "description": "For example 9Router, OpenRouter or a self-hosted gateway. Save one here, then pick it for a session in the workspace prompt box.",
"add": "Add profile", "add": "Add profile",
"config9router": "Config 9router", "config9router": "Config 9router",
"update9router": "Update 9router",
"update9routerAvailable": "A newer 9router is available",
"update9routerCurrent": "Installed",
"update9routerLatest": "Latest",
"update9routerState": "State",
"update9routerRunning": "Running",
"update9routerRunningUnit": "Running (9router.service)",
"update9routerStopped": "Not running",
"update9routerExplain": "Every session using 9router is disconnected while it updates: 9router is shut down, updated with npm, then started again hidden (tray, no browser).",
"update9routerExplainStopped": "9router is not running: it is updated with npm and left off.",
"update9routerStep": {
"shutdown": "Disconnect and shut down 9router",
"update": "Run the update (npm install -g 9router@latest)",
"relaunch": "Start 9router again, hidden"
},
"update9routerDone": "9router {{version}} is running.",
"update9routerDoneStopped": "9router {{version}} is installed and left off, as it was.",
"update9routerFailed": "The update did not finish — see the steps and the log.",
"update9routerLog": "npm output",
"update9routerUpToDate": "9router is already the latest version.",
"update9routerCopy": "Copy command",
"update9routerClose": "Close",
"update9routerStart": "Update now",
"edit": "Edit", "edit": "Edit",
"remove": "Remove", "remove": "Remove",
"empty": "No proxy profile saved yet. Native runs against the vendor's own API.", "empty": "No proxy profile saved yet. Native runs against the vendor's own API.",
@@ -884,7 +907,6 @@
"gitHubTitle": "GitHub · GitLab · Gitea · Bitbucket", "gitHubTitle": "GitHub · GitLab · Gitea · Bitbucket",
"gitHubSub": "ED25519 key — the git@… SSH address", "gitHubSub": "ED25519 key — the git@… SSH address",
"gitHubStepAdd": "Add it to your account on the host: GitHub → Settings → SSH and GPG keys → New SSH key. GitLab: Preferences → SSH Keys. Paste the whole line.", "gitHubStepAdd": "Add it to your account on the host: GitHub → Settings → SSH and GPG keys → New SSH key. GitLab: Preferences → SSH Keys. Paste the whole line.",
"gitHubStepClone": "Clone with the SSH address the repository page shows:",
"awsTitle": "AWS CodeCommit", "awsTitle": "AWS CodeCommit",
"awsSub": "RSA 4096 key — AWS accepts only RSA", "awsSub": "RSA 4096 key — AWS accepts only RSA",
"stepCopy": "Copy the public key.", "stepCopy": "Copy the public key.",
@@ -894,7 +916,6 @@
"awsRegion": "Region", "awsRegion": "Region",
"awsSave": "Save", "awsSave": "Save",
"awsSaved": "SSH key ID saved for {{region}}", "awsSaved": "SSH key ID saved for {{region}}",
"awsReady": "Ready. Clone with the plain address CodeCommit shows — in a terminal or in Add workspace → Clone:",
"otherTitle": "Other SSH hosts (advanced)", "otherTitle": "Other SSH hosts (advanced)",
"otherHint": "Only for a host that logs you in with a user name other than git. Then a plain ssh://<host>/… address logs in as that user.", "otherHint": "Only for a host that logs you in with a user name other than git. Then a plain ssh://<host>/… address logs in as that user.",
"otherHost": "Host", "otherHost": "Host",
@@ -1035,6 +1056,10 @@
"title": "Claude Code", "title": "Claude Code",
"description": "Claude Code's own configuration, and what AgentHub reads from it." "description": "Claude Code's own configuration, and what AgentHub reads from it."
}, },
"claudeCodeOwn": {
"title": "Your Claude Code config",
"description": "The skills, agents, plugins, MCP servers and settings your own sessions use."
},
"codex": { "codex": {
"title": "Codex", "title": "Codex",
"description": "Codex's own configuration, and what AgentHub reads from it." "description": "Codex's own configuration, and what AgentHub reads from it."
@@ -1142,7 +1167,7 @@
}, },
"nativePaste": { "nativePaste": {
"label": "Paste images natively", "label": "Paste images natively",
"description": "On this machine, an image pasted into a Claude pane is read by Claude from your clipboard, like in a normal terminal — nothing is uploaded. Turn it off if you open the dashboard through an SSH tunnel: Claude would read the server's clipboard. This browser only." "description": "On this machine, an image pasted into a Claude pane is read by Claude from your clipboard, like in a normal terminal — nothing is uploaded. Needs wl-paste or xclip on Linux; without them the image is uploaded instead. Turn it off if you open the dashboard through an SSH tunnel: Claude would read the server's clipboard. This browser only."
}, },
"terminalFont": { "terminalFont": {
"title": "Terminal font", "title": "Terminal font",
-1
View File
@@ -36,7 +36,6 @@
"autoFixHint": "Open a Claude Code session in the install folder with this error, so an AI agent fixes it and finishes the update.", "autoFixHint": "Open a Claude Code session in the install folder with this error, so an AI agent fixes it and finishes the update.",
"autoFixWorkspace": "AgentHub update", "autoFixWorkspace": "AgentHub update",
"autoFixError": "Could not start the Auto fix session", "autoFixError": "Could not start the Auto fix session",
"backgroundNote": "The update keeps running in the background — the page reloads itself when the new version is up.",
"chipTitle": "Updating to {{version}}…", "chipTitle": "Updating to {{version}}…",
"chipHint": "The update runs in the background — click to show it", "chipHint": "The update runs in the background — click to show it",
"pluginStep.marketplace_update": "Step {{index}}/{{total}}: refreshing the marketplace", "pluginStep.marketplace_update": "Step {{index}}/{{total}}: refreshing the marketplace",
+32 -2
View File
@@ -315,7 +315,9 @@
"doneIdle": "Restarted: {{names}}", "doneIdle": "Restarted: {{names}}",
"errors": { "errors": {
"ETMUXSTUCK": "The server did not stop. Run the `sudo kill -9` command shown, as root, on the server." "ETMUXSTUCK": "The server did not stop. Run the `sudo kill -9` command shown, as root, on the server."
} },
"sessionsCount_one": "{{count}} session",
"sessionsCount_other": "{{count}} sessions"
}, },
"rowMenu": { "rowMenu": {
"openHome": "Open home in Files", "openHome": "Open home in Files",
@@ -331,5 +333,33 @@
"digit": "A digit", "digit": "A digit",
"symbol": "A symbol (e.g. ! - _ @)", "symbol": "A symbol (e.g. ! - _ @)",
"username": "Does not contain the username" "username": "Does not contain the username"
} },
"detail": {
"open": "Open {{name}}'s details",
"lastLogin": "Last sign-in",
"lastSession": "Last activity",
"never": "never",
"spendDaily": "Spend · last 30 days",
"noUsage": "No spend in the last 30 days.",
"pool": "Account pool & quota",
"poolPinned": "pinned to {{account}}",
"poolGroup": "group {{name}}",
"poolShared": "shared pool",
"poolNone": "No account in this tool's pool.",
"poolNotAllowed": "no longer allowed",
"poolTotal7d": "Account total 7 days: {{cost}}",
"noPanes": "Nothing running.",
"recentTitle": "Recent sessions",
"failed": "Could not load this user",
"activeLabel": "Running now",
"activeValue": "{{sessions}} sessions · {{panes}} panes",
"nowTitle": "Running now",
"poolTheirs7d": "theirs 7 d {{cost}}"
},
"activity": "Activity",
"spend": "Spend today",
"spendSub": "7 d {{week}} · 30 d {{month}}",
"sessionsN": "{{n}} sessions",
"workspacesN": "{{n}} workspaces",
"tmuxCol": "tmux"
} }
+1 -34
View File
@@ -1,37 +1,4 @@
{ {
"title": "Quản trị", "title": "Quản trị",
"description": "Tài khoản và máy chủ AgentHub đang chạy.", "description": "Tài khoản và máy chủ AgentHub đang chạy."
"tabs": {
"users": "Người dùng",
"server": "Máy chủ"
},
"server": {
"title": "Máy chủ",
"uptime": "đã chạy {{v}}",
"uptimeDays": "{{d}} ngày {{h}} giờ",
"uptimeHours": "{{h}} giờ {{m}} phút",
"cpu": "CPU",
"cores": "{{n}} lõi · tải {{load}}",
"cpuHelp": "Tỷ lệ trên toàn máy: 100% nghĩa là mọi lõi đều bận.",
"memory": "Bộ nhớ",
"swap": "Swap",
"homesDisk": "Ổ chứa thư mục người dùng",
"homesDiskHelp": "Ổ đĩa chứa thư mục home của các người dùng.",
"free": "trống",
"usageTitle": "Mức sử dụng theo người dùng",
"usageHelp": "Số liệu trực tiếp, lấy mẫu trong {{s}} giây. Một tiến trình được tính cho người dùng khi nó chạy bằng tài khoản Linux của họ hoặc trong một phiên thuộc workspace của họ. Bộ nhớ là RSS; trang nhớ dùng chung được tính cho mỗi tiến trình. Bấm vào một dòng để xem các tiến trình nặng nhất.",
"usageUnavailable": "Mức dùng theo người dùng được đọc từ /proc nên chỉ có trên Linux.",
"who": "Người dùng",
"processes": "Tiến trình",
"home": "Thư mục home",
"sampling": "Đang lấy mẫu…",
"hostGroup": "Tài khoản máy chủ (AgentHub và tiến trình khác của host)",
"systemGroup": "Hệ thống (root và tài khoản khác)",
"disks": "Ổ đĩa",
"mount": "Điểm gắn",
"device": "Thiết bị",
"used": "Đã dùng",
"homesBadge": "home",
"gpus": "GPU"
}
} }
+5
View File
@@ -1,6 +1,10 @@
{ {
"title": "Trình khám phá cấu hình Claude", "title": "Trình khám phá cấu hình Claude",
"subtitle": "Toàn cảnh thiết lập Claude Code của bạn - plugin, skill, subagent, máy chủ MCP, hook và cài đặt.", "subtitle": "Toàn cảnh thiết lập Claude Code của bạn - plugin, skill, subagent, máy chủ MCP, hook và cài đặt.",
"own": {
"label": "Cấu hình Claude Code của bạn — {{home}}",
"subtitle": "Chỉ thiết lập của tài khoản bạn: mọi thay đổi ở đây chạy dưới tài khoản của bạn và chỉ áp dụng cho các phiên của chính bạn."
},
"refresh": "Làm mới", "refresh": "Làm mới",
"refreshing": "Đang làm mới…", "refreshing": "Đang làm mới…",
"lastUpdated": "Cập nhật lần cuối {{time}}", "lastUpdated": "Cập nhật lần cuối {{time}}",
@@ -30,6 +34,7 @@
}, },
"overview": { "overview": {
"rootsTitle": "Thư mục gốc", "rootsTitle": "Thư mục gốc",
"claudeHome": ".claude của bạn",
"projectClaudeDir": "Dự án .claude", "projectClaudeDir": "Dự án .claude",
"projectRoot": "Thư mục dự án", "projectRoot": "Thư mục dự án",
"claudeJson": "claude.json", "claudeJson": "claude.json",
+95 -17
View File
@@ -30,14 +30,6 @@
"since": "đang chờ {{ago}}" "since": "đang chờ {{ago}}"
}, },
"health": { "health": {
"runtime": "Môi trường chạy",
"cores_one": "{{count}} lõi",
"cores_other": "{{count}} lõi",
"uptime": "Thời gian chạy",
"cpu": "CPU (1/5/15 phút)",
"nodeRss": "Bộ nhớ Node (RSS)",
"hostMemory": "Bộ nhớ máy",
"hostMemoryTip": "Bộ nhớ máy: đã dùng {{pct}}%\n{{used}} / {{total}}",
"heap": "Heap V8", "heap": "Heap V8",
"heapTip": "Heap V8: đã dùng {{pct}}%\n{{used}} / giới hạn {{limit}}\nĐã cấp phát: {{allocated}}\nNgoài heap: {{external}}", "heapTip": "Heap V8: đã dùng {{pct}}%\n{{used}} / giới hạn {{limit}}\nĐã cấp phát: {{allocated}}\nNgoài heap: {{external}}",
"storage": "Lưu trữ", "storage": "Lưu trữ",
@@ -50,12 +42,7 @@
"events": "Sự kiện", "events": "Sự kiện",
"score": "Điểm sức khỏe", "score": "Điểm sức khỏe",
"formula": "Công thức", "formula": "Công thức",
"formulaTip": "Công thức chỉ số tổng hợp:\n0.4 × Tỉ lệ thành công ({{success}}%)\n+ 0.25 × Trúng cache ({{cache}}%)\n+ 0.25 × (100 − Tỉ lệ lỗi) ({{noError}}%)\n+ 0.10 × (100 − Heap%) ({{noHeap}}%)\n= {{score}}", "cacheTip": "Tỉ lệ trúng cache transcript: {{pct}}% (chỉ để xem, không tính điểm — cache trống lại mỗi lần server khởi động)",
"scoreTip": "Điểm: {{score}} / 100\n\n• Tỉ lệ thành công (40%): {{success}}%\n• Trúng cache (25%): {{cache}}%\n• Tránh lỗi (25%): {{noError}}%\n• Sức khỏe bộ nhớ (10%): {{noHeap}}%",
"cache": "Cache",
"cacheTip": "Tỉ lệ trúng cache: {{pct}}%\nTrúng: {{hits}}\nTrượt: {{misses}}",
"errors": "Lỗi",
"errorsTip": "Tỉ lệ lỗi: {{pct}}%\n<5% = tốt, 5–15% = cảnh báo, >15% = nghiêm trọng",
"compact": "Nén", "compact": "Nén",
"compactTip": "Số lần nén transcript: {{count}}\nThu gọn cửa sổ ngữ cảnh bằng cách tóm tắt các lượt.", "compactTip": "Số lần nén transcript: {{count}}\nThu gọn cửa sổ ngữ cảnh bằng cách tóm tắt các lượt.",
"saved": "Tiết kiệm", "saved": "Tiết kiệm",
@@ -65,9 +52,10 @@
"modelTip": "{{model}}\nĐầu vào: {{input}}\nĐầu ra: {{output}}\nĐọc cache: {{cacheRead}}\nTỉ trọng: {{pct}}%", "modelTip": "{{model}}\nĐầu vào: {{input}}\nĐầu ra: {{output}}\nĐọc cache: {{cacheRead}}\nTỉ trọng: {{pct}}%",
"noModelData": "Chưa có dữ liệu mô hình", "noModelData": "Chưa có dữ liệu mô hình",
"concurrency": "Đồng thời", "concurrency": "Đồng thời",
"intervals_one": "{{count}} khoảng", "intervals_one": "{{count}} loại agent",
"intervals_other": "{{count}} khoảng", "intervals_other": "{{count}} loại agent",
"concurrencyTip": "Đỉnh: {{peak}} phiên song song\nKhoảng có hoạt động: {{active}}\nTrung bình: {{avg}}", "concurrencyTip": "Đỉnh: {{peak}} phiên song song\nKhoảng có hoạt động: {{active}}\nTrung bình: {{avg}}",
"laneTip": "{{name}}\n{{count}} agent\nChạy từ {{start}}–{{end}}% thời lượng phiên (trung bình)",
"noConcurrencyData": "Chưa có dữ liệu đồng thời", "noConcurrencyData": "Chưa có dữ liệu đồng thời",
"peak": "Đỉnh", "peak": "Đỉnh",
"peakTip": "Đỉnh: {{count}} phiên chạy cùng lúc.", "peakTip": "Đỉnh: {{count}} phiên chạy cùng lúc.",
@@ -99,11 +87,101 @@
"synchronous": "Đồng bộ", "synchronous": "Đồng bộ",
"autoVacuum": "Tự dọn dẹp", "autoVacuum": "Tự dọn dẹp",
"foreignKeys": "Khóa ngoại", "foreignKeys": "Khóa ngoại",
"busyTimeout": "Thời gian chờ khóa" "busyTimeout": "Thời gian chờ khóa",
"groupApp": "AgentHub",
"groupAppHint": "Tình trạng của chính dashboard: điểm, cơ sở dữ liệu, số phiên đồng thời",
"groupMachine": "Máy chủ",
"groupMachineHint": "Máy đang chạy AgentHub: CPU, bộ nhớ, ổ đĩa, tiến trình",
"groupActivity": "Hoạt động agent",
"groupActivityHint": "Agent đã dùng gì: token theo model, công cụ, subagent",
"scoreFormula": "100 trừ đi phần mỗi mục bị trừ:",
"scoreResult": "= {{score}} / 100",
"check": {
"loop": "Phản hồi AgentHub",
"cpu": "Chờ CPU",
"memory": "Chờ bộ nhớ",
"io": "Chờ ổ đĩa",
"ram": "RAM đang dùng",
"disk": "Ổ chứa dữ liệu",
"heap": "V8 heap",
"hooks": "Hook gần nhất",
"agents": "Agent lỗi 24h"
},
"checkTip": {
"loop": "Event loop của AgentHub bị nghẽn bao lâu (p99 trong phút vừa qua). Mọi trang, hook và phím gõ trong terminal đều phải chờ chừng ấy.",
"cpu": "Áp lực CPU (Linux PSI, 10 s gần nhất): tỉ lệ thời gian tác vụ phải chờ CPU rảnh — vài % trở lên là máy bắt đầu chậm.",
"cpuLoad": "Tải mỗi lõi (1 phút) — máy này không có PSI nên ước lượng chờ CPU từ load.",
"memory": "Áp lực bộ nhớ (Linux PSI, 10 s gần nhất): tỉ lệ thời gian tác vụ bị treo để thu hồi hoặc swap bộ nhớ.",
"io": "Áp lực I/O ổ đĩa (Linux PSI, 10 s gần nhất): tỉ lệ thời gian tác vụ phải chờ ổ đĩa — HDD chậm hoặc bận sẽ hiện ở đây.",
"ram": "Bộ nhớ đang dùng (tổng trừ phần còn khả dụng).",
"disk": "Mức đầy của ổ chứa cơ sở dữ liệu AgentHub ({{mount}}).",
"heap": "V8 heap của AgentHub so với giới hạn.",
"hooks": "Số phút từ hook gần nhất, chỉ tính khi có agent đang chạy: im lặng lúc đó nghĩa là dashboard không còn thấy agent.",
"agents": "Agent kết thúc với lỗi trong 24 giờ qua (lỗi / đã kết thúc; cần ít nhất 5)."
},
"checkNa": "Không đo được trên máy này — không trừ điểm.",
"checkThresholds": "Vàng từ {{warn}}{{unit}}, đỏ từ {{bad}}{{unit}}",
"checkPenalty": "Trừ {{n}} / tối đa {{max}} điểm",
"hooksMissing": "chưa cài",
"hooksIdle": "nghỉ",
"minutesAgo": "{{n}} phút trước",
"verdict": {
"ok": "Khỏe",
"warn": "Suy giảm",
"bad": "Có vấn đề"
},
"allOk": "Mọi mục đều ổn",
"badCount_one": "{{count}} đỏ",
"badCount_other": "{{count}} đỏ",
"warnCount_one": "{{count}} vàng",
"warnCount_other": "{{count}} vàng",
"lagGroup": "Độ trễ",
"systemGroup": "Tài nguyên & tín hiệu"
}, },
"errorBoundary": { "errorBoundary": {
"title": "Đã xảy ra lỗi", "title": "Đã xảy ra lỗi",
"body": "Không thể tải trang này. Tải lại thường sẽ khắc phục — ví dụ sau khi dashboard vừa được cập nhật.", "body": "Không thể tải trang này. Tải lại thường sẽ khắc phục — ví dụ sau khi dashboard vừa được cập nhật.",
"reload": "Tải lại" "reload": "Tải lại"
},
"host": {
"uptimeDays": "{{d}} ngày {{h}} giờ",
"uptimeHours": "{{h}} giờ {{m}} phút",
"cpu": "CPU",
"cpuHelp": "Tỷ lệ trên toàn máy: 100% nghĩa là mọi lõi đều bận.",
"memory": "Bộ nhớ",
"swap": "Swap",
"free": "trống",
"usageUnavailable": "Mức dùng theo người dùng được đọc từ /proc nên chỉ có trên Linux.",
"hostGroup": "Tài khoản máy chủ (AgentHub và tiến trình khác của host)",
"systemGroup": "Hệ thống (root và tài khoản khác)",
"homesBadge": "home",
"historyTitle": "Lịch sử",
"historyHelp": "Lưu trong bộ nhớ, mỗi {{s}} giây một điểm, từ khi máy chủ khởi động lúc {{at}}.",
"historyCpu": "CPU (% toàn máy)",
"historyMemory": "Bộ nhớ đã dùng (%)",
"range": "Khoảng thời gian",
"range1h": "1 giờ",
"range6h": "6 giờ",
"range24h": "24 giờ",
"coreN": "CPU {{n}}",
"threads": "{{n}} luồng",
"physical": "{{n}} lõi",
"perSecond": "{{v}}/giây",
"temperatures": "Nhiệt độ",
"topProcesses": "Tiến trình nặng nhất",
"cpuModel": "CPU",
"storageNetwork": "Ổ đĩa & mạng",
"processCount": "{{n}} tiến trình",
"title": "Máy chủ",
"os": "Hệ điều hành",
"uptime": "Thời gian chạy",
"load": "Tải (1/5/15 phút)",
"agenthubTip": "pid {{pid}} · Node {{node}}",
"sampling": "Đang lấy mẫu…",
"sampled": "mẫu {{s}} giây",
"historyNone": "Chưa có lịch sử — server chỉ giữ trong bộ nhớ trên Linux, tính từ lúc khởi động.",
"agenthub": "AgentHub",
"usersTitle": "Người dùng · CPU / RAM hiện tại",
"usersCount": "{{n}} người dùng"
} }
} }
+2 -1
View File
@@ -88,5 +88,6 @@
"now": "bây giờ", "now": "bây giờ",
"users": "Người dùng", "users": "Người dùng",
"admin": "Quản trị", "admin": "Quản trị",
"files": "Quản lý file" "files": "Quản lý file",
"snapshot": "Snapshot"
} }
+1
View File
@@ -73,6 +73,7 @@
"failed": "Không đính kèm được file", "failed": "Không đính kèm được file",
"tooBig": "File quá lớn để tải lên (tối đa {{mb}} MB) — hãy đặt file vào thư mục workspace rồi đính kèm từ Files", "tooBig": "File quá lớn để tải lên (tối đa {{mb}} MB) — hãy đặt file vào thư mục workspace rồi đính kèm từ Files",
"tooBigLocal": "File quá lớn để tải lên (tối đa {{mb}} MB) — hãy đính kèm bằng đường dẫn: + → Chọn file trên máy này", "tooBigLocal": "File quá lớn để tải lên (tối đa {{mb}} MB) — hãy đính kèm bằng đường dẫn: + → Chọn file trên máy này",
"tooBigLocalPaste": "File quá lớn để tải lên (tối đa {{mb}} MB) — hãy copy file trong trình quản lý file rồi dán vào đây (Ctrl+V): file được dán sẽ dùng đường dẫn gốc",
"remove": "Bỏ đính kèm", "remove": "Bỏ đính kèm",
"view": "Xem ảnh" "view": "Xem ảnh"
}, },
+6
View File
@@ -18,6 +18,12 @@
"tableAgents": "Agent", "tableAgents": "Agent",
"tableCost": "Chi phí", "tableCost": "Chi phí",
"tableDirectory": "Thư mục", "tableDirectory": "Thư mục",
"tableUser": "Người dùng",
"tableTool": "AI Tool",
"tableActions": "Thao tác",
"noOwner": "Không có chủ",
"moreActions": "Thao tác khác",
"viewDetails": "Xem chi tiết",
"defaultName": "Phiên ", "defaultName": "Phiên ",
"detail": { "detail": {
"waitingBanner": { "waitingBanner": {
+28 -3
View File
@@ -747,6 +747,29 @@
"description": "Ví dụ 9Router, OpenRouter hoặc gateway tự dựng. Lưu ở đây rồi chọn cho từng session ở ô nhập prompt của workspace.", "description": "Ví dụ 9Router, OpenRouter hoặc gateway tự dựng. Lưu ở đây rồi chọn cho từng session ở ô nhập prompt của workspace.",
"add": "Thêm profile", "add": "Thêm profile",
"config9router": "Config 9router", "config9router": "Config 9router",
"update9router": "Cập nhật 9router",
"update9routerAvailable": "Đã có bản 9router mới",
"update9routerCurrent": "Đang cài",
"update9routerLatest": "Mới nhất",
"update9routerState": "Trạng thái",
"update9routerRunning": "Đang chạy",
"update9routerRunningUnit": "Đang chạy (9router.service)",
"update9routerStopped": "Không chạy",
"update9routerExplain": "Mọi session đang dùng 9router sẽ bị ngắt kết nối trong lúc cập nhật: 9router được tắt, cập nhật bằng npm rồi mở lại ở chế độ ẩn (khay hệ thống, không mở trình duyệt).",
"update9routerExplainStopped": "9router đang không chạy: chỉ cập nhật bằng npm và giữ nguyên trạng thái tắt.",
"update9routerStep": {
"shutdown": "Ngắt kết nối và tắt 9router",
"update": "Chạy cập nhật (npm install -g 9router@latest)",
"relaunch": "Mở lại 9router ở chế độ ẩn"
},
"update9routerDone": "9router {{version}} đang chạy.",
"update9routerDoneStopped": "Đã cài 9router {{version}}, giữ nguyên trạng thái tắt như trước.",
"update9routerFailed": "Cập nhật chưa hoàn tất — xem các bước và log.",
"update9routerLog": "Output của npm",
"update9routerUpToDate": "9router đã là bản mới nhất.",
"update9routerCopy": "Sao chép lệnh",
"update9routerClose": "Đóng",
"update9routerStart": "Cập nhật ngay",
"edit": "Sửa", "edit": "Sửa",
"remove": "Xoá", "remove": "Xoá",
"empty": "Chưa lưu profile proxy nào. Native là chạy thẳng vào API của hãng.", "empty": "Chưa lưu profile proxy nào. Native là chạy thẳng vào API của hãng.",
@@ -872,7 +895,6 @@
"gitHubTitle": "GitHub · GitLab · Gitea · Bitbucket", "gitHubTitle": "GitHub · GitLab · Gitea · Bitbucket",
"gitHubSub": "Key ED25519 — địa chỉ SSH dạng git@…", "gitHubSub": "Key ED25519 — địa chỉ SSH dạng git@…",
"gitHubStepAdd": "Thêm vào tài khoản trên host: GitHub → Settings → SSH and GPG keys → New SSH key. GitLab: Preferences → SSH Keys. Dán nguyên dòng.", "gitHubStepAdd": "Thêm vào tài khoản trên host: GitHub → Settings → SSH and GPG keys → New SSH key. GitLab: Preferences → SSH Keys. Dán nguyên dòng.",
"gitHubStepClone": "Clone bằng địa chỉ SSH mà trang repository hiển thị:",
"awsTitle": "AWS CodeCommit", "awsTitle": "AWS CodeCommit",
"awsSub": "Key RSA 4096 — AWS chỉ nhận RSA", "awsSub": "Key RSA 4096 — AWS chỉ nhận RSA",
"stepCopy": "Copy public key.", "stepCopy": "Copy public key.",
@@ -882,7 +904,6 @@
"awsRegion": "Region", "awsRegion": "Region",
"awsSave": "Lưu", "awsSave": "Lưu",
"awsSaved": "Đã lưu SSH key ID cho {{region}}", "awsSaved": "Đã lưu SSH key ID cho {{region}}",
"awsReady": "Sẵn sàng. Clone bằng địa chỉ trơn mà CodeCommit hiển thị — trong terminal hoặc Add workspace → Clone:",
"otherTitle": "Host SSH khác (nâng cao)", "otherTitle": "Host SSH khác (nâng cao)",
"otherHint": "Chỉ dùng cho host đăng nhập bằng tên user khác git. Sau khi lưu, địa chỉ ssh://<host>/… trơn sẽ đăng nhập bằng user đó.", "otherHint": "Chỉ dùng cho host đăng nhập bằng tên user khác git. Sau khi lưu, địa chỉ ssh://<host>/… trơn sẽ đăng nhập bằng user đó.",
"otherHost": "Host", "otherHost": "Host",
@@ -1023,6 +1044,10 @@
"title": "Claude Code", "title": "Claude Code",
"description": "Cấu hình của Claude Code và những gì AgentHub đọc từ đó." "description": "Cấu hình của Claude Code và những gì AgentHub đọc từ đó."
}, },
"claudeCodeOwn": {
"title": "Cấu hình Claude Code của bạn",
"description": "Skill, agent, plugin, MCP server và thiết lập mà các phiên của chính bạn dùng."
},
"codex": { "codex": {
"title": "Codex", "title": "Codex",
"description": "Cấu hình của Codex và những gì AgentHub đọc từ đó." "description": "Cấu hình của Codex và những gì AgentHub đọc từ đó."
@@ -1130,7 +1155,7 @@
}, },
"nativePaste": { "nativePaste": {
"label": "Dán ảnh kiểu native", "label": "Dán ảnh kiểu native",
"description": "Trên máy này, ảnh dán vào pane Claude được Claude tự đọc từ clipboard, như trong terminal thường — không upload gì. Tắt nếu bạn mở dashboard qua SSH tunnel: khi đó Claude sẽ đọc clipboard của server. Chỉ áp dụng cho trình duyệt này." "description": "Trên máy này, ảnh dán vào pane Claude được Claude tự đọc từ clipboard, như trong terminal thường — không upload gì. Trên Linux cần wl-paste hoặc xclip; thiếu thì ảnh được upload thay thế. Tắt nếu bạn mở dashboard qua SSH tunnel: khi đó Claude sẽ đọc clipboard của server. Chỉ áp dụng cho trình duyệt này."
}, },
"terminalFont": { "terminalFont": {
"title": "Font terminal", "title": "Font terminal",
-1
View File
@@ -36,7 +36,6 @@
"autoFixHint": "Mở một phiên Claude Code trong thư mục cài đặt kèm lỗi này, để AI agent sửa và hoàn tất cập nhật.", "autoFixHint": "Mở một phiên Claude Code trong thư mục cài đặt kèm lỗi này, để AI agent sửa và hoàn tất cập nhật.",
"autoFixWorkspace": "Cập nhật AgentHub", "autoFixWorkspace": "Cập nhật AgentHub",
"autoFixError": "Không mở được phiên Tự sửa", "autoFixError": "Không mở được phiên Tự sửa",
"backgroundNote": "Bản cập nhật vẫn chạy ngầm — trang sẽ tự tải lại khi phiên bản mới sẵn sàng.",
"chipTitle": "Đang cập nhật lên {{version}}…", "chipTitle": "Đang cập nhật lên {{version}}…",
"chipHint": "Bản cập nhật đang chạy ngầm — bấm để xem", "chipHint": "Bản cập nhật đang chạy ngầm — bấm để xem",
"pluginStep.marketplace_update": "Bước {{index}}/{{total}}: làm mới marketplace", "pluginStep.marketplace_update": "Bước {{index}}/{{total}}: làm mới marketplace",
+32 -2
View File
@@ -315,7 +315,9 @@
"doneIdle": "Đã khởi động lại: {{names}}", "doneIdle": "Đã khởi động lại: {{names}}",
"errors": { "errors": {
"ETMUXSTUCK": "Server không dừng. Chạy lệnh `sudo kill -9` được nêu, bằng quyền root, trên máy server." "ETMUXSTUCK": "Server không dừng. Chạy lệnh `sudo kill -9` được nêu, bằng quyền root, trên máy server."
} },
"sessionsCount_one": "{{count}} session",
"sessionsCount_other": "{{count}} session"
}, },
"rowMenu": { "rowMenu": {
"openHome": "Mở home trong Files", "openHome": "Mở home trong Files",
@@ -331,5 +333,33 @@
"digit": "Có chữ số", "digit": "Có chữ số",
"symbol": "Có ký tự đặc biệt (vd. ! - _ @)", "symbol": "Có ký tự đặc biệt (vd. ! - _ @)",
"username": "Không chứa tên đăng nhập" "username": "Không chứa tên đăng nhập"
} },
"detail": {
"open": "Xem chi tiết {{name}}",
"lastLogin": "Đăng nhập gần nhất",
"lastSession": "Hoạt động gần nhất",
"never": "chưa bao giờ",
"spendDaily": "Chi phí · 30 ngày qua",
"noUsage": "Không có chi phí trong 30 ngày qua.",
"pool": "Account pool & quota",
"poolPinned": "ghim vào {{account}}",
"poolGroup": "nhóm {{name}}",
"poolShared": "pool dùng chung",
"poolNone": "Pool của tool này chưa có tài khoản nào.",
"poolNotAllowed": "không còn được dùng",
"poolTotal7d": "Tổng tài khoản 7 ngày: {{cost}}",
"noPanes": "Không có gì đang chạy.",
"recentTitle": "Session gần đây",
"failed": "Không tải được thông tin người dùng",
"activeLabel": "Đang chạy",
"activeValue": "{{sessions}} session · {{panes}} pane",
"nowTitle": "Đang chạy",
"poolTheirs7d": "của họ 7 ngày {{cost}}"
},
"activity": "Hoạt động",
"spend": "Chi phí hôm nay",
"spendSub": "7 ngày {{week}} · 30 ngày {{month}}",
"sessionsN": "{{n}} session",
"workspacesN": "{{n}} workspace",
"tmuxCol": "tmux"
} }
+5
View File
@@ -717,6 +717,11 @@
.xterm-viewport::-webkit-scrollbar { .xterm-viewport::-webkit-scrollbar {
display: none; display: none;
} }
/* xterm 6 draws its own scrollbar (VS Code's scrollable element) instead of
the viewport's native one: the same dead affordance, hidden the same way. */
.xterm .xterm-scrollable-element > .scrollbar {
display: none;
}
/* Settings → Appearance → Text size (lib/fontSize.ts) sets the ROOT font size. /* Settings → Appearance → Text size (lib/fontSize.ts) sets the ROOT font size.
Tailwind's own sizes are rem and follow it; these are the `text-[Npx]` Tailwind's own sizes are rem and follow it; these are the `text-[Npx]`
@@ -49,6 +49,16 @@ describe("artifactView", () => {
expect(isArtifact("server.js", true)).toBe(false); expect(isArtifact("server.js", true)).toBe(false);
}); });
it("never takes the project's own HTML pages for artifacts", () => {
expect(isArtifact("/tmp/out/report.html", true)).toBe(true);
expect(isArtifact("/tmp/out/report.html", false)).toBe(false);
expect(isArtifact("/repo/index.html", true)).toBe(false);
expect(isArtifact("/repo/client/INDEX.HTM", true)).toBe(false);
expect(isArtifact("/repo/public/about.html", true)).toBe(false);
expect(isArtifact("C:\\repo\\templates\\mail.html", true)).toBe(false);
expect(isArtifact("/repo/docs/chart.html", true)).toBe(true);
});
it("rebuilds a file's versions from Write, Edit and MultiEdit", () => { it("rebuilds a file's versions from Write, Edit and MultiEdit", () => {
const msgs = [ const msgs = [
use("1", "Write", { file_path: "/w/a.md", content: "# A\nold line\n" }), use("1", "Write", { file_path: "/w/a.md", content: "# A\nold line\n" }),
@@ -31,9 +31,9 @@ describe("git targets", () => {
await api.lanes.gitAction(gitTarget(3, "s-1"), "stage", { paths: ["a"] }); await api.lanes.gitAction(gitTarget(3, "s-1"), "stage", { paths: ["a"] });
await api.lanes.git(gitTarget(3, null)); await api.lanes.git(gitTarget(3, null));
const urls = spy.mock.calls.map((c) => String(c[0])); const urls = spy.mock.calls.map((c) => String(c[0]));
expect(urls[0]).toMatch(/\/lanes\/3\/git\/changes\?range=work$/); expect(urls[0]).toMatch(/\/workspaces\/3\/git\/changes\?range=work$/);
expect(urls[1]).toMatch(/\/lanes\/3\/git\/changes\?range=work&session=s-1$/); expect(urls[1]).toMatch(/\/workspaces\/3\/git\/changes\?range=work&session=s-1$/);
expect(urls[2]).toMatch(/\/lanes\/3\/git\/stage\?session=s-1$/); expect(urls[2]).toMatch(/\/workspaces\/3\/git\/stage\?session=s-1$/);
expect(urls[3]).toMatch(/\/lanes\/3\/git$/); expect(urls[3]).toMatch(/\/workspaces\/3\/git$/);
}); });
}); });
@@ -0,0 +1,153 @@
/**
* @file healthScore.test.ts
* @description Tests for lib/healthScore.ts — the System Health score: a calm
* machine scores 100, lag (event loop, CPU / memory / I/O pressure) pulls it
* down hard, a check without data costs nothing, CPU falls back to load per
* core without PSI, the data disk is the one holding the database, and hooks
* count only while an agent works.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, expect, it } from "vitest";
import type { HostStats } from "../api";
import { healthChecks, healthScore, scoreLevel, type HealthInput } from "../healthScore";
const GB = 1024 ** 3;
const NOW = Date.parse("2026-10-27T10:00:00Z");
const host = (over: Partial<HostStats> = {}): HostStats => ({
at: "2026-10-27T10:00:00Z",
hostname: "box",
os: { platform: "linux", release: "7", distro: null, arch: "x64" },
uptime: 1000,
cpu: { model: null, cores: 4, load: [0.5, 0.5, 0.5] },
memory: { total: 16 * GB, available: 12 * GB, used: 4 * GB, swapTotal: 0, swapUsed: 0 },
disks: [
{ device: "/dev/a", mount: "/", type: "ext4", kind: "SSD", total: 100, used: 10, free: 90 },
{ device: "/dev/b", mount: "/data", type: "ext4", kind: "HDD", total: 100, used: 99, free: 1 },
],
homesMount: null,
gpus: [],
usage: null,
pressure: {
cpu: { some10: 1, some60: 1, full10: 0 },
memory: { some10: 0, some60: 0, full10: 0 },
io: { some10: 0, some60: 0, full10: 0 },
},
...over,
});
const calm = (over: Partial<HealthInput> = {}): HealthInput => ({
eventLoop: { p50: 10, p99: 20, max: 40 },
heap: { heapUsed: 100, heapTotal: 200, heapLimit: 1000 },
hooksInstalled: true,
activity: {
last_event_at: "2026-10-27T09:59:00Z",
working_agents: 1,
agents_24h: { completed: 50, error: 0 },
},
dbPath: "/home/me/.claude/agenthub/dashboard.db",
host: host(),
now: NOW,
...over,
});
const byKey = (input: HealthInput) => new Map(healthChecks(input).map((c) => [c.key, c]));
describe("healthScore", () => {
it("scores a calm machine 100 with every check ok", () => {
const checks = healthChecks(calm());
expect(healthScore(checks)).toBe(100);
expect(checks.every((c) => c.level === "ok")).toBe(true);
expect(scoreLevel(100)).toBe("ok");
});
it("drops the score when the machine lags", () => {
const lagging = calm({
eventLoop: { p50: 50, p99: 1200, max: 2000 },
host: host({
pressure: {
cpu: { some10: 70, some60: 50, full10: 0 },
memory: { some10: 0, some60: 0, full10: 0 },
io: { some10: 60, some60: 40, full10: 30 },
},
}),
});
const c = byKey(lagging);
expect(c.get("loop")?.level).toBe("bad");
expect(c.get("cpu")?.level).toBe("bad");
expect(c.get("io")?.level).toBe("bad");
const score = healthScore([...c.values()]);
expect(score).toBe(100 - 25 - 20 - 20);
expect(scoreLevel(score)).toBe("bad");
});
it("charges part of the points between amber and red", () => {
const c = byKey(calm({ eventLoop: { p50: 10, p99: 550, max: 600 } })).get("loop");
expect(c?.level).toBe("warn");
expect(c?.penalty).toBe(12.5);
});
it("costs nothing for what was not measured, and estimates CPU from load without PSI", () => {
const c = byKey(
calm({
eventLoop: undefined,
host: host({ pressure: null, cpu: { model: null, cores: 2, load: [5, 1, 1] } }),
})
);
expect(c.get("loop")?.level).toBe("na");
expect(c.get("memory")?.level).toBe("na");
expect(c.get("cpu")?.detail?.source).toBe("load");
expect(c.get("cpu")?.value).toBe(250);
expect(c.get("cpu")?.level).toBe("bad");
});
it("reads the disk that holds the database", () => {
const c = byKey(calm({ dbPath: "/data/agenthub/dashboard.db" })).get("disk");
expect(c?.detail?.mount).toBe("/data");
expect(c?.level).toBe("bad");
expect(byKey(calm({ dbPath: null })).get("disk")?.detail?.mount).toBe("/");
});
it("counts silent hooks only while an agent works; missing hooks are red", () => {
const silent = calm({
activity: {
last_event_at: "2026-10-27T09:20:00Z",
working_agents: 2,
agents_24h: { completed: 0, error: 0 },
},
});
expect(byKey(silent).get("hooks")?.level).toBe("bad");
const idle = calm({
activity: {
last_event_at: "2026-10-27T05:00:00Z",
working_agents: 0,
agents_24h: { completed: 0, error: 0 },
},
});
expect(byKey(idle).get("hooks")).toMatchObject({ level: "ok", penalty: 0 });
expect(byKey(calm({ hooksInstalled: false })).get("hooks")).toMatchObject({
level: "bad",
penalty: 30,
});
});
it("rates agent errors only with enough finished agents", () => {
const few = calm({
activity: {
last_event_at: null,
working_agents: 0,
agents_24h: { completed: 1, error: 2 },
},
});
expect(byKey(few).get("agents")?.level).toBe("na");
const many = calm({
activity: {
last_event_at: null,
working_agents: 0,
agents_24h: { completed: 6, error: 4 },
},
});
expect(byKey(many).get("agents")).toMatchObject({ level: "bad", value: 40 });
});
});
@@ -25,7 +25,7 @@ function stubFetch(body: unknown) {
} }
describe("api.lanes.git", () => { describe("api.lanes.git", () => {
it("requests /api/lanes/<id>/git and returns the parsed facts", async () => { it("requests /api/workspaces/<id>/git and returns the parsed facts", async () => {
const facts = { const facts = {
available: true, available: true,
branch: "feat/x", branch: "feat/x",
@@ -41,7 +41,7 @@ describe("api.lanes.git", () => {
expect(spy).toHaveBeenCalledTimes(1); expect(spy).toHaveBeenCalledTimes(1);
const call = spy.mock.calls[0] as [string, RequestInit | undefined]; const call = spy.mock.calls[0] as [string, RequestInit | undefined];
// endsWith, not toContain: `/gitt` contains `/git` and would slip through. // endsWith, not toContain: `/gitt` contains `/git` and would slip through.
expect(String(call[0]).endsWith("/api/lanes/3/git")).toBe(true); expect(String(call[0]).endsWith("/api/workspaces/3/git")).toBe(true);
expect(call[1]?.method ?? "GET").toBe("GET"); expect(call[1]?.method ?? "GET").toBe("GET");
expect(result).toEqual(facts); expect(result).toEqual(facts);
}); });
+283 -47
View File
@@ -546,6 +546,83 @@ export type ServerUserUsage = AuthUser & {
logins?: number; logins?: number;
lastLoginAt?: string | null; lastLoginAt?: string | null;
}; };
/** A priced slice of a user's spend (`GET /api/users/:id/details`). */
export type UsageSlice = { key: string; cost: number; tokens: number; sessions: number };
/** One pool account as one user's detail sees it: the account's totals and their share. */
export type UserPoolAccount = {
name: string;
kind: string;
email: string | null;
expired: boolean;
/** Their group/pin lets them run on it (false = they ran on it before). */
allowed: boolean;
pinned: boolean;
quota: PoolAccount["quota"] | CodexPoolAccount["quota"];
quotaPercent: number | null;
full: boolean;
livePanes: number;
spend5h: number;
spend7d: number;
userSpend5h: number;
userSpend7d: number;
userTokens5h: number;
userTokens7d: number;
userRuns7d: number;
userLivePanes: number;
userLastAt: string | null;
};
/** `GET /api/users/:id/details` — the Administration page's user detail. */
export type UserDetails = {
user: AuthUser & { disabled: boolean; logins: number; lastLoginAt: string | null };
sessions: { total: number; active: number; firstAt: string | null; lastAt: string | null };
usage: {
today: { cost: number; tokens: number };
last7d: { cost: number; tokens: number };
last30d: { cost: number; tokens: number };
/** 30 local days, the oldest first. */
daily: { date: string; cost: number; tokens: number }[];
byTool: UsageSlice[];
byModel: UsageSlice[];
};
workspaces: {
id: number;
title: string;
cwd: string;
tool: string;
kind: string;
branch: string | null;
status: string;
createdAt: string;
sessions: number;
lastActiveAt: string | null;
livePanes: number;
}[];
panes: {
id: string;
laneId: number;
laneTitle: string;
laneCwd: string;
tool: string;
model: string | null;
account: string | null;
proxy: string | null;
sessionId: string | null;
startedAt: string;
status: string;
}[];
recentSessions: (Session & { tokens?: number })[];
pool: Partial<
Record<
PoolTool,
{
pin: string | null;
group: { id: number; name: string } | null;
accounts: UserPoolAccount[];
}
>
>;
audit: AuditEntry[];
};
/** One member's own tmux server (`GET /api/users/tmux`). */ /** One member's own tmux server (`GET /api/users/tmux`). */
export interface MemberTmuxRow { export interface MemberTmuxRow {
id: number; id: number;
@@ -578,6 +655,8 @@ export interface HostUsageGroup {
kind: "user" | "host" | "system"; kind: "user" | "host" | "system";
userId?: number; userId?: number;
username?: string; username?: string;
/** The user's display name, null when none is set (absent from an older server). */
displayName?: string | null;
/** % of the whole machine's CPU (100 = every core busy) over the sample. */ /** % of the whole machine's CPU (100 = every core busy) over the sample. */
cpu: number; cpu: number;
/** Resident memory, bytes (shared pages count in every process). */ /** Resident memory, bytes (shared pages count in every process). */
@@ -587,13 +666,31 @@ export interface HostUsageGroup {
/** Busiest processes, by the kernel's short name only. */ /** Busiest processes, by the kernel's short name only. */
top: { pid: number; name: string; cpu: number; memory: number }[]; top: { pid: number; name: string; cpu: number; memory: number }[];
} }
/** One `/proc/pressure/<resource>` reading, in % of the window. */
export interface HostPressure {
some10: number;
some60: number;
full10: number;
}
/** `GET /api/host` — hardware, load, disks and per-user usage. */ /** `GET /api/host` — hardware, load, disks and per-user usage. */
export interface HostStats { export interface HostStats {
at: string; at: string;
hostname: string; hostname: string;
os: { platform: string; release: string; distro: string | null; arch: string }; os: { platform: string; release: string; distro: string | null; arch: string };
uptime: number; uptime: number;
cpu: { model: string | null; cores: number; load: number[] }; /** When the machine booted (ISO); absent from an older server. */
bootTime?: string;
cpu: {
model: string | null;
/** Logical CPUs (threads). */
cores: number;
load: number[];
/** Physical cores from /proc/cpuinfo; null when it cannot tell. */
physicalCores?: number | null;
/** Busy % of each logical CPU over the sample; [] off Linux. */
perCore?: number[];
};
memory: { total: number; available: number; used: number; swapTotal: number; swapUsed: number }; memory: { total: number; available: number; used: number; swapTotal: number; swapUsed: number };
disks: { disks: {
device: string; device: string;
@@ -609,6 +706,46 @@ export interface HostStats {
gpus: { name: string; utilization: number; memoryUsed: number; memoryTotal: number }[]; gpus: { name: string; utilization: number; memoryUsed: number; memoryTotal: number }[];
/** null off Linux. */ /** null off Linux. */
usage: { cpu: number; sampleMs: number; groups: HostUsageGroup[] } | null; usage: { cpu: number; sampleMs: number; groups: HostUsageGroup[] } | null;
/** Bytes/s per network interface over the sample (`lo` left out). */
network?: { name: string; rx: number; tx: number }[];
/** Bytes/s per whole disk over the sample. */
diskIo?: { name: string; read: number; write: number }[];
/** `/sys/class/hwmon` sensors; [] when none. */
temperatures?: { chip: string; label: string; celsius: number }[];
/** Linux PSI (`/proc/pressure/*`): % of the last 10 / 60 s tasks stalled on
* each resource; null without PSI (or off Linux). */
pressure?: {
cpu: HostPressure | null;
memory: HostPressure | null;
io: HostPressure | null;
} | null;
/** The ten busiest processes machine-wide; `group` is a `HostUsageGroup` key. */
topProcesses?: { pid: number; name: string; cpu: number; memory: number; group: string }[];
/** The dashboard's own process. */
process?: { pid: number; node: string; uptime: number; rss: number; heapUsed: number };
}
/** One point of `GET /api/host/history`. */
export interface HostHistoryPoint {
at: string;
/** Machine CPU %, null when it could not be sampled. */
cpu: number | null;
/** Memory used, % of total. */
memory: number | null;
memoryUsed: number;
swapUsed: number;
load1: number | null;
/** Per user id: CPU % and resident bytes (users with any use only). */
users: Record<string, { cpu: number; memory: number }>;
}
export type HostHistoryRange = "1h" | "6h" | "24h";
/** `GET /api/host/history` — kept in memory since the server started. */
export interface HostHistory {
range: HostHistoryRange;
intervalMs: number;
/** False when this server keeps no history (local mode, not Linux). */
running: boolean;
startedAt: string | null;
points: HostHistoryPoint[];
} }
/** One user's home folder on disk (`GET /api/users/storage`); null = not measured. */ /** One user's home folder on disk (`GET /api/users/storage`); null = not measured. */
@@ -915,7 +1052,7 @@ function gitUrl(target: GitTarget, sub: string, q?: URLSearchParams): string {
if (typeof target !== "number" && target.source) params.set("source", String(target.source)); if (typeof target !== "number" && target.source) params.set("source", String(target.source));
if (typeof target !== "number" && target.repo) params.set("repo", target.repo); if (typeof target !== "number" && target.repo) params.set("repo", target.repo);
const qs = params.toString(); const qs = params.toString();
return `/lanes/${lane}/git${sub}${qs ? `?${qs}` : ""}`; return `/workspaces/${lane}/git${sub}${qs ? `?${qs}` : ""}`;
} }
/** Where the File Explorer works: a workspace (as {@link GitTarget}) or a user's home. */ /** Where the File Explorer works: a workspace (as {@link GitTarget}) or a user's home. */
@@ -929,7 +1066,7 @@ function fsUrl(place: FsPlace, sub: string, q?: URLSearchParams): string {
const qs = new URLSearchParams(q).toString(); const qs = new URLSearchParams(q).toString();
return `/homes/${encodeURIComponent(place.home)}/fs${sub}${qs ? `?${qs}` : ""}`; return `/homes/${encodeURIComponent(place.home)}/fs${sub}${qs ? `?${qs}` : ""}`;
} }
return gitUrl(place, sub, q).replace(/^(\/lanes\/\d+)\/git/, "$1/fs"); return gitUrl(place, sub, q).replace(/^(\/workspaces\/\d+)\/git/, "$1/fs");
} }
/** /**
@@ -1023,6 +1160,13 @@ export function gitTarget(
return session ? { lane: laneId, session } : laneId; return session ? { lane: laneId, session } : laneId;
} }
/** A file the dashboard machine's file manager holds, by its own path. */
export interface FileManagerFile {
path: string;
size: number | null;
dir: boolean;
}
export const api = { export const api = {
// ───────────────────────── Server mode login (routes/auth.js) ───────────────────────── // ───────────────────────── Server mode login (routes/auth.js) ─────────────────────────
auth: { auth: {
@@ -1083,9 +1227,11 @@ export const api = {
/** DELETE /api/me/avatar — back to the initials. */ /** DELETE /api/me/avatar — back to the initials. */
removeAvatar: () => request<MyAccount>("/me/avatar", { method: "DELETE" }), removeAvatar: () => request<MyAccount>("/me/avatar", { method: "DELETE" }),
}, },
/** GET /api/host — the Server tab: hardware and each user's CPU/memory share (staff). */ /** GET /api/host — System Health's machine cards and the Administration users table's CPU/RAM (local mode, or staff). */
host: { host: {
stats: () => request<HostStats>("/host"), stats: () => request<HostStats>("/host"),
history: (range: HostHistoryRange = "1h") =>
request<HostHistory>(`/host/history?range=${range}`),
}, },
// ───────────────────────── Server mode staff views (routes/users.js) ───────────────────────── // ───────────────────────── Server mode staff views (routes/users.js) ─────────────────────────
users: { users: {
@@ -1095,6 +1241,9 @@ export const api = {
/** GET /api/users — every account with its recent usage (admin/analyst). */ /** GET /api/users — every account with its recent usage (admin/analyst). */
list: () => list: () =>
request<{ users: ServerUserUsage[] }>(`/users?tz_offset=${new Date().getTimezoneOffset()}`), request<{ users: ServerUserUsage[] }>(`/users?tz_offset=${new Date().getTimezoneOffset()}`),
/** GET /api/users/:id/details — one account in full (admin/analyst). */
details: (id: number) =>
request<UserDetails>(`/users/${id}/details?tz_offset=${new Date().getTimezoneOffset()}`),
/** GET /api/users/audit — staff reads of members' sessions and admins' /** GET /api/users/audit — staff reads of members' sessions and admins'
* account changes, newest first. */ * account changes, newest first. */
audit: () => request<{ entries: AuditEntry[] }>("/users/audit"), audit: () => request<{ entries: AuditEntry[] }>("/users/audit"),
@@ -2003,10 +2152,13 @@ export const api = {
purged_agents: number; purged_agents: number;
}>("/settings/cleanup", { method: "POST", body: JSON.stringify(params) }), }>("/settings/cleanup", { method: "POST", body: JSON.stringify(params) }),
gc: (body: { dryRun?: boolean } = {}) => gc: (body: { dryRun?: boolean } = {}) =>
request<{ reaped: number[]; capped: { path: string; sizeBefore: number }[] }>("/lanes/gc", { request<{ reaped: number[]; capped: { path: string; sizeBefore: number }[] }>(
method: "POST", "/workspaces/gc",
body: JSON.stringify(body), {
}), method: "POST",
body: JSON.stringify(body),
}
),
}, },
// ─────────────────────────────── Workflows API ────────────────────────────── // ─────────────────────────────── Workflows API ──────────────────────────────
@@ -2152,7 +2304,9 @@ export const api = {
* *
* @returns {@link CostResult} — the aggregate cost breakdown across sessions. * @returns {@link CostResult} — the aggregate cost breakdown across sessions.
*/ */
totalCost: (opts: { lane?: number; date?: string; tool?: string; range?: DateRange } = {}) => { totalCost: (
opts: { lane?: number; date?: string; tool?: string; range?: DateRange; mine?: boolean } = {}
) => {
// Scope the aggregate to the active data-scope, exactly like the sessions / // Scope the aggregate to the active data-scope, exactly like the sessions /
// stats / analytics endpoints — otherwise switching the Data scope selector // stats / analytics endpoints — otherwise switching the Data scope selector
// left the Dashboard "total cost" showing the un-narrowed global total. // left the Dashboard "total cost" showing the un-narrowed global total.
@@ -2162,6 +2316,8 @@ export const api = {
// `date` (local YYYY-MM-DD): only that day's usage, breakdown included. // `date` (local YYYY-MM-DD): only that day's usage, breakdown included.
if (opts.date) qs.set("date", opts.date); if (opts.date) qs.set("date", opts.date);
if (opts.tool) qs.set("tool", opts.tool); if (opts.tool) qs.set("tool", opts.tool);
// `mine`: only the caller's own sessions, even for an admin/analyst (server mode).
if (opts.mine) qs.set("mine", "1");
// `range`: total, breakdown and by_tool for those local days only. // `range`: total, breakdown and by_tool for those local days only.
applyRange(qs, opts.range); applyRange(qs, opts.range);
applyScope(qs); applyScope(qs);
@@ -2286,6 +2442,13 @@ export const api = {
* CLAUDE.md/auto-memory, marketplaces, keybindings, and the statusline * CLAUDE.md/auto-memory, marketplaces, keybindings, and the statusline
* script - for the dashboard's "CC Config" explorer/editor pages. */ * script - for the dashboard's "CC Config" explorer/editor pages. */
ccConfig: { ccConfig: {
/**
* GET /api/cc-config/target - whose Claude Code config this login manages
* here: `own` (server mode, their own account's), `host` (an admin, local
* mode, or an analyst's read-only view), `none` (no account: the page is
* not theirs).
*/
target: () => request<CcTarget>("/cc-config/target"),
/** /**
* GET /api/cc-config/overview - counts of every artifact kind, for the * GET /api/cc-config/overview - counts of every artifact kind, for the
* explorer's landing page. * explorer's landing page.
@@ -3263,6 +3426,12 @@ export const api = {
list: () => request<{ items: AiProxy[] }>("/ai-proxies"), list: () => request<{ items: AiProxy[] }>("/ai-proxies"),
/** GET /api/ai-proxies/9router - whether a `9router` binary is installed on the server. */ /** GET /api/ai-proxies/9router - whether a `9router` binary is installed on the server. */
nineRouter: () => request<{ installed: boolean }>("/ai-proxies/9router"), nineRouter: () => request<{ installed: boolean }>("/ai-proxies/9router"),
/** GET /api/ai-proxies/9router/update - versions, whether it runs, why an update would be refused, the job. */
nineRouterUpdateStatus: (fresh = false) =>
request<NineRouterUpdateStatus>(`/ai-proxies/9router/update${fresh ? "?fresh=1" : ""}`),
/** POST /api/ai-proxies/9router/update - shut 9router down, update it, start it again hidden (admin). */
nineRouterUpdate: () =>
request<NineRouterUpdateJob>("/ai-proxies/9router/update", { method: "POST" }),
/** POST /api/ai-proxies - save a new profile. */ /** POST /api/ai-proxies - save a new profile. */
create: (body: AiProxyInput) => create: (body: AiProxyInput) =>
request<AiProxy>("/ai-proxies", { method: "POST", body: JSON.stringify(body) }), request<AiProxy>("/ai-proxies", { method: "POST", body: JSON.stringify(body) }),
@@ -3350,6 +3519,27 @@ export const api = {
method: "POST", method: "POST",
body: JSON.stringify({ paths }), body: JSON.stringify({ paths }),
}), }),
/** POST /api/run/clipboard-files — the files a file manager's Copy put on
* the dashboard machine's clipboard, by their own paths (`size` null for a
* folder); 403 EREMOTE unless local mode and a loopback browser. */
clipboardFiles: () =>
request<{ files: FileManagerFile[] }>("/run/clipboard-files", {
method: "POST",
body: "{}",
}),
/** POST /api/run/selected-files — the files selected in that machine's
* file manager (Explorer, Finder): what a drag from it carries. Same
* shape and refusal as `clipboardFiles`. */
selectedFiles: () =>
request<{ files: FileManagerFile[] }>("/run/selected-files", {
method: "POST",
body: "{}",
}),
/** GET /api/run/native-paste — whether a `claude` pane can read this
* machine's clipboard itself (a reader installed, a display reachable);
* false for a remote browser and in server mode. `key`: the chord
* `claude` reads an image with on that machine (Alt+V on Windows). */
nativePaste: () => request<{ native: boolean; key?: "ctrl+v" | "alt+v" }>("/run/native-paste"),
/** POST /api/run - start (or adopt, if already live) a lane's terminal run. */ /** POST /api/run - start (or adopt, if already live) a lane's terminal run. */
start: (args: RunStartArgs) => start: (args: RunStartArgs) =>
request<RunHandle>("/run", { method: "POST", body: JSON.stringify(args) }), request<RunHandle>("/run", { method: "POST", body: JSON.stringify(args) }),
@@ -3758,7 +3948,7 @@ export const api = {
* GET /api/lanes — list all lanes with pipeline progress and liveness. * GET /api/lanes — list all lanes with pipeline progress and liveness.
* @returns `{ lanes, counts }` — all {@link Lane} records and aggregate counts. * @returns `{ lanes, counts }` — all {@link Lane} records and aggregate counts.
*/ */
list: () => request<{ lanes: Lane[]; counts: LaneCounts }>("/lanes"), list: () => request<{ lanes: Lane[]; counts: LaneCounts }>("/workspaces"),
/** /**
* POST /api/lanes/ensure — ensure a lane exists for the given cwd. * POST /api/lanes/ensure — ensure a lane exists for the given cwd.
* @param body The cwd and optional title. When `cwd` is omitted the server * @param body The cwd and optional title. When `cwd` is omitted the server
@@ -3774,7 +3964,7 @@ export const api = {
/** Only a lane on THIS directory counts, not one on a parent of it. */ /** Only a lane on THIS directory counts, not one on a parent of it. */
exact?: boolean; exact?: boolean;
}) => }) =>
request<{ lane: Lane; created: boolean }>("/lanes/ensure", { request<{ lane: Lane; created: boolean }>("/workspaces/ensure", {
method: "POST", method: "POST",
body: JSON.stringify(body), body: JSON.stringify(body),
}), }),
@@ -3797,7 +3987,7 @@ export const api = {
/** This clone's name for {@link lanes.cloneProgress}. */ /** This clone's name for {@link lanes.cloneProgress}. */
progressToken?: string; progressToken?: string;
}) => }) =>
request<{ lane: Lane; created: boolean; path: string }>("/lanes/clone", { request<{ lane: Lane; created: boolean; path: string }>("/workspaces/clone", {
method: "POST", method: "POST",
body: JSON.stringify(body), body: JSON.stringify(body),
}), }),
@@ -3807,7 +3997,7 @@ export const api = {
* carrying `progressToken` arrives, and a minute after it ends. * carrying `progressToken` arrives, and a minute after it ends.
*/ */
cloneProgress: (token: string) => cloneProgress: (token: string) =>
request<CloneProgressView>(`/lanes/clone/progress/${encodeURIComponent(token)}`), request<CloneProgressView>(`/workspaces/clone/progress/${encodeURIComponent(token)}`),
/** /**
* GET /api/lanes/pipelines — every pipeline template the server can render * GET /api/lanes/pipelines — every pipeline template the server can render
* a lane against, built-in plus any `DASHBOARD_PIPELINES_DIR` override. * a lane against, built-in plus any `DASHBOARD_PIPELINES_DIR` override.
@@ -3815,7 +4005,7 @@ export const api = {
*/ */
pipelines: () => pipelines: () =>
request<{ pipelines: { id: string; name: string; nodes: { id: string }[] }[] }>( request<{ pipelines: { id: string; name: string; nodes: { id: string }[] }[] }>(
"/lanes/pipelines" "/workspaces/pipelines"
), ),
/** /**
* GET /api/lanes/pipeline-stats — per-node pass counts aggregated over every * GET /api/lanes/pipeline-stats — per-node pass counts aggregated over every
@@ -3825,7 +4015,7 @@ export const api = {
*/ */
pipelineStats: (laneId?: number | null) => pipelineStats: (laneId?: number | null) =>
request<PipelineStats>( request<PipelineStats>(
`/lanes/pipeline-stats${laneId ? `?lane=${encodeURIComponent(laneId)}` : ""}` `/workspaces/pipeline-stats${laneId ? `?lane=${encodeURIComponent(laneId)}` : ""}`
), ),
/** /**
* GET /api/lanes/branches — a candidate source repo's local branches. * GET /api/lanes/branches — a candidate source repo's local branches.
@@ -3837,7 +4027,7 @@ export const api = {
*/ */
branches: (repo: string) => branches: (repo: string) =>
request<{ branches: string[]; current: string | null; worktrees: LaneWorktree[] }>( request<{ branches: string[]; current: string | null; worktrees: LaneWorktree[] }>(
`/lanes/branches?repo=${encodeURIComponent(repo)}` `/workspaces/branches?repo=${encodeURIComponent(repo)}`
), ),
/** /**
* POST /api/lanes/worktree — provision a dashboard-managed git worktree as * POST /api/lanes/worktree — provision a dashboard-managed git worktree as
@@ -3855,7 +4045,7 @@ export const api = {
pipeline?: string; pipeline?: string;
tool?: LaneTool; tool?: LaneTool;
}) => }) =>
request<{ lane: Lane }>("/lanes/worktree", { request<{ lane: Lane }>("/workspaces/worktree", {
method: "POST", method: "POST",
body: JSON.stringify(body), body: JSON.stringify(body),
}), }),
@@ -3890,7 +4080,7 @@ export const api = {
file?: boolean; file?: boolean;
mtime?: number | null; mtime?: number | null;
}[]; }[];
}>(`/lanes/browse${qs ? `?${qs}` : ""}`); }>(`/workspaces/browse${qs ? `?${qs}` : ""}`);
}, },
/** /**
* POST /api/lanes/browse/mkdir — the folder browser's New folder: one * POST /api/lanes/browse/mkdir — the folder browser's New folder: one
@@ -3898,7 +4088,7 @@ export const api = {
* `EFOLDER` (outside the caller's own folder). * `EFOLDER` (outside the caller's own folder).
*/ */
mkdir: (parent: string, name: string) => mkdir: (parent: string, name: string) =>
request<{ path: string }>("/lanes/browse/mkdir", { request<{ path: string }>("/workspaces/browse/mkdir", {
method: "POST", method: "POST",
body: JSON.stringify({ parent, name }), body: JSON.stringify({ parent, name }),
}), }),
@@ -3912,7 +4102,7 @@ export const api = {
* @returns `{ path }`, or `{ cancelled: true }` when the user cancelled. * @returns `{ path }`, or `{ cancelled: true }` when the user cancelled.
*/ */
pickPath: (kind: "dir" | "file", start?: string) => pickPath: (kind: "dir" | "file", start?: string) =>
request<{ path?: string; cancelled?: boolean }>("/lanes/pick-path", { request<{ path?: string; cancelled?: boolean }>("/workspaces/pick-path", {
method: "POST", method: "POST",
body: JSON.stringify({ kind, start }), body: JSON.stringify({ kind, start }),
}), }),
@@ -3921,7 +4111,7 @@ export const api = {
* @param id The lane id. * @param id The lane id.
* @returns `{ lane }` — the {@link Lane} record. * @returns `{ lane }` — the {@link Lane} record.
*/ */
get: (id: number) => request<{ lane: Lane }>(`/lanes/${id}`), get: (id: number) => request<{ lane: Lane }>(`/workspaces/${id}`),
/** /**
* GET /api/lanes/:id/git — the lane's working-copy facts. * GET /api/lanes/:id/git — the lane's working-copy facts.
* @param id The lane id. * @param id The lane id.
@@ -4055,7 +4245,7 @@ export const api = {
*/ */
removeSessionWorktree: (id: number, session: string) => removeSessionWorktree: (id: number, session: string) =>
request<{ removed: true; branch: string | null; branchKept: boolean }>( request<{ removed: true; branch: string | null; branchKept: boolean }>(
`/lanes/${id}/session-worktree/remove`, `/workspaces/${id}/session-worktree/remove`,
{ method: "POST", body: JSON.stringify({ session, confirm: true }) } { method: "POST", body: JSON.stringify({ session, confirm: true }) }
), ),
gitBranches: (id: GitTarget) => gitBranches: (id: GitTarget) =>
@@ -4149,7 +4339,7 @@ export const api = {
* @param id The lane id. * @param id The lane id.
* @returns {@link LaneRuntime} * @returns {@link LaneRuntime}
*/ */
runtime: (id: number) => request<LaneRuntime>(`/lanes/${id}/runtime`), runtime: (id: number) => request<LaneRuntime>(`/workspaces/${id}/runtime`),
/** /**
* POST /api/lanes/:id/up — boot the lane's stack through its profile hooks. * POST /api/lanes/:id/up — boot the lane's stack through its profile hooks.
* Returns 202: the boot runs in the background and streams progress as * Returns 202: the boot runs in the background and streams progress as
@@ -4158,7 +4348,7 @@ export const api = {
* @param body `build: false` reuses an existing build. * @param body `build: false` reuses an existing build.
*/ */
up: (id: number, body: { build?: boolean } = {}) => up: (id: number, body: { build?: boolean } = {}) =>
request<{ ok: true; laneId: number }>(`/lanes/${id}/up`, { request<{ ok: true; laneId: number }>(`/workspaces/${id}/up`, {
method: "POST", method: "POST",
body: JSON.stringify(body), body: JSON.stringify(body),
}), }),
@@ -4169,7 +4359,7 @@ export const api = {
* @returns The pids stopped and the resulting runtime facts. * @returns The pids stopped and the resulting runtime facts.
*/ */
down: (id: number) => down: (id: number) =>
request<{ ok: true; killed: number[]; runtime: LaneRuntime }>(`/lanes/${id}/down`, { request<{ ok: true; killed: number[]; runtime: LaneRuntime }>(`/workspaces/${id}/down`, {
method: "POST", method: "POST",
body: JSON.stringify({}), body: JSON.stringify({}),
}), }),
@@ -4179,7 +4369,7 @@ export const api = {
* @returns `{ lane }` — the created {@link Lane}. * @returns `{ lane }` — the created {@link Lane}.
*/ */
create: (body: { title?: string; cwd: string; branch?: string; pipeline?: string }) => create: (body: { title?: string; cwd: string; branch?: string; pipeline?: string }) =>
request<{ lane: Lane }>("/lanes", { method: "POST", body: JSON.stringify(body) }), request<{ lane: Lane }>("/workspaces", { method: "POST", body: JSON.stringify(body) }),
/** /**
* PATCH /api/lanes/:id — partial update of a lane. * PATCH /api/lanes/:id — partial update of a lane.
* @param id The lane id. * @param id The lane id.
@@ -4188,17 +4378,17 @@ export const api = {
*/ */
/** GET /api/lanes/:id/sources — the workspace's instructions, extra /** GET /api/lanes/:id/sources — the workspace's instructions, extra
* folders and context files, and whether its tool can take them. */ * folders and context files, and whether its tool can take them. */
sources: (id: number) => request<LaneSourcesView>(`/lanes/${id}/sources`), sources: (id: number) => request<LaneSourcesView>(`/workspaces/${id}/sources`),
/** GET /api/lanes/:id/repos — the git repos inside the workspace folder /** GET /api/lanes/:id/repos — the git repos inside the workspace folder
* (a parent folder of several repos), each a Changes section read with * (a parent folder of several repos), each a Changes section read with
* `?repo=<path>`; `root` = the folder itself is in a repo. */ * `?repo=<path>`; `root` = the folder itself is in a repo. */
repos: (id: number, refresh = false) => repos: (id: number, refresh = false) =>
request<{ root: boolean; items: { path: string; name: string }[]; truncated: boolean }>( request<{ root: boolean; items: { path: string; name: string }[]; truncated: boolean }>(
`/lanes/${id}/repos${refresh ? "?refresh=1" : ""}` `/workspaces/${id}/repos${refresh ? "?refresh=1" : ""}`
), ),
/** PUT /api/lanes/:id/instructions — appended to every new pane's system prompt. */ /** PUT /api/lanes/:id/instructions — appended to every new pane's system prompt. */
setInstructions: (id: number, instructions: string | null) => setInstructions: (id: number, instructions: string | null) =>
request<LaneSourcesView>(`/lanes/${id}/instructions`, { request<LaneSourcesView>(`/workspaces/${id}/instructions`, {
method: "PUT", method: "PUT",
body: JSON.stringify({ instructions }), body: JSON.stringify({ instructions }),
}), }),
@@ -4216,19 +4406,19 @@ export const api = {
progressToken?: string; progressToken?: string;
} }
) => ) =>
request<LaneSourcesView & { path: string }>(`/lanes/${id}/sources/clone`, { request<LaneSourcesView & { path: string }>(`/workspaces/${id}/sources/clone`, {
method: "POST", method: "POST",
body: JSON.stringify(body), body: JSON.stringify(body),
}), }),
/** POST /api/lanes/:id/sources/folder — an extra folder the agent may use. */ /** POST /api/lanes/:id/sources/folder — an extra folder the agent may use. */
addFolder: (id: number, path: string) => addFolder: (id: number, path: string) =>
request<LaneSourcesView>(`/lanes/${id}/sources/folder`, { request<LaneSourcesView>(`/workspaces/${id}/sources/folder`, {
method: "POST", method: "POST",
body: JSON.stringify({ path }), body: JSON.stringify({ path }),
}), }),
/** POST /api/lanes/:id/sources/file — upload one context file (≤ 10 MB). */ /** POST /api/lanes/:id/sources/file — upload one context file (≤ 10 MB). */
addFile: (id: number, file: File) => addFile: (id: number, file: File) =>
request<LaneSourcesView>(`/lanes/${id}/sources/file`, { request<LaneSourcesView>(`/workspaces/${id}/sources/file`, {
method: "POST", method: "POST",
body: file, body: file,
headers: { headers: {
@@ -4239,11 +4429,14 @@ export const api = {
/** DELETE /api/lanes/:id/sources/:sourceId — a folder stays on disk; an /** DELETE /api/lanes/:id/sources/:sourceId — a folder stays on disk; an
* uploaded file's AgentHub copy is deleted. */ * uploaded file's AgentHub copy is deleted. */
removeSource: (id: number, sourceId: number) => removeSource: (id: number, sourceId: number) =>
request<LaneSourcesView>(`/lanes/${id}/sources/${sourceId}`, { method: "DELETE" }), request<LaneSourcesView>(`/workspaces/${id}/sources/${sourceId}`, { method: "DELETE" }),
/** GET /api/lanes/:id/memory — CLAUDE.md/AGENTS.md and Claude's auto memory, read-only. */ /** GET /api/lanes/:id/memory — CLAUDE.md/AGENTS.md and Claude's auto memory, read-only. */
memory: (id: number) => request<{ files: LaneMemoryFile[] }>(`/lanes/${id}/memory`), memory: (id: number) => request<{ files: LaneMemoryFile[] }>(`/workspaces/${id}/memory`),
update: (id: number, patch: Partial<Lane>) => update: (id: number, patch: Partial<Lane>) =>
request<{ lane: Lane }>(`/lanes/${id}`, { method: "PATCH", body: JSON.stringify(patch) }), request<{ lane: Lane }>(`/workspaces/${id}`, {
method: "PATCH",
body: JSON.stringify(patch),
}),
/** /**
* POST /api/lanes/:id/stage — advance the pipeline stage. * POST /api/lanes/:id/stage — advance the pipeline stage.
* @param id The lane id. * @param id The lane id.
@@ -4254,14 +4447,17 @@ export const api = {
id: number, id: number,
body: { stage: string; status?: string; evidence?: string; note?: string; result?: string } body: { stage: string; status?: string; evidence?: string; note?: string; result?: string }
) => ) =>
request<{ lane: Lane }>(`/lanes/${id}/stage`, { method: "POST", body: JSON.stringify(body) }), request<{ lane: Lane }>(`/workspaces/${id}/stage`, {
method: "POST",
body: JSON.stringify(body),
}),
/** /**
* GET /api/lanes/:id/preflight?action=reset|remove|purge — read the * GET /api/lanes/:id/preflight?action=reset|remove|purge — read the
* action-specific facts that a destructive request must echo as `expect`. * action-specific facts that a destructive request must echo as `expect`.
*/ */
preflight: (id: number, action: "reset" | "remove" | "purge") => preflight: (id: number, action: "reset" | "remove" | "purge") =>
request<import("./types").LanePreflight>( request<import("./types").LanePreflight>(
`/lanes/${id}/preflight?${new URLSearchParams({ action }).toString()}` `/workspaces/${id}/preflight?${new URLSearchParams({ action }).toString()}`
), ),
/** /**
* POST /api/lanes/:id/:action — trigger a lane action (start, stop, clear, remove, etc.). * POST /api/lanes/:id/:action — trigger a lane action (start, stop, clear, remove, etc.).
@@ -4273,7 +4469,7 @@ export const api = {
action: (id: number, action: string, body: Record<string, unknown> = {}) => action: (id: number, action: string, body: Record<string, unknown> = {}) =>
// `run_id` answers a `start`: the session it opened, which for a split // `run_id` answers a `start`: the session it opened, which for a split
// pane's slot (`body.slot` 1–3) is not the lane's own `run_id`. // pane's slot (`body.slot` 1–3) is not the lane's own `run_id`.
request<{ lane?: Lane; ok?: boolean; run_id?: string }>(`/lanes/${id}/${action}`, { request<{ lane?: Lane; ok?: boolean; run_id?: string }>(`/workspaces/${id}/${action}`, {
method: "POST", method: "POST",
body: JSON.stringify(body), body: JSON.stringify(body),
}), }),
@@ -4284,7 +4480,7 @@ export const api = {
*/ */
features: { features: {
list: (id: number) => list: (id: number) =>
request<{ features: import("./types").LaneFeature[] }>(`/lanes/${id}/features`), request<{ features: import("./types").LaneFeature[] }>(`/workspaces/${id}/features`),
/** /**
* GET /api/lanes/:id/features/:slug — fetch an archived feature snapshot. * GET /api/lanes/:id/features/:slug — fetch an archived feature snapshot.
* @param id The lane id. * @param id The lane id.
@@ -4293,7 +4489,7 @@ export const api = {
*/ */
show: (id: number, slug: string) => show: (id: number, slug: string) =>
request<{ feature: import("./types").LaneFeature }>( request<{ feature: import("./types").LaneFeature }>(
`/lanes/${id}/features/${encodeURIComponent(slug)}` `/workspaces/${id}/features/${encodeURIComponent(slug)}`
), ),
}, },
/** /**
@@ -4303,7 +4499,7 @@ export const api = {
*/ */
proof: { proof: {
list: (id: number) => list: (id: number) =>
request<{ features: import("./types").ProofFeature[] }>(`/lanes/${id}/proof`), request<{ features: import("./types").ProofFeature[] }>(`/workspaces/${id}/proof`),
/** /**
* URL for a proof image, ready to use in an <img src>. * URL for a proof image, ready to use in an <img src>.
* @param id The lane id. * @param id The lane id.
@@ -4313,7 +4509,7 @@ export const api = {
* @returns A URL string pointing to the image. * @returns A URL string pointing to the image.
*/ */
imageUrl: (id: number, slug: string, group: string, file: string): string => imageUrl: (id: number, slug: string, group: string, file: string): string =>
`/api/lanes/${id}/proof/${encodeURIComponent(slug)}/${encodeURIComponent(group)}/${encodeURIComponent(file)}`, `/api/workspaces/${id}/proof/${encodeURIComponent(slug)}/${encodeURIComponent(group)}/${encodeURIComponent(file)}`,
/** /**
* DELETE /api/lanes/:id/proof/:slug — delete proof entries for a feature. * DELETE /api/lanes/:id/proof/:slug — delete proof entries for a feature.
* @param id The lane id. * @param id The lane id.
@@ -4322,19 +4518,19 @@ export const api = {
* @returns `{ deleted }` — count of deleted images. * @returns `{ deleted }` — count of deleted images.
*/ */
delete: (id: number, slug: string, body: { group?: string; images?: string[] }) => delete: (id: number, slug: string, body: { group?: string; images?: string[] }) =>
request<{ deleted: number }>(`/lanes/${id}/proof/${encodeURIComponent(slug)}`, { request<{ deleted: number }>(`/workspaces/${id}/proof/${encodeURIComponent(slug)}`, {
method: "DELETE", method: "DELETE",
body: JSON.stringify(body), body: JSON.stringify(body),
}), }),
}, },
mcpSync: (id: number) => mcpSync: (id: number) =>
request<{ servers: string[]; profilesSeeded: string[] }>(`/lanes/${id}/mcp/sync`, { request<{ servers: string[]; profilesSeeded: string[] }>(`/workspaces/${id}/mcp/sync`, {
method: "POST", method: "POST",
body: "{}", body: "{}",
}), }),
profileInit: (id: number, force = false) => profileInit: (id: number, force = false) =>
request<{ scaffolded: boolean; written?: string[]; todos?: string[]; reason?: string }>( request<{ scaffolded: boolean; written?: string[]; todos?: string[]; reason?: string }>(
`/lanes/${id}/profile/init`, `/workspaces/${id}/profile/init`,
{ method: "POST", body: JSON.stringify({ force }) } { method: "POST", body: JSON.stringify({ force }) }
), ),
}, },
@@ -4595,6 +4791,13 @@ export interface CcProfileUsage {
* user + project scope in one response. */ * user + project scope in one response. */
export type CcScope = "user" | "project" | "all"; export type CcScope = "user" | "project" | "all";
/** GET /api/cc-config/target — whose Claude Code config the page manages. */
export interface CcTarget {
kind: "own" | "host" | "none";
readOnly: boolean;
claudeHome: string | null;
}
/** The per-tier model names an AI Proxy profile carries. Each maps to one env /** The per-tier model names an AI Proxy profile carries. Each maps to one env
* var Claude Code reads; a proxy that names its models once fills them all * var Claude Code reads; a proxy that names its models once fills them all
* from the profile's `model`. */ * from the profile's `model`. */
@@ -4790,6 +4993,38 @@ export interface AiProxy {
* error is still a successful HTTP call: `ok` is the PROXY's verdict, `status` * error is still a successful HTTP call: `ok` is the PROXY's verdict, `status`
* is its HTTP code (null when nothing answered at all), and `message` is its * is its HTTP code (null when nothing answered at all), and `message` is its
* own words — truncated server-side, never the key. */ * own words — truncated server-side, never the key. */
/** One step of the 9router update job, in order: shutdown → update → relaunch. */
export interface NineRouterUpdateStep {
id: "shutdown" | "update" | "relaunch";
status: "pending" | "running" | "done" | "failed" | "skipped";
message: string | null;
}
/** The server's one 9router update job (in memory; null before the first). */
export interface NineRouterUpdateJob {
status: "running" | "done" | "failed";
startedAt: string;
finishedAt: string | null;
from: string | null;
to: string | null;
managedBy: "systemd" | "process" | null;
install: "direct" | "sudo";
steps: NineRouterUpdateStep[];
log: string;
}
/** GET /api/ai-proxies/9router/update. `blocker` is why a start would be refused now. */
export interface NineRouterUpdateStatus {
installed: boolean;
version: string | null;
latest: string | null;
updateAvailable: boolean;
running: boolean;
managedBy: "systemd" | "process" | null;
blocker: { code: string; message: string; command?: string } | null;
job: NineRouterUpdateJob | null;
}
export interface AiProxyProbe { export interface AiProxyProbe {
ok: boolean; ok: boolean;
status: number | null; status: number | null;
@@ -5175,8 +5410,9 @@ export interface CcOverview {
/** Key filesystem locations the explorer reads from. */ /** Key filesystem locations the explorer reads from. */
roots: { roots: {
claudeHome: string; claudeHome: string;
projectClaudeDir: string; /** Null for a member's own config with no workspace named: no project layer. */
projectRoot: string; projectClaudeDir: string | null;
projectRoot: string | null;
/** Path to the top-level `.claude.json` (marketplaces/global settings). */ /** Path to the top-level `.claude.json` (marketplaces/global settings). */
claudeJson: string; claudeJson: string;
}; };
+31 -4
View File
@@ -43,13 +43,40 @@ export const RENDERED: ReadonlySet<ArtifactKind> = new Set([
"image", "image",
]); ]);
/** An edit worth a card in the conversation: a page, a document, a diagram, a /** Folders an app keeps its own pages in: an HTML file under one of them is
* table — and a React component the model wrote whole (a `.tsx` it merely * the project's code, never a page the model made to show. */
* edited is project code, not an artifact). */ const PROJECT_DIRS = new Set([
"src",
"public",
"templates",
"views",
"pages",
"app",
"static",
"dist",
"build",
"components",
"layouts",
]);
/** An HTML file the project serves itself: an `index.html`, or one under a
* folder in {@link PROJECT_DIRS}. */
function isProjectPage(path: string): boolean {
const parts = path.split(/[\\/]/);
const name = (parts.pop() || "").toLowerCase();
if (name === "index.html" || name === "index.htm") return true;
return parts.some((d) => PROJECT_DIRS.has(d.toLowerCase()));
}
/** An edit worth a card in the conversation: a document, a diagram, a table —
* and a page or React component the model wrote whole (a `.tsx` or `.html` it
* merely edited, and an HTML page of the project itself, are project code,
* not an artifact). */
export function isArtifact(path: string, created: boolean): boolean { export function isArtifact(path: string, created: boolean): boolean {
const k = artifactKind(path); const k = artifactKind(path);
if (k === "react") return created; if (k === "react") return created;
return k === "html" || k === "svg" || k === "markdown" || k === "mermaid" || k === "csv"; if (k === "html") return created && !isProjectPage(path);
return k === "svg" || k === "markdown" || k === "mermaid" || k === "csv";
} }
/** A code fence language for the Code tab. */ /** A code fence language for the Code tab. */
+213
View File
@@ -0,0 +1,213 @@
/**
* @file healthScore.ts
* @description The Dashboard System Health score: a list of checks, each read
* against two thresholds (`warnAt` → amber, `badAt` → red) and costing up to
* `max` points as it climbs between them; the score is 100 minus every
* deduction (never below 0). Most of the weight is on what makes the machine
* or the dashboard feel slow — the event loop's p99 delay
* (`/api/settings/info` `server.event_loop`) and Linux pressure stall
* information for CPU, memory and disk I/O (`/api/host` `pressure`; the CPU
* falls back to load per core without PSI) — then RAM, the data disk, the V8
* heap, hooks going silent while agents work, and agent errors in the last
* 24 h. A check whose input is missing is `na` and costs nothing. Pure — the
* card in `Dashboard.tsx` only renders the result.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import type { HostStats } from "./api";
export type CheckLevel = "ok" | "warn" | "bad" | "na";
export type CheckKey =
"loop" | "cpu" | "memory" | "io" | "ram" | "disk" | "heap" | "hooks" | "agents";
export interface HealthCheck {
key: CheckKey;
/** "lag" checks are what a user feels as a slow machine or dashboard. */
group: "lag" | "system";
/** The measured value in `unit`; null when not measured. */
value: number | null;
unit: "ms" | "%" | "min";
level: CheckLevel;
warnAt: number;
badAt: number;
/** Points this check took off the score. */
penalty: number;
/** The most it can take. */
max: number;
/** Extra facts for the row (e.g. which mount, how many agents). */
detail?: Record<string, string | number>;
}
export interface HealthInput {
/** `server.event_loop` (ms); absent on an older server. */
eventLoop?: { p50: number; p99: number; max: number };
heap: { heapUsed: number; heapTotal: number; heapLimit?: number };
hooksInstalled: boolean;
activity?: {
last_event_at: string | null;
working_agents: number;
agents_24h: { completed: number; error: number };
};
/** The database file, to find the disk it lives on; null for a developer. */
dbPath: string | null;
host: HostStats | null;
now?: number;
}
/** Fewest finished agents before an error rate means anything. */
const MIN_AGENTS = 5;
function check(
key: CheckKey,
group: HealthCheck["group"],
value: number | null,
unit: HealthCheck["unit"],
warnAt: number,
badAt: number,
max: number,
detail?: HealthCheck["detail"]
): HealthCheck {
if (value == null || !Number.isFinite(value)) {
return { key, group, value: null, unit, level: "na", warnAt, badAt, penalty: 0, max, detail };
}
const level: CheckLevel = value >= badAt ? "bad" : value >= warnAt ? "warn" : "ok";
const t = Math.max(0, Math.min(1, (value - warnAt) / (badAt - warnAt)));
return {
key,
group,
value,
unit,
level,
warnAt,
badAt,
penalty: Math.round(max * t * 10) / 10,
max,
detail,
};
}
/** The mount holding `path`: the longest mount point that prefixes it, else `/`. */
function diskOf(host: HostStats, path: string | null) {
const disks = host.disks ?? [];
if (path) {
const hit = disks
.filter((d) => path === d.mount || path.startsWith(d.mount === "/" ? "/" : `${d.mount}/`))
.sort((a, b) => b.mount.length - a.mount.length)[0];
if (hit) return hit;
}
return disks.find((d) => d.mount === "/") ?? disks[0] ?? null;
}
export function healthChecks(input: HealthInput): HealthCheck[] {
const { host } = input;
const now = input.now ?? Date.now();
const psi = host?.pressure ?? null;
const out: HealthCheck[] = [];
// ── What lag is made of ──
out.push(check("loop", "lag", input.eventLoop?.p99 ?? null, "ms", 100, 1000, 25));
if (psi?.cpu) {
out.push(check("cpu", "lag", psi.cpu.some10, "%", 20, 60, 20, { source: "psi" }));
} else {
const load =
host && host.cpu.cores > 0 ? ((host.cpu.load[0] ?? 0) / host.cpu.cores) * 100 : null;
out.push(check("cpu", "lag", load, "%", 100, 200, 20, { source: "load" }));
}
out.push(check("memory", "lag", psi?.memory?.some10 ?? null, "%", 5, 25, 25));
out.push(check("io", "lag", psi?.io?.some10 ?? null, "%", 15, 50, 20));
// ── Capacity and the dashboard's own signals ──
const mem = host?.memory;
out.push(
check(
"ram",
"system",
mem && mem.total > 0 ? (mem.used / mem.total) * 100 : null,
"%",
90,
98,
10
)
);
const disk = host ? diskOf(host, input.dbPath) : null;
out.push(
check(
"disk",
"system",
disk && disk.total > 0 ? (disk.used / disk.total) * 100 : null,
"%",
90,
98,
15,
disk ? { mount: disk.mount } : undefined
)
);
const heapMax = input.heap.heapLimit || input.heap.heapTotal;
out.push(
check(
"heap",
"system",
heapMax > 0 ? (input.heap.heapUsed / heapMax) * 100 : null,
"%",
70,
90,
15
)
);
// Hooks: not installed is always red; installed, they matter only while an
// agent works — then minutes of silence mean the dashboard went blind.
const a = input.activity;
if (!input.hooksInstalled) {
out.push({
key: "hooks",
group: "system",
value: null,
unit: "min",
level: "bad",
warnAt: 10,
badAt: 30,
penalty: 30,
max: 30,
detail: { state: "missing" },
});
} else if (!a || a.working_agents === 0 || !a.last_event_at) {
out.push({
...check("hooks", "system", null, "min", 10, 30, 30, { state: "idle" }),
level: "ok",
});
} else {
const age = Math.max(0, (now - Date.parse(a.last_event_at)) / 60000);
out.push(
check("hooks", "system", age, "min", 10, 30, 30, {
state: "working",
working: a.working_agents,
})
);
}
const done = a ? a.agents_24h.completed + a.agents_24h.error : 0;
out.push(
check(
"agents",
"system",
a && done >= MIN_AGENTS ? (a.agents_24h.error / done) * 100 : null,
"%",
10,
30,
10,
a ? { error: a.agents_24h.error, total: done } : undefined
)
);
return out;
}
export function healthScore(checks: HealthCheck[]): number {
const lost = checks.reduce((s, c) => s + c.penalty, 0);
return Math.max(0, Math.min(100, Math.round((100 - lost) * 10) / 10));
}
/** Green from 90, amber from 70, red below. */
export function scoreLevel(score: number): Exclude<CheckLevel, "na"> {
return score >= 90 ? "ok" : score >= 70 ? "warn" : "bad";
}
+1 -1
View File
@@ -7,7 +7,7 @@
* *
* The order lives in the browser rather than the lanes table on purpose: it is * The order lives in the browser rather than the lanes table on purpose: it is
* a per-viewer preference, and nothing * a per-viewer preference, and nothing
* server-side (the CLI's `agenthub lanes`, the pipeline-stats picker) has an * server-side (the CLI's `agenthub workspaces`, the pipeline-stats picker) has an
* opinion about it. Ceiling: the order does not follow the user to another * opinion about it. Ceiling: the order does not follow the user to another
* browser or to the CLI — move it to a `lanes.sort_order` column if it ever * browser or to the CLI — move it to a `lanes.sort_order` column if it ever
* must. * must.
+39
View File
@@ -8,9 +8,15 @@
* uploaded or copied. Off, the image is uploaded like a remote browser's. An * uploaded or copied. Off, the image is uploaded like a remote browser's. An
* SSH tunnel's browser also reaches the dashboard by loopback, and its `^V` * SSH tunnel's browser also reaches the dashboard by loopback, and its `^V`
* would read the SERVER's clipboard — that is what turning it off is for. * would read the SERVER's clipboard — that is what turning it off is for.
* The server must also be able to read its clipboard (`nativePasteWorks`: on
* Linux `wl-paste`/`xclip` and a display) — else the `^V` pasted nothing.
* On Windows Claude Code binds its image paste to Alt+V, not Ctrl+V
* (`nativePasteKey`): a `^V` there was silently ignored.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { api } from "./api";
export const NATIVE_PASTE_KEY = "agenthub.terminal.nativePaste"; export const NATIVE_PASTE_KEY = "agenthub.terminal.nativePaste";
/** True unless this browser turned native image paste off. */ /** True unless this browser turned native image paste off. */
@@ -30,3 +36,36 @@ export function writeNativePaste(on: boolean) {
/* storage blocked — the default */ /* storage blocked — the default */
} }
} }
let works: boolean | null = null;
let asked: Promise<void> | null = null;
// Claude Code's image-paste chord on the dashboard's machine: Ctrl+V, but
// Alt+V on Windows, where a ^V reaches no binding and pastes nothing.
let key = "\x16";
/** Whether the dashboard's machine can hand a pane its clipboard, as last
* answered — false until it has (an upload then, which always works). The
* first call asks the server once per page. */
export function nativePasteWorks(): boolean {
asked ??= api.run
.nativePaste()
.then((r) => {
works = r.native === true;
key = r.key === "alt+v" ? "\x1bv" : "\x16";
})
.catch(() => void (works = false));
return works === true;
}
/** The bytes that make the pane's `claude` read its clipboard: ^V, or ESC v
* (Alt+V) on a Windows dashboard. */
export function nativePasteKey(): string {
return key;
}
/** Test seam: forget the server's answer. */
export function __resetNativePaste() {
works = null;
asked = null;
key = "\x16";
}
+54 -3
View File
@@ -9,8 +9,10 @@
* dashboard's own machine attaches by path instead of uploading * dashboard's own machine attaches by path instead of uploading
* (`localCandidates` — a drag's uri-list, or the plain-text paths a pasted * (`localCandidates` — a drag's uri-list, or the plain-text paths a pasted
* file-manager copy carries —, checked by the server in `sameFilesHere` — * file-manager copy carries —, checked by the server in `sameFilesHere` —
* `POST /api/run/local-paths` —, `localAttachment`). An attachment is a path * `POST /api/run/local-paths` —, `localAttachment`), and the pasted or
* sent with the prompt; that check is the one call here to the server. * dropped files that machine's file manager holds (`fileManagerPaths`). An
* attachment is a path sent with the prompt; those checks are the calls here
* to the server.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
@@ -144,6 +146,53 @@ export async function sameFilesHere(paths: string[], files: File[]): Promise<boo
} }
} }
/** A pasted BITMAP (a screenshot), not a copied image FILE: browsers name the
* clipboard's picture `image.png`, while a file manager's copy keeps its own
* name. Only a bitmap goes to `claude`'s own image paste — a copied file is
* pasted by its path, as a native terminal does (`claude` finds no picture
* on a clipboard that holds a file) — and a bitmap that comes WITH text (a
* spreadsheet's cells, a page's selection) is the text's picture: a
* terminal pastes the text. */
export function isClipboardBitmap(f: File): boolean {
return f.type.startsWith("image/") && f.name === "image.png";
}
/** The pasted or dropped `files` by their own paths on the dashboard's
* machine, as its file manager holds them — a paste on its OS clipboard, a
* drop as its selection, which is what a drag from it carries
* (`POST /api/run/clipboard-files` / `selected-files`): a native terminal's
* paths, and the one way past the upload limit. Null when this is not a
* loopback browser, on a refusal (server mode), or when any file is not
* held under the same name and size (an SSH tunnel's browser pastes
* ANOTHER machine's files). Paths come back in `files`' order. */
export async function fileManagerPaths(
files: File[],
from: "paste" | "drop"
): Promise<string[] | null> {
if (!isLocalBrowser() || files.length === 0) return null;
try {
const { files: held } = await (from === "paste"
? api.run.clipboardFiles()
: api.run.selectedFiles());
const base = (p: string) =>
p
.replace(/[\\/]+$/, "")
.split(/[\\/]/)
.pop() ?? "";
const left = [...held];
const paths: string[] = [];
for (const f of files) {
const i = left.findIndex((h) => base(h.path) === f.name && (h.dir || h.size === f.size));
if (i < 0) return null;
paths.push(left[i]!.path.replace(/(.)[\\/]+$/, "$1"));
left.splice(i, 1);
}
return paths;
} catch {
return null;
}
}
/** The local files a drop or a paste names as `file://` URIs (a file /** The local files a drop or a paste names as `file://` URIs (a file
* manager's, an editor's), as absolute paths — or null when it names none, * manager's, an editor's), as absolute paths — or null when it names none,
* or anything that is not one. `file:///C:/x` is `C:/x`. */ * or anything that is not one. `file:///C:/x` is `C:/x`. */
@@ -169,9 +218,11 @@ export function droppedFilePaths(dt: DataTransfer | null | undefined): string[]
/** A file on this machine, by its absolute path, as an attachment. */ /** A file on this machine, by its absolute path, as an attachment. */
export function localAttachment(path: string): Attachment { export function localAttachment(path: string): Attachment {
// A Windows path in `/` form, like `claudeMention` (Windows takes either).
const p = /^[A-Za-z]:\\/.test(path) ? path.replace(/\\/g, "/") : path;
return { return {
// A space would end the mention: Claude Code reads `@"…"` whole. // A space would end the mention: Claude Code reads `@"…"` whole.
path: /\s/.test(path) ? `@"${path}"` : `@${path}`, path: /\s/.test(p) ? `@"${p}"` : `@${p}`,
name: name:
path path
.replace(/[\\/]$/, "") .replace(/[\\/]$/, "")
+10 -1
View File
@@ -746,6 +746,15 @@ export interface Session {
/** ISO time the user pinned the session to the top of its workspace's /** ISO time the user pinned the session to the top of its workspace's
* sidebar list; null when not pinned. Maps to `sessions.pinned_at`. */ * sidebar list; null when not pinned. Maps to `sessions.pinned_at`. */
pinned_at?: string | null; pinned_at?: string | null;
/** Server mode, `GET /api/sessions` only: the account owning the session's
* workspace (null when no workspace owns it). Absent in local mode. */
owner_id?: number | null;
/** That owner's username (`users.username`). */
owner_name?: string | null;
/** That owner's display name (`users.display_name`); null when none is set. */
owner_display_name?: string | null;
/** When that owner's avatar was last set (`users.avatar_at`); null = none. */
owner_avatar_at?: string | null;
} }
/** /**
@@ -2633,7 +2642,7 @@ export interface LaneNode {
icon: string; icon: string;
gate: boolean; gate: boolean;
/** The one command that drives this node — a slash command /** The one command that drives this node — a slash command
* (`/superpowers:writing-plans`) or a shell one (`agenthub lanes hook ci-gate`), * (`/superpowers:writing-plans`) or a shell one (`agenthub workspace hook ci-gate`),
* from the pipeline template. Empty string for a node with no single driver * from the pipeline template. Empty string for a node with no single driver
* (`done`, or a gate another agent declares). Display text only; the * (`done`, or a gate another agent declares). Display text only; the
* dashboard never runs it. Optional in the type only because the archived * dashboard never runs it. Optional in the type only because the archived
+1 -1
View File
@@ -1,6 +1,6 @@
/** /**
* @file main.tsx * @file main.tsx
* @description Vite client entry point. Bootstraps React 18, loads global styles, * @description Vite client entry point. Bootstraps React 19, loads global styles,
* registers the service worker, prefetches the Workspace chunk on a `/workspace` * registers the service worker, prefetches the Workspace chunk on a `/workspace`
* deep link, and mounts {@link App} into `#root` once the active locale is loaded. * deep link, and mounts {@link App} into `#root` once the active locale is loaded.
* *
+16 -37
View File
@@ -1,42 +1,32 @@
/** /**
* @file Administration.tsx * @file Administration.tsx
* @description The Administration page (`/admin`, a sidebar item; `/users` * @description The Administration page (`/admin`, a sidebar item; `/users`
* redirects here), server mode only and only for an admin or analyst: a tab * redirects here), server mode only and only for an admin or analyst — ONE
* shell like `History.tsx`, the active tab in `?tab=` — **Users** (the * page, no tabs: ONE users table holding every per-user account fact (account,
* accounts, the account pool and the audit log, `Users.tsx`) and **Server** * spend, home, tmux; each row expands in
* (the machine's hardware and each user's share of it, `ServerPanel`). * place into that user's detail), the account pool, then the audit log — all
* Local mode and a developer are sent home, like the Users page always did. * from `Users.tsx`. The machine and each user's live CPU / RAM live on the
* Dashboard's System Health tab (`HostHealth.tsx`). Local mode and a developer are sent home, like the
* Users page always did.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import type { ComponentType } from "react"; import { Navigate } from "react-router-dom";
import { Navigate, useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Server, ShieldCheck, Users as UsersIcon } from "lucide-react"; import { ShieldCheck } from "lucide-react";
import { PageTabs } from "../components/PageTabs";
import { useAuth } from "../components/AuthGate"; import { useAuth } from "../components/AuthGate";
import { ServerPanel } from "../components/admin/ServerPanel"; import { useHostStats } from "../components/health/HostHealth";
import { Users } from "./Users"; import { Users } from "./Users";
interface AdminTab {
id: string;
label: string;
Icon: ComponentType<{ className?: string }>;
}
export function Administration() { export function Administration() {
const { t } = useTranslation("admin"); const { t } = useTranslation("admin");
const auth = useAuth(); const auth = useAuth();
const [params, setParams] = useSearchParams();
const me = auth.state.mode === "server" ? auth.state.user : null; const me = auth.state.mode === "server" ? auth.state.user : null;
const staff = !!me && me.role !== "developer";
// Read once, for each home's share of the homes disk. The machine and each
// user's live CPU / RAM are on the Dashboard's System Health tab.
const host = useHostStats(staff, 0);
if (!me || me.role === "developer") return <Navigate to="/" replace />; if (!staff) return <Navigate to="/" replace />;
const tabs: AdminTab[] = [
{ id: "users", label: t("tabs.users"), Icon: UsersIcon },
{ id: "server", label: t("tabs.server"), Icon: Server },
];
const first = tabs[0]!;
const active = tabs.find((x) => x.id === params.get("tab")) ?? first;
return ( return (
<div className="animate-fade-in space-y-6"> <div className="animate-fade-in space-y-6">
@@ -49,18 +39,7 @@ export function Administration() {
<p className="text-xs text-fg-muted">{t("description")}</p> <p className="text-xs text-fg-muted">{t("description")}</p>
</div> </div>
</div> </div>
<PageTabs <Users embedded host={host} />
ariaLabel={t("title")}
activeId={active.id}
onSelect={(id) => setParams(id === first.id ? {} : { tab: id }, { replace: true })}
tabs={tabs}
>
{active.id === "server" ? (
<ServerPanel isAdmin={me.role === "admin"} />
) : (
<Users embedded />
)}
</PageTabs>
</div> </div>
); );
} }
+4
View File
@@ -30,6 +30,8 @@
* — on the DASHBOARD's host at `NINE_ROUTER_PORT` (`nineRouterUrl`), never the * — on the DASHBOARD's host at `NINE_ROUTER_PORT` (`nineRouterUrl`), never the
* viewer's own localhost: a teammate on the LAN has no 9router of their own. * viewer's own localhost: a teammate on the LAN has no 9router of their own.
* It is shown only when the server finds a `9router` binary installed. * It is shown only when the server finds a `9router` binary installed.
* Beside it an admin gets **Update 9router** (`NineRouterUpdate`): shut it
* down, `npm install -g 9router@latest`, start it again hidden.
* *
* **Test** asks the endpoint itself, once as Claude Code would * **Test** asks the endpoint itself, once as Claude Code would
* (`/v1/messages`) and once as Codex would (`/v1/responses`), one line each. * (`/v1/messages`) and once as Codex would (`/v1/responses`), one line each.
@@ -54,6 +56,7 @@ import { confirmDialog } from "../lib/confirm";
import { copyText } from "../lib/clipboard"; import { copyText } from "../lib/clipboard";
import { useDialog } from "../hooks/useDialog"; import { useDialog } from "../hooks/useDialog";
import { useAuth } from "../components/AuthGate"; import { useAuth } from "../components/AuthGate";
import { NineRouterUpdate } from "../components/settings/NineRouterUpdate";
const NINE_ROUTER_PORT = 20128; const NINE_ROUTER_PORT = 20128;
@@ -271,6 +274,7 @@ export function AiProxyConfig() {
{t("aiProxy.config9router")} {t("aiProxy.config9router")}
</a> </a>
)} )}
{nineRouter && isAdmin && <NineRouterUpdate />}
<button <button
type="button" type="button"
onClick={startAdd} onClick={startAdd}
+80 -26
View File
@@ -16,6 +16,11 @@
* files, and the `MdItemList` artifacts) also opens a * files, and the `MdItemList` artifacts) also opens a
* right-click menu that repeats those buttons — same handlers, same * right-click menu that repeats those buttons — same handlers, same
* confirm path — plus Copy name/path. * confirm path — plus Copy name/path.
* Server mode, a non-admin with their own Linux account (S3): the page is
* THEIR config (`useCcTarget`, labelled as such, no project layer unless a
* workspace is named), and the host-wide controls — Switch profile, Update
* all, AgentHub's own Claude settings — are an admin's and hidden (the server
* refuses them anyway).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
/* ============================================================================= /* =============================================================================
@@ -126,6 +131,8 @@ import { ScopeBadge } from "../components/config/ScopeBadge";
import { ConfigTabBar } from "../components/config/ConfigTabBar"; import { ConfigTabBar } from "../components/config/ConfigTabBar";
import { ProjectMemoryModal } from "../components/config/ProjectMemoryModal"; import { ProjectMemoryModal } from "../components/config/ProjectMemoryModal";
import { ClaudeAgentHubSettings } from "../components/settings/ClaudeAgentHubSettings"; import { ClaudeAgentHubSettings } from "../components/settings/ClaudeAgentHubSettings";
import { usePermissions } from "../components/AuthGate";
import { useCcTarget } from "../hooks/useCcTarget";
import { import {
Empty, Empty,
SkeletonRows, SkeletonRows,
@@ -516,8 +523,14 @@ export function CcConfig() {
const wsConnected = useSyncExternalStore(eventBus.onConnection, () => eventBus.connected); const wsConnected = useSyncExternalStore(eventBus.onConnection, () => eventBus.connected);
const projectRoot = data.overview?.roots.projectRoot ?? null; const projectRoot = data.overview?.roots.projectRoot ?? null;
// Project files under the plugin cache are wiped by the next plugin update. // Server mode: whose config this is. The host-wide controls are an admin's.
const projectDisabled = isPluginCachePath(projectRoot); const { isAdmin } = usePermissions();
const ccTarget = useCcTarget();
const ownHome = ccTarget?.kind === "own" ? ccTarget.claudeHome : null;
// Project files under the plugin cache are wiped by the next plugin update;
// a member's own config has no project layer at all.
const projectDisabled =
isPluginCachePath(projectRoot) || (!!data.overview && projectRoot === null);
useEffect(() => { useEffect(() => {
if (projectDisabled && scope === "project") setScope("all"); if (projectDisabled && scope === "project") setScope("all");
}, [projectDisabled, scope]); }, [projectDisabled, scope]);
@@ -681,6 +694,8 @@ export function CcConfig() {
}} }}
onOpenBackups={() => setBackupsOpen(true)} onOpenBackups={() => setBackupsOpen(true)}
wsConnected={wsConnected} wsConnected={wsConnected}
showProfiles={isAdmin}
ownHome={ownHome}
/> />
<Tabs current={tab} onSelect={(k) => void setTab(k)} counts={data.overview?.counts} /> <Tabs current={tab} onSelect={(k) => void setTab(k)} counts={data.overview?.counts} />
@@ -777,7 +792,7 @@ export function CcConfig() {
{/* AgentHub's own Claude Code settings follow the overview, outside the {/* AgentHub's own Claude Code settings follow the overview, outside the
list's search box and scope. */} list's search box and scope. */}
{tab === "overview" && ( {tab === "overview" && isAdmin && (
<ClaudeAgentHubSettings key={agenthubKey} onHomeChanged={() => void fetchAll()} /> <ClaudeAgentHubSettings key={agenthubKey} onHomeChanged={() => void fetchAll()} />
)} )}
@@ -820,10 +835,22 @@ interface HeaderProps {
onRefresh: () => void; onRefresh: () => void;
onOpenBackups: () => void; onOpenBackups: () => void;
wsConnected: boolean; wsConnected: boolean;
/** The host's login profiles link: an admin's (and local mode's). */
showProfiles?: boolean;
/** A member's own Claude home (server mode, S3): the page says whose it is. */
ownHome?: string | null;
} }
/** The page's toolbar. No title of its own: the Settings hub already draws one. */ /** The page's toolbar. No title of its own: the Settings hub already draws one. */
function Header({ loading, lastUpdated, onRefresh, onOpenBackups, wsConnected }: HeaderProps) { function Header({
loading,
lastUpdated,
onRefresh,
onOpenBackups,
wsConnected,
showProfiles = true,
ownHome = null,
}: HeaderProps) {
const { t } = useTranslation("ccConfig"); const { t } = useTranslation("ccConfig");
const settingsHref = useSettingsHref(); const settingsHref = useSettingsHref();
const { t: tCommon } = useTranslation("common"); const { t: tCommon } = useTranslation("common");
@@ -848,18 +875,30 @@ function Header({ loading, lastUpdated, onRefresh, onOpenBackups, wsConnected }:
{tCommon("offline")} {tCommon("offline")}
</span> </span>
)} )}
<p className="text-xs text-fg-muted max-w-2xl">{t("subtitle")}</p> {ownHome ? (
<div className="min-w-0">
<p className="text-xs font-medium text-fg-primary" data-testid="cc-own-config">
{t("own.label", { home: ownHome })}
</p>
<p className="text-xs text-fg-muted max-w-2xl">{t("own.subtitle")}</p>
</div>
) : (
<p className="text-xs text-fg-muted max-w-2xl">{t("subtitle")}</p>
)}
</div> </div>
<div className="flex flex-col items-stretch lg:items-end gap-2 flex-shrink-0"> <div className="flex flex-col items-stretch lg:items-end gap-2 flex-shrink-0">
<div className="flex items-center gap-2 justify-end flex-wrap"> <div className="flex items-center gap-2 justify-end flex-wrap">
{/* Every AI tool's login profiles live in one place: Settings → AI tools. */} {/* Every AI tool's login profiles live in one place: Settings → AI tools.
<Link The host's logins: an admin's. */}
to={settingsHref("agents#tool-profiles")} {showProfiles && (
className="inline-flex items-center gap-2 rounded-lg border border-border bg-surface-2 px-3 py-1.5 text-xs font-medium text-fg-secondary hover:bg-surface-3 transition-colors" <Link
> to={settingsHref("agents#tool-profiles")}
<UserCog className="w-3.5 h-3.5" /> className="inline-flex items-center gap-2 rounded-lg border border-border bg-surface-2 px-3 py-1.5 text-xs font-medium text-fg-secondary hover:bg-surface-3 transition-colors"
{t("profiles.openButton")} >
</Link> <UserCog className="w-3.5 h-3.5" />
{t("profiles.openButton")}
</Link>
)}
<button <button
type="button" type="button"
onClick={onOpenBackups} onClick={onOpenBackups}
@@ -1290,18 +1329,31 @@ function OverviewPanel({ overview }: { overview: CcOverview | null }) {
{t("overview.rootsTitle")} {t("overview.rootsTitle")}
</h2> </h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2"> <div className="grid grid-cols-1 md:grid-cols-2 gap-2">
<RootRow {/* A member's own config has no project layer: their Claude home instead. */}
icon={FolderTree} {roots.projectRoot === null && (
tone="emerald" <RootRow
label={t("overview.projectClaudeDir")} icon={FolderTree}
value={roots.projectClaudeDir} tone="emerald"
/> label={t("overview.claudeHome")}
<RootRow value={roots.claudeHome}
icon={FolderTree} />
tone="violet" )}
label={t("overview.projectRoot")} {roots.projectClaudeDir !== null && (
value={roots.projectRoot} <RootRow
/> icon={FolderTree}
tone="emerald"
label={t("overview.projectClaudeDir")}
value={roots.projectClaudeDir}
/>
)}
{roots.projectRoot !== null && (
<RootRow
icon={FolderTree}
tone="violet"
label={t("overview.projectRoot")}
value={roots.projectRoot}
/>
)}
<RootRow <RootRow
icon={FileText} icon={FileText}
tone="amber" tone="amber"
@@ -1718,6 +1770,7 @@ function PluginsPanel({
onChanged: () => void; onChanged: () => void;
}) { }) {
const { t } = useTranslation("ccConfig"); const { t } = useTranslation("ccConfig");
const { isAdmin } = usePermissions();
const { busy, run } = useConfigAction(onToast, onChanged); const { busy, run } = useConfigAction(onToast, onChanged);
const [input, setInput] = useState(""); const [input, setInput] = useState("");
const [available, setAvailable] = useState<CcAvailablePlugin[] | null>(null); const [available, setAvailable] = useState<CcAvailablePlugin[] | null>(null);
@@ -1785,7 +1838,8 @@ function PluginsPanel({
commands={[{ cmd: t("explain.plugins.installCmd"), note: "" }]} commands={[{ cmd: t("explain.plugins.installCmd"), note: "" }]}
/> />
<UpdateAllCard onToast={onToast} onChanged={onChanged} /> {/* Update all runs on the host's plugins (AgentHub's own included): an admin's. */}
{isAdmin && <UpdateAllCard onToast={onToast} onChanged={onChanged} />}
<div className="rounded-lg border border-accent/25 bg-accent/5 px-3 py-2.5"> <div className="rounded-lg border border-accent/25 bg-accent/5 px-3 py-2.5">
<div className="text-[11px] font-semibold text-fg-secondary mb-1.5"> <div className="text-[11px] font-semibold text-fg-secondary mb-1.5">
File diff suppressed because it is too large Load Diff
+175 -96
View File
@@ -1,6 +1,6 @@
/** /**
* @file Sessions.tsx * @file Sessions.tsx
* @description Displays a list of all recorded sessions with filtering, searching, and pagination features; a row's right-click opens `SessionRowMenu`. Sessions are updated in real-time based on events received from the event bus. * @description Displays a list of all recorded sessions with filtering, searching, and pagination features; columns include the AI tool and (server-mode admins) the owning user, and the last column holds the row's actions (open in workspace, `SessionRowMenu` — also on right-click — and details). Sessions are updated in real-time based on events received from the event bus.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
/* ============================================================================= /* =============================================================================
@@ -66,6 +66,8 @@ import {
Server, Server,
Download, Download,
AlertCircle, AlertCircle,
MoreHorizontal,
SquareArrowOutUpRight,
} from "lucide-react"; } from "lucide-react";
import { api } from "../lib/api"; import { api } from "../lib/api";
import type { RemoteSource, RunHandle } from "../lib/api"; import type { RemoteSource, RunHandle } from "../lib/api";
@@ -80,7 +82,8 @@ import { eventBus } from "../lib/eventBus";
import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents"; import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
import { useDataScope } from "../lib/dataScope"; import { useDataScope } from "../lib/dataScope";
import { SessionStatusBadge } from "../components/StatusBadge"; import { SessionStatusBadge } from "../components/StatusBadge";
import { SessionToolBadge } from "../components/SessionToolBadge"; import { AiToolIcon } from "../components/AiToolIcon";
import { UserAvatar } from "../components/users/UserAvatar";
import { EmptyState } from "../components/EmptyState"; import { EmptyState } from "../components/EmptyState";
import { TableRowSkeleton } from "../components/Skeleton"; import { TableRowSkeleton } from "../components/Skeleton";
import { useContextMenu } from "../components/ContextMenu"; import { useContextMenu } from "../components/ContextMenu";
@@ -91,12 +94,16 @@ import type { Session, DashboardEvent } from "../lib/types";
import { usePermissions } from "../components/AuthGate"; import { usePermissions } from "../components/AuthGate";
const PAGE_SIZE = 10; const PAGE_SIZE = 10;
const TH =
"px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider whitespace-nowrap";
const ACTION_ICON =
"inline-flex items-center justify-center w-7 h-7 rounded-md text-fg-muted hover:text-fg-primary hover:bg-surface-3 transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent";
export function Sessions({ embedded = false }: { embedded?: boolean } = {}) { export function Sessions({ embedded = false }: { embedded?: boolean } = {}) {
const navigate = useNavigate(); const navigate = useNavigate();
const rowCtx = useContextMenu<Session>(); const rowCtx = useContextMenu<Session>();
const { t } = useTranslation("sessions"); const { t } = useTranslation("sessions");
const { tools } = useAiTools(); const { tools, label: toolLabel } = useAiTools();
const { lanes } = useLanes(); const { lanes } = useLanes();
// Filters, sort and page live in the URL (replace, no history spam), so a // Filters, sort and page live in the URL (replace, no history spam), so a
// reload, the back button and a shared link all land on the same view. // reload, the back button and a shared link all land on the same view.
@@ -185,7 +192,11 @@ export function Sessions({ embedded = false }: { embedded?: boolean } = {}) {
// Load remote-source labels so remote-origin rows can show a friendly badge // Load remote-source labels so remote-origin rows can show a friendly badge
// instead of a raw `src_…` id. Refreshed when a source's status changes. // instead of a raw `src_…` id. Refreshed when a source's status changes.
// Staff only: a developer's /api/remote-sources is a 403 (STAFF_ONLY_PREFIXES). // Staff only: a developer's /api/remote-sources is a 403 (STAFF_ONLY_PREFIXES).
const { seesAll } = usePermissions(); const { seesAll, isAdmin, local } = usePermissions();
// Who owns each session: an admin's column only, and only where there are
// accounts (server mode; the API sends no owner in local mode).
const showUser = isAdmin && !local;
const columns = showUser ? 10 : 9;
const loadSourceLabels = useCallback(() => { const loadSourceLabels = useCallback(() => {
if (!seesAll) return; if (!seesAll) return;
api.remoteSources api.remoteSources
@@ -581,31 +592,19 @@ export function Sessions({ embedded = false }: { embedded?: boolean } = {}) {
role="region" role="region"
aria-label={t("tableRegion")} aria-label={t("tableRegion")}
> >
<table className="w-full min-w-[800px]"> <table className="w-full min-w-[960px]">
<thead> <thead>
<tr className="border-b border-border text-left"> <tr className="border-b border-border text-left bg-surface-2/40">
<th className="px-5 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider"> <th className={TH}>{t("tableSession")}</th>
{t("tableSession")} {showUser && <th className={TH}>{t("tableUser")}</th>}
</th> <th className={TH}>{t("tableTool")}</th>
<th className="px-5 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider"> <th className={TH}>{t("tableStatus")}</th>
{t("tableStatus")} <th className={TH}>{t("tableLastActive")}</th>
</th> <th className={`${TH} text-right`}>{t("tableDuration")}</th>
<th className="px-5 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider"> <th className={`${TH} text-right`}>{t("tableAgents")}</th>
{t("tableLastActive")} <th className={`${TH} text-right`}>{t("tableCost")}</th>
</th> <th className={TH}>{t("tableDirectory")}</th>
<th className="px-5 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider"> <th className={`${TH} text-right`}>{t("tableActions")}</th>
{t("tableDuration")}
</th>
<th className="px-5 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
{t("tableAgents")}
</th>
<th className="px-5 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
{t("tableCost")}
</th>
<th className="px-5 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
{t("tableDirectory")}
</th>
<th className="w-10"></th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-border"> <tbody className="divide-y divide-border">
@@ -613,94 +612,174 @@ export function Sessions({ embedded = false }: { embedded?: boolean } = {}) {
? Array.from({ length: 8 }).map((_, i) => ( ? Array.from({ length: 8 }).map((_, i) => (
<TableRowSkeleton <TableRowSkeleton
key={`sk-${i}`} key={`sk-${i}`}
columns={8} columns={columns}
widths={["w-40", "w-20", "w-28", "w-20", "w-10", "w-16", "w-44", "w-4"]} widths={[
"w-40",
...(showUser ? ["w-24"] : []),
"w-20",
"w-20",
"w-28",
"w-16",
"w-8",
"w-14",
"w-40",
"w-20",
]}
/> />
)) ))
: null} : null}
{paged.map((session) => ( {paged.map((session) => {
<tr const toolId = session.tool || "claude";
key={session.id} // A live dashboard pane holds it: open that pane; else its
// Mouse convenience; the session name below is the real, // workspace's replay, when a workspace owns it.
// focusable link (a click on a link or button inside wins). const live = runs.some((r) => r.sessionId === session.id);
onClick={(e) => { const href = workspaceHref(session, runs);
if ((e.target as HTMLElement).closest("a,button")) return; const owner = session.owner_display_name || session.owner_name;
navigate(`/sessions/${session.id}`); return (
}} <tr
onContextMenu={(e) => rowCtx.open(e, session)} key={session.id}
className="hover:bg-surface-4 transition-colors cursor-pointer group" // Mouse convenience; the session name below is the real,
> // focusable link (a click on a link or button inside wins).
<td className="px-5 py-4"> onClick={(e) => {
<div> if ((e.target as HTMLElement).closest("a,button")) return;
<div className="flex items-center gap-2"> navigate(`/sessions/${session.id}`);
}}
onContextMenu={(e) => rowCtx.open(e, session)}
className="hover:bg-surface-3/60 transition-colors cursor-pointer group"
>
<td className="px-4 py-3 max-w-[22rem]">
<div className="flex items-center gap-2 min-w-0">
<Link <Link
to={`/sessions/${session.id}`} to={`/sessions/${session.id}`}
className="text-sm font-medium text-fg-secondary hover:text-fg-primary focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent rounded" title={session.name || undefined}
className="truncate text-sm font-medium text-fg-primary hover:text-accent focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent rounded"
> >
{session.name || `${t("defaultName")}${session.id.slice(0, 8)}`} {session.name || `${t("defaultName")}${session.id.slice(0, 8)}`}
</Link> </Link>
<SessionToolBadge tool={session.tool} />
{session.source && session.source !== "local" && ( {session.source && session.source !== "local" && (
<span <span
className="inline-flex items-center gap-1 text-[10px] font-semibold text-syntax-builtin bg-surface-3 border border-border px-1.5 py-0.5 rounded-full" className="inline-flex flex-shrink-0 items-center gap-1 text-[10px] font-semibold text-syntax-builtin bg-surface-3 border border-border px-1.5 py-0.5 rounded-full"
title={t("remoteSourceBadgeTitle", "Collected from a remote machine")} title={t("remoteSourceBadgeTitle", "Collected from a remote machine")}
> >
<Server className="w-2.5 h-2.5" /> <Server className="w-2.5 h-2.5" />
{sourceLabels.get(session.source) || session.source} {sourceLabels.get(session.source) || session.source}
</span> </span>
)} )}
{(() => {
// A live dashboard pane holds it: open that pane.
const run = runs.find((r) => r.sessionId === session.id);
const href = run ? workspaceHref(session, runs) : null;
return href ? (
<Link
to={href}
className="inline-flex items-center gap-1 text-[10px] font-semibold text-status-success bg-status-success/10 border border-status-success/25 hover:bg-status-success/20 hover:text-status-success px-1.5 py-0.5 rounded-full transition-colors"
title={t("openInWorkspaceTitle")}
>
<Play className="w-2.5 h-2.5" />
{t("openInWorkspace")}
</Link>
) : null;
})()}
</div> </div>
<p className="text-[11px] text-fg-muted font-mono"> <p className="text-[11px] text-fg-muted font-mono">
{session.id.slice(0, 12)} {session.id.slice(0, 12)}
</p> </p>
</div> </td>
</td> {showUser && (
<td className="px-5 py-4"> <td className="px-4 py-3">
<SessionStatusBadge {owner ? (
status={effectiveSessionStatus(session)} <div
reason={sessionAwaitingReason(session)} className="flex items-center gap-2 min-w-0"
/> title={session.owner_name || undefined}
</td> >
<td className="px-5 py-4 text-sm text-fg-secondary"> <UserAvatar
{formatDateTime(session.last_activity || session.started_at)} name={owner}
</td> userId={session.owner_id ?? undefined}
<td className="px-5 py-4 text-sm text-fg-secondary font-mono"> avatarAt={session.owner_avatar_at}
{session.ended_at className="h-6 w-6 text-[10px]"
? formatDuration(session.started_at, session.ended_at) />
: t("common:running")} <span className="truncate text-sm text-fg-secondary max-w-[9rem]">
</td> {owner}
<td className="px-5 py-4 text-sm text-fg-secondary"> </span>
{session.agent_count ?? "-"} </div>
</td> ) : (
<td className="px-5 py-4 text-sm text-fg-secondary font-mono"> <span className="text-xs text-fg-muted" title={t("noOwner")}>
{session.cost != null && session.cost > 0 ? fmtCost(session.cost) : "-"} -
</td> </span>
<td )}
className="px-5 py-4 text-[11px] text-fg-muted font-mono" </td>
title={session.cwd || undefined} )}
> <td className="px-4 py-3">
{session.cwd ? truncate(session.cwd, 30) : "-"} <div
</td> className="flex items-center gap-2"
<td className="px-3 py-4"> title={t("toolBadgeTitle", { tool: toolLabel(toolId) })}
<ChevronRight className="w-4 h-4 text-fg-muted group-hover:text-fg-secondary transition-colors" /> >
</td> <AiToolIcon
</tr> tool={{ id: toolId, label: toolLabel(toolId) }}
))} className="h-6 w-6"
/>
<span className="text-sm text-fg-secondary whitespace-nowrap">
{toolLabel(toolId)}
</span>
</div>
</td>
<td className="px-4 py-3">
<SessionStatusBadge
status={effectiveSessionStatus(session)}
reason={sessionAwaitingReason(session)}
/>
</td>
<td className="px-4 py-3 text-sm text-fg-secondary whitespace-nowrap">
{formatDateTime(session.last_activity || session.started_at)}
</td>
<td className="px-4 py-3 text-sm text-fg-secondary font-mono text-right whitespace-nowrap">
{session.ended_at
? formatDuration(session.started_at, session.ended_at)
: t("common:running")}
</td>
<td className="px-4 py-3 text-sm text-fg-secondary text-right tabular-nums">
{session.agent_count ?? "-"}
</td>
<td className="px-4 py-3 text-sm text-fg-secondary font-mono text-right">
{session.cost != null && session.cost > 0 ? fmtCost(session.cost) : "-"}
</td>
<td
className="px-4 py-3 text-[11px] text-fg-muted font-mono"
title={session.cwd || undefined}
>
{session.cwd ? truncate(session.cwd, 30) : "-"}
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-1">
{href &&
(live ? (
<Link
to={href}
className="inline-flex items-center gap-1 whitespace-nowrap text-[11px] font-semibold text-status-success bg-status-success/10 border border-status-success/25 hover:bg-status-success/20 hover:text-status-success px-2 py-1 rounded-md transition-colors"
title={t("openInWorkspaceTitle")}
>
<Play className="w-3 h-3" />
{t("openInWorkspace")}
</Link>
) : (
<Link
to={href}
className={ACTION_ICON}
title={t("menu.inWorkspace")}
aria-label={t("menu.inWorkspace")}
>
<SquareArrowOutUpRight className="w-4 h-4" />
</Link>
))}
<button
type="button"
onClick={(e) => rowCtx.open(e, session)}
className={ACTION_ICON}
title={t("moreActions")}
aria-label={t("moreActions")}
aria-haspopup="menu"
>
<MoreHorizontal className="w-4 h-4" />
</button>
<Link
to={`/sessions/${session.id}`}
className={ACTION_ICON}
title={t("viewDetails")}
aria-label={t("viewDetails")}
tabIndex={-1}
>
<ChevronRight className="w-4 h-4" />
</Link>
</div>
</td>
</tr>
);
})}
</tbody> </tbody>
</table> </table>
</div> </div>
+17 -5
View File
@@ -27,6 +27,9 @@
* /workspace, `lib/settingsLink.ts`): the same pages, every link kept in the * /workspace, `lib/settingsLink.ts`): the same pages, every link kept in the
* tab (replaced, as a tab click), no Back link, and Ctrl/Cmd+F left alone * tab (replaced, as a tab click), no Back link, and Ctrl/Cmd+F left alone
* while the tab is hidden behind a session. * while the tab is hidden behind a session.
* Server mode hides every tool page from a non-admin, except Claude Code for a
* non-admin with their own Linux account (`useCcTarget`): "Your Claude Code
* config", which they manage as themselves.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { import {
@@ -75,6 +78,7 @@ import { PricingSettings } from "../components/settings/PricingSettings";
import { WorktreeVisibilitySettings } from "../components/settings/WorktreeVisibilitySettings"; import { WorktreeVisibilitySettings } from "../components/settings/WorktreeVisibilitySettings";
import { ChangesPanelSettings } from "../components/settings/ChangesPanelSettings"; import { ChangesPanelSettings } from "../components/settings/ChangesPanelSettings";
import { useAiTools } from "../hooks/useAiTools"; import { useAiTools } from "../hooks/useAiTools";
import { useCcTarget } from "../hooks/useCcTarget";
import { import {
rankSettings, rankSettings,
rankSettingsEntries, rankSettingsEntries,
@@ -248,10 +252,14 @@ export function SettingsHub({ section }: { section?: string } = {}) {
}; };
}, [location.key, location.hash]); }, [location.key, location.hash]);
const { isAdmin, local } = usePermissions(); const { isAdmin, local, seesAll } = usePermissions();
const authState = useAuth().state; const authState = useAuth().state;
const signedIn = authState.mode === "server" && !!authState.user; const signedIn = authState.mode === "server" && !!authState.user;
const enabled = (id: string) => aiTools.byId(id)?.enabled !== false; const enabled = (id: string) => aiTools.byId(id)?.enabled !== false;
// Server mode: a non-admin with their own Linux account (S3) manages their
// OWN Claude Code config here; the host's stays an admin's.
const ccTarget = useCcTarget();
const ownClaude = !isAdmin && ccTarget?.kind === "own";
const s = (key: string) => ({ const s = (key: string) => ({
title: t(`settingsNav.section.${key}.title`), title: t(`settingsNav.section.${key}.title`),
description: t(`settingsNav.section.${key}.description`), description: t(`settingsNav.section.${key}.description`),
@@ -299,8 +307,9 @@ export function SettingsHub({ section }: { section?: string } = {}) {
<AgentInstructionsSettings /> <AgentInstructionsSettings />
<SessionDefaultsSettings /> <SessionDefaultsSettings />
<AiToolsSettings settingsHref={toolHref} /> <AiToolsSettings settingsHref={toolHref} />
{/* Every AI tool's login profiles, linked to the account pool. */} {/* Every AI tool's login profiles, linked to the account pool: the
<ToolProfilesSettings /> host's logins, a staff-only read (a developer would get 403s). */}
{seesAll && <ToolProfilesSettings />}
{/* The Hub's install switch: server mode, an admin's (it hides itself). */} {/* The Hub's install switch: server mode, an admin's (it hides itself). */}
<ArtifactsHubSettings /> <ArtifactsHubSettings />
{/* The host's omp login for members: server mode + S3, an admin's. */} {/* The host's omp login for members: server mode + S3, an admin's. */}
@@ -315,7 +324,7 @@ export function SettingsHub({ section }: { section?: string } = {}) {
group: "agents" as const, group: "agents" as const,
tool: "claude", tool: "claude",
Icon: toolNavIcon("claude", "Claude Code"), Icon: toolNavIcon("claude", "Claude Code"),
...s("claudeCode"), ...s(ownClaude ? "claudeCodeOwn" : "claudeCode"),
render: () => <CcConfig />, render: () => <CcConfig />,
}, },
] ]
@@ -439,7 +448,10 @@ export function SettingsHub({ section }: { section?: string } = {}) {
// server answers 403 EADMIN), so its pages — every tool's too — are hidden. // server answers 403 EADMIN), so its pages — every tool's too — are hidden.
const sections = isAdmin const sections = isAdmin
? allSections ? allSections
: allSections.filter((x) => !x.tool && !ADMIN_SECTIONS.has(x.id)); : allSections.filter(
(x) =>
(!x.tool && !ADMIN_SECTIONS.has(x.id)) || (ownClaude && x.id === "agents/claude-code")
);
const navSections = sections.filter((x) => !x.tool); const navSections = sections.filter((x) => !x.tool);
const toolTabs = sections.filter((x) => x.tool); const toolTabs = sections.filter((x) => x.tool);
function toolHref(id: string) { function toolHref(id: string) {
+252 -111
View File
@@ -1,38 +1,56 @@
/** /**
* @file Users.tsx * @file Users.tsx
* @description The Users tab of the Administration page (`/admin`, which * @description The users section of the Administration page (`/admin`, which
* `/users` redirects to — `Administration.tsx`), server mode only and * `/users` redirects to — `Administration.tsx`), server mode only and
* only for an admin or analyst (docs/SERVER-MODE.md): every account with its * only for an admin or analyst (docs/SERVER-MODE.md): every account with its
* role, state, workspaces, sessions, the tokens/$ it used today (the * role, state, workspaces, sessions, the $ it used today (the browser's local
* browser's local day) and in the last 7 and 30 days (`GET /api/users`) and how much disk its home folder takes * day) with 7 and 30 days under it (`GET /api/users`),
* how much disk its home folder takes and its share of the homes disk (from
* `host`, the page's one `GET /api/host` read; each user's live CPU / RAM is on
* the Dashboard's System Health tab)
* (`GET /api/users/storage`, measured in the background — polled while the * (`GET /api/users/storage`, measured in the background — polled while the
* server says `measuring`; Refresh re-measures), the audit log (`GET /api/users/audit`), and — for * server says `measuring`; Refresh re-measures), and — one Linux user per
* account — its own tmux server (`MemberTmux.tsx`: version, outdated badge,
* sessions, agents; an admin's restart in the row's actions and "Restart idle"
* above the table): every per-user fact in ONE table. Also the audit log
* (`GET /api/users/audit`), and — for
* an admin — add a user, and ONE Edit per row (`UserEditDialog`: profile, * an admin — add a user, and ONE Edit per row (`UserEditDialog`: profile,
* role, active/disabled, password, owned workspaces, sign out everywhere, sent * role, active/disabled, password, owned workspaces, sign out everywhere, sent
* as one change), and the Claude/Codex account pool every user's sessions run * as one change), and the Claude/Codex account pool every user's sessions run
* on (`AccountPoolCard`). (Each member's own tmux server — one Linux user per * on (`AccountPoolCard`).
* account — moved to the Server tab, `MemberTmuxCard` in `ServerPanel`.)
* Every change asks for the admin's OWN password again (the * Every change asks for the admin's OWN password again (the
* server refuses without it); an analyst only reads. A row shows the display * server refuses without it); an analyst only reads. A row shows the display
* name and email under the username when set, and "New password due" while * name and email under the username when set, and "New password due" while
* the account still has a password an admin gave; right-clicking a row offers * the account still has a password an admin gave; right-clicking a row offers
* Edit, Open home (in /files), and copying the username or email. * Edit, Open home (in /files), and copying the username or email. Clicking a
* row (or its name) expands that account in full right under it —
* `UserDetail`, one at a time, kept in `?user=<id>` so a reload or a shared
* link lands on it. `children` go between the account pool and the audit log.
* Laid out like the other list pages: icon header, a row of totals * Laid out like the other list pages: icon header, a row of totals
* (`StatCard`), the accounts in a `card` table, the audit log in a card. * (`StatCard`), the accounts in a `card` table, the audit log in a card.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { useCallback, useEffect, useState, type KeyboardEvent, type ReactNode } from "react"; import {
import { Navigate, useNavigate } from "react-router-dom"; Fragment,
useCallback,
useEffect,
useState,
type KeyboardEvent,
type ReactNode,
} from "react";
import { Navigate, useNavigate, useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
AlertCircle, AlertCircle,
ChevronRight,
DollarSign, DollarSign,
Eye, Eye,
FolderOpen, FolderOpen,
MessagesSquare, MessagesSquare,
Pencil, Pencil,
RefreshCw, RefreshCw,
RotateCcw,
ScrollText, ScrollText,
UserPlus, UserPlus,
Users as UsersIcon, Users as UsersIcon,
@@ -41,6 +59,7 @@ import {
api, api,
type AuditEntry, type AuditEntry,
type AuthUser, type AuthUser,
type MemberTmuxRow,
type ServerUserUsage, type ServerUserUsage,
type UserStorage, type UserStorage,
} from "../lib/api"; } from "../lib/api";
@@ -53,6 +72,15 @@ import { ConfirmModal } from "../components/ConfirmModal";
import { StatCard } from "../components/StatCard"; import { StatCard } from "../components/StatCard";
import { ROLE_BADGE, UserEditDialog } from "../components/users/UserEditDialog"; import { ROLE_BADGE, UserEditDialog } from "../components/users/UserEditDialog";
import { AccountPoolCard } from "../components/users/AccountPoolCard"; import { AccountPoolCard } from "../components/users/AccountPoolCard";
import { UserDetail } from "../components/users/UserDetail";
import { UserAvatar } from "../components/users/UserAvatar";
import {
MemberTmuxBar,
TmuxCell,
TmuxRestartDialog,
useMemberTmux,
} from "../components/users/MemberTmux";
import { Meter, fmtPct, pct, type HostState } from "../components/health/HostHealth";
import { TableRowSkeleton } from "../components/Skeleton"; import { TableRowSkeleton } from "../components/Skeleton";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../components/ContextMenu"; import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../components/ContextMenu";
import { copyText } from "../lib/clipboard"; import { copyText } from "../lib/clipboard";
@@ -60,7 +88,16 @@ import { copyText } from "../lib/clipboard";
type Role = AuthUser["role"]; type Role = AuthUser["role"];
const ROLES: Role[] = ["admin", "analyst", "developer"]; const ROLES: Role[] = ["admin", "analyst", "developer"];
export function Users({ embedded = false }: { embedded?: boolean } = {}) { export function Users({
embedded = false,
host,
children,
}: {
embedded?: boolean;
/** The Administration page's one `GET /api/host` read: each user's CPU/RAM now. */
host?: HostState;
children?: ReactNode;
} = {}) {
const { t } = useTranslation("users"); const { t } = useTranslation("users");
const auth = useAuth(); const auth = useAuth();
const me = auth.state.mode === "server" ? auth.state.user : null; const me = auth.state.mode === "server" ? auth.state.user : null;
@@ -75,6 +112,24 @@ export function Users({ embedded = false }: { embedded?: boolean } = {}) {
const navigate = useNavigate(); const navigate = useNavigate();
const [storage, setStorage] = useState<Record<string, UserStorage> | null>(null); const [storage, setStorage] = useState<Record<string, UserStorage> | null>(null);
const [measuring, setMeasuring] = useState(false); const [measuring, setMeasuring] = useState(false);
const tmux = useMemberTmux(!!me && me.role !== "developer");
// A member's tmux restart: one member, or every idle outdated one.
const [restarting, setRestarting] = useState<MemberTmuxRow | "idle" | null>(null);
const [params, setParams] = useSearchParams();
const detailId = Number(params.get("user")) || null;
const openDetail = useCallback(
(id: number | null) =>
setParams(
(prev) => {
const next = new URLSearchParams(prev);
if (id == null) next.delete("user");
else next.set("user", String(id));
return next;
},
{ replace: true }
),
[setParams]
);
const loadStorage = useCallback( const loadStorage = useCallback(
(remeasure = false) => (remeasure = false) =>
@@ -108,7 +163,14 @@ export function Users({ embedded = false }: { embedded?: boolean } = {}) {
); );
// The Refresh button also re-measures every home; a plain load reuses the // The Refresh button also re-measures every home; a plain load reuses the
// server's cached numbers. // server's cached numbers.
const [spinning, refresh] = useSpin(useCallback(() => load(true), [load])); const reloadHost = host?.reload;
const reloadTmux = tmux.reload;
const [spinning, refresh] = useSpin(
useCallback(
() => Promise.all([load(true), reloadTmux(), reloadHost?.()]),
[load, reloadTmux, reloadHost]
)
);
useEffect(() => { useEffect(() => {
if (me && me.role !== "developer") void load(); if (me && me.role !== "developer") void load();
@@ -127,7 +189,12 @@ export function Users({ embedded = false }: { embedded?: boolean } = {}) {
const sum = (pick: (u: ServerUserUsage) => number) => const sum = (pick: (u: ServerUserUsage) => number) =>
(users ?? []).reduce((n, u) => n + pick(u), 0); (users ?? []).reduce((n, u) => n + pick(u), 0);
const active = (users ?? []).filter((u) => !u.disabled).length; const active = (users ?? []).filter((u) => !u.disabled).length;
const th = "px-5 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider"; const th = "px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider";
const stats = host?.stats ?? null;
const homesDisk = stats?.disks.find((d) => d.mount === stats.homesMount) ?? null;
const showTmux = !!tmux.data?.available;
const tmuxById = new Map((tmux.data?.users ?? []).map((r) => [r.id, r]));
const columns = 5 + (showTmux ? 1 : 0) + (isAdmin ? 1 : 0);
return ( return (
<div className="animate-fade-in space-y-6"> <div className="animate-fade-in space-y-6">
@@ -139,10 +206,12 @@ export function Users({ embedded = false }: { embedded?: boolean } = {}) {
</div> </div>
)} )}
<div> <div>
{/* Under Administration's tab bar the tab already names the page. */} {/* On the Administration page this is one section of it: a section heading. */}
<h1 className={embedded ? "sr-only" : "text-lg font-semibold text-fg-primary"}> {embedded ? (
{t("title")} <h2 className="text-base font-semibold text-fg-primary">{t("title")}</h2>
</h1> ) : (
<h1 className="text-lg font-semibold text-fg-primary">{t("title")}</h1>
)}
<p className="text-xs text-fg-muted">{t("description")}</p> <p className="text-xs text-fg-muted">{t("description")}</p>
</div> </div>
</div> </div>
@@ -213,18 +282,18 @@ export function Users({ embedded = false }: { embedded?: boolean } = {}) {
</p> </p>
)} )}
<MemberTmuxBar data={tmux.data} isAdmin={isAdmin} onRestart={setRestarting} />
<div className="card overflow-x-auto"> <div className="card overflow-x-auto">
<table className="w-full min-w-[960px]" data-testid="users-table"> <table className="w-full min-w-[1040px]" data-testid="users-table">
<thead> <thead>
<tr className="border-b border-border text-left"> <tr className="border-b border-border text-left">
<th className={th}>{t("name")}</th> <th className={th}>{t("name")}</th>
<th className={th}>{t("role")}</th> <th className={th}>{t("role")}</th>
<th className={`${th} text-right`}>{t("workspaces")}</th> <th className={th}>{t("activity")}</th>
<th className={`${th} text-right`}>{t("sessions")}</th> <th className={`${th} text-right`}>{t("spend")}</th>
<th className={`${th} text-right`}>{t("today")}</th> <th className={`${th} w-[12%]`}>{t("storage")}</th>
<th className={`${th} text-right`}>{t("last7d")}</th> {showTmux && <th className={th}>{t("tmuxCol")}</th>}
<th className={`${th} text-right`}>{t("last30d")}</th>
<th className={`${th} text-right`}>{t("storage")}</th>
{isAdmin && <th className={`${th} text-right`}>{t("actions")}</th>} {isAdmin && <th className={`${th} text-right`}>{t("actions")}</th>}
</tr> </tr>
</thead> </thead>
@@ -234,87 +303,140 @@ export function Users({ embedded = false }: { embedded?: boolean } = {}) {
Array.from({ length: 4 }).map((_, i) => ( Array.from({ length: 4 }).map((_, i) => (
<TableRowSkeleton <TableRowSkeleton
key={i} key={i}
columns={isAdmin ? 8 : 7} columns={columns}
widths={["w-36", "w-20", "w-8", "w-8", "w-14", "w-14", "w-14", "w-28"]} widths={["w-36", "w-20", "w-24", "w-16", "w-24", "w-24", "w-16", "w-10"].slice(
0,
columns
)}
/> />
))} ))}
{users?.map((u) => { {users?.map((u) => {
const self = u.id === me.id; const self = u.id === me.id;
const open = detailId === u.id;
const toggle = () => openDetail(open ? null : u.id);
const tmuxRow = tmuxById.get(u.id);
const home = storage ? (storage[u.id] ?? null) : undefined;
return ( return (
<tr <Fragment key={u.id}>
key={u.id} <tr
onContextMenu={(e) => rowMenu.open(e, u)} onContextMenu={(e) => rowMenu.open(e, u)}
className={`hover:bg-surface-4/40 transition-colors ${u.disabled ? "opacity-60" : ""}`} onClick={(e) => {
> if ((e.target as HTMLElement).closest("a,button")) return;
<td className="px-5 py-3"> toggle();
<div className="flex items-center gap-3 min-w-0"> }}
<span className={`cursor-pointer hover:bg-surface-4/40 transition-colors ${open ? "bg-surface-3/50" : ""} ${u.disabled ? "opacity-60" : ""}`}
aria-hidden >
className="w-8 h-8 flex-shrink-0 rounded-full bg-surface-4 flex items-center justify-center text-xs font-semibold uppercase text-fg-secondary" <td className="px-4 py-3">
> <div className="flex items-center gap-3 min-w-0">
{(u.displayName || u.username).slice(0, 2)} <ChevronRight
</span> aria-hidden
<div className="min-w-0"> className={`w-4 h-4 flex-shrink-0 text-fg-muted transition-transform ${open ? "rotate-90" : ""}`}
<div className="truncate text-sm font-medium text-fg-primary"> />
{u.displayName || u.username} <UserAvatar
{u.displayName && ( name={u.displayName || u.username}
<span className="ml-1.5 font-normal text-fg-muted">@{u.username}</span> userId={u.id}
avatarAt={u.avatarAt}
className="h-8 w-8 text-xs"
initialsClassName="bg-surface-4 text-fg-secondary"
/>
<div className="min-w-0">
<button
type="button"
onClick={toggle}
aria-expanded={open}
title={t("detail.open", { name: u.displayName || u.username })}
className="block max-w-full truncate text-left text-sm font-medium text-fg-primary hover:text-accent"
>
{u.displayName || u.username}
{u.displayName && (
<span className="ml-1.5 font-normal text-fg-muted">
@{u.username}
</span>
)}
</button>
{u.email && (
<div className="truncate text-[11px] text-fg-muted">{u.email}</div>
)} )}
</div> </div>
{u.email && ( {self && (
<div className="truncate text-[11px] text-fg-muted">{u.email}</div> <span className="badge py-0.5 text-[11px] bg-accent/15 text-accent border-accent/20">
{t("you")}
</span>
)}
{u.disabled && (
<span className="badge py-0.5 text-[11px] bg-status-danger/10 text-status-danger border-status-danger/20">
{t("disabled")}
</span>
)}
{!u.disabled && u.mustChangePassword && (
<span
className="badge py-0.5 text-[11px] bg-status-warning/10 text-status-warning border-status-warning/20"
title={t("mustChangeHint")}
data-testid="user-must-change"
>
{t("mustChange")}
</span>
)} )}
</div> </div>
{self && (
<span className="badge py-0.5 text-[11px] bg-accent/15 text-accent border-accent/20">
{t("you")}
</span>
)}
{u.disabled && (
<span className="badge py-0.5 text-[11px] bg-status-danger/10 text-status-danger border-status-danger/20">
{t("disabled")}
</span>
)}
{!u.disabled && u.mustChangePassword && (
<span
className="badge py-0.5 text-[11px] bg-status-warning/10 text-status-warning border-status-warning/20"
title={t("mustChangeHint")}
data-testid="user-must-change"
>
{t("mustChange")}
</span>
)}
</div>
</td>
<td className="px-5 py-3">
<span
className={`badge py-0.5 text-[11px] ${ROLE_BADGE[u.role]}`}
title={t(`roleHint.${u.role}`)}
>
{t(`roles.${u.role}`)}
</span>
</td>
<td className="px-5 py-3 text-right text-sm tabular-nums text-fg-secondary">
{u.workspaces}
</td>
<td className="px-5 py-3 text-right text-sm tabular-nums text-fg-secondary">
{u.sessions}
</td>
<UsageCell usage={u.today ?? { cost: 0, tokens: 0 }} />
<UsageCell usage={u.last7d} />
<UsageCell usage={u.last30d} />
<StorageCell
storage={storage ? (storage[u.id] ?? null) : undefined}
measuring={measuring}
/>
{isAdmin && (
<td className="px-5 py-2 text-right whitespace-nowrap">
<RowAction label={t("edit.button")} onClick={() => setEditing(u)}>
<Pencil className="h-4 w-4" />
</RowAction>
</td> </td>
<td className="px-4 py-3">
<span
className={`badge py-0.5 text-[11px] ${ROLE_BADGE[u.role]}`}
title={t(`roleHint.${u.role}`)}
>
{t(`roles.${u.role}`)}
</span>
</td>
<td className="px-4 py-3 text-sm tabular-nums">
<div className="text-fg-primary">{t("sessionsN", { n: u.sessions })}</div>
<div className="text-[11px] text-fg-muted">
{t("workspacesN", { n: u.workspaces })}
</div>
</td>
<td
className="px-4 py-3 text-right tabular-nums"
title={t("tokens", { n: fmt((u.today ?? { tokens: 0 }).tokens) })}
>
<div className="text-sm text-fg-primary">
{fmtCost((u.today ?? { cost: 0 }).cost)}
</div>
<div className="whitespace-nowrap text-[11px] text-fg-muted">
{t("spendSub", {
week: fmtCost(u.last7d.cost),
month: fmtCost(u.last30d.cost),
})}
</div>
</td>
<StorageCell storage={home} measuring={measuring} disk={homesDisk} />
{showTmux && (
<td className="px-4 py-3">
<TmuxCell row={tmuxRow} />
</td>
)}
{isAdmin && (
<td className="px-4 py-2 text-right whitespace-nowrap">
{tmuxRow?.running && (
<RowAction
label={t("tmux.restart", { name: u.username })}
onClick={() => setRestarting(tmuxRow)}
>
<RotateCcw className="h-4 w-4" />
</RowAction>
)}
<RowAction label={t("edit.button")} onClick={() => setEditing(u)}>
<Pencil className="h-4 w-4" />
</RowAction>
</td>
)}
</tr>
{open && (
<tr className="bg-surface-2/40">
<td colSpan={columns} className="px-5 py-4 cursor-default">
<UserDetail userId={u.id} />
</td>
</tr>
)} )}
</tr> </Fragment>
); );
})} })}
</tbody> </tbody>
@@ -335,6 +457,9 @@ export function Users({ embedded = false }: { embedded?: boolean } = {}) {
onClose={rowMenu.close} onClose={rowMenu.close}
testId="users-row-menu" testId="users-row-menu"
> >
<MenuItem onSelect={pick(() => openDetail(detailId === u.id ? null : u.id))}>
{t("detail.open", { name: u.displayName || u.username })}
</MenuItem>
{isAdmin && ( {isAdmin && (
<MenuItem onSelect={pick(() => setEditing(u))}>{t("edit.button")}</MenuItem> <MenuItem onSelect={pick(() => setEditing(u))}>{t("edit.button")}</MenuItem>
)} )}
@@ -364,6 +489,8 @@ export function Users({ embedded = false }: { embedded?: boolean } = {}) {
<AccountPoolCard isAdmin={isAdmin} /> <AccountPoolCard isAdmin={isAdmin} />
{children}
<section className="card"> <section className="card">
<div className="flex items-center gap-2 px-5 py-4 border-b border-border"> <div className="flex items-center gap-2 px-5 py-4 border-b border-border">
<ScrollText className="w-4 h-4 text-fg-muted" /> <ScrollText className="w-4 h-4 text-fg-muted" />
@@ -417,6 +544,18 @@ export function Users({ embedded = false }: { embedded?: boolean } = {}) {
}} }}
/> />
)} )}
{restarting && (
<TmuxRestartDialog
target={restarting}
data={tmux.data}
onClose={() => setRestarting(null)}
onDone={(message) => {
toast.success(message);
setRestarting(null);
void tmux.reload();
}}
/>
)}
{editing && ( {editing && (
<UserEditDialog <UserEditDialog
user={editing} user={editing}
@@ -434,47 +573,49 @@ export function Users({ embedded = false }: { embedded?: boolean } = {}) {
); );
} }
/** A period's cost, with its tokens underneath. */
function UsageCell({ usage }: { usage: { cost: number; tokens: number } }) {
const { t } = useTranslation("users");
return (
<td className="px-5 py-3 text-right tabular-nums">
<div className="text-sm text-fg-primary">{fmtCost(usage.cost)}</div>
<div className="text-[11px] text-fg-muted">{t("tokens", { n: fmt(usage.tokens) })}</div>
</td>
);
}
/** /**
* A user's home folder size (`undefined` = not loaded yet). `null` while the * A user's home folder size (`undefined` = not loaded yet). `null` while the
* server is still measuring reads "Measuring…", otherwise "—" (no home of * server is still measuring reads "Measuring…", otherwise "—" (no home of
* their own). * their own).
*/ */
function StorageCell({ storage, measuring }: { storage?: UserStorage; measuring: boolean }) { function StorageCell({
storage,
measuring,
disk,
}: {
storage?: UserStorage;
measuring: boolean;
/** The disk holding the homes: the home is also shown as a share of it. */
disk: { total: number } | null;
}) {
const { t } = useTranslation("users"); const { t } = useTranslation("users");
if (!storage) { if (!storage) {
const pending = storage === undefined || measuring; const pending = storage === undefined || measuring;
return ( return (
<td <td
className="px-5 py-3 text-right text-sm tabular-nums text-fg-muted" className="px-4 py-3 text-sm tabular-nums text-fg-muted"
title={pending ? undefined : t("storageNone")} title={pending ? undefined : t("storageNone")}
> >
{pending ? t("storageMeasuring") : "—"} {pending ? t("storageMeasuring") : "—"}
</td> </td>
); );
} }
const share = disk ? pct(storage.bytes, disk.total) : null;
return ( return (
<td <td
className="px-5 py-3 text-right tabular-nums" className="px-4 py-3 tabular-nums"
title={storage.partial ? t("storagePartial") : undefined} title={
storage.partial
? t("storagePartial")
: t("storageHint", { files: fmt(storage.files), at: formatDateTime(storage.measuredAt) })
}
> >
<div className="text-sm text-fg-primary"> <div className="text-sm text-fg-primary">
{storage.partial ? "≥ " : ""} {storage.partial ? "≥ " : ""}
{formatBytes(storage.bytes)} {formatBytes(storage.bytes)}
{share != null && <span className="text-[11px] text-fg-muted"> ({fmtPct(share)})</span>}
</div> </div>
<div className="text-[11px] text-fg-muted"> {share != null && <Meter value={share} label={t("storage")} />}
{t("storageHint", { files: fmt(storage.files), at: formatDateTime(storage.measuredAt) })}
</div>
</td> </td>
); );
} }
+2 -2
View File
@@ -1251,7 +1251,7 @@ export function Workspace() {
} }
}; };
// Mirrors `agenthub lanes pipeline <template> <id>`: same PATCH, same // Mirrors `agenthub workspace pipeline <template> <id>`: same PATCH, same
// stage-mismatch warning when the current declared stage matches no node // stage-mismatch warning when the current declared stage matches no node
// in the newly chosen template. // in the newly chosen template.
const handlePipelineChange = async (id: number, pipeline: string) => { const handlePipelineChange = async (id: number, pipeline: string) => {
@@ -1420,7 +1420,7 @@ export function Workspace() {
<div data-testid="proof-gallery" className="mt-2"> <div data-testid="proof-gallery" className="mt-2">
{proofFeature.ticket_report && ( {proofFeature.ticket_report && (
<a <a
href={`/api/lanes/${currentLane.id}/proof/${proofFeature.ticket_report}`} href={`/api/workspaces/${currentLane.id}/proof/${proofFeature.ticket_report}`}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="text-xs text-fg-muted" className="text-xs text-fg-muted"
@@ -1,14 +1,14 @@
/** /**
* @file Administration.test.tsx * @file Administration.test.tsx
* @description The Administration page (`/admin`): local mode and a developer * @description The Administration page (`/admin`): local mode and a developer
* are sent home; staff land on the Users tab; the Server tab shows the * are sent home; staff see ONE page, no tabs, and no machine section (that is
* machine's hardware, every disk, and each user's CPU/memory/home share from * the Dashboard's System Health tab now, each user's live CPU / RAM included)
* `GET /api/host`, with a user's busiest processes on click. * — the users table carries each user's home share (`GET /api/host`) and tmux.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, within } from "@testing-library/react"; import { render, screen, waitFor, within } from "@testing-library/react";
import { MemoryRouter, Route, Routes } from "react-router-dom"; import { MemoryRouter, Route, Routes } from "react-router-dom";
import type { AuthState, HostStats } from "../../lib/api"; import type { AuthState, HostStats } from "../../lib/api";
@@ -73,7 +73,7 @@ const api = vi.hoisted(() => ({
list: vi.fn(), list: vi.fn(),
audit: vi.fn(), audit: vi.fn(),
storage: vi.fn(), storage: vi.fn(),
tmux: vi.fn(async () => ({ available: false, users: [] })), tmux: vi.fn(),
avatarUrl: () => null, avatarUrl: () => null,
}, },
})); }));
@@ -137,6 +137,7 @@ beforeEach(() => {
], ],
}); });
api.users.audit.mockResolvedValue({ entries: [] }); api.users.audit.mockResolvedValue({ entries: [] });
api.users.tmux.mockResolvedValue({ available: false, users: [] });
api.users.storage.mockResolvedValue({ api.users.storage.mockResolvedValue({
storage: { 2: { bytes: 20 * GB, files: 10, partial: false, measuredAt: "x" } }, storage: { 2: { bytes: 20 * GB, files: 10, partial: false, measuredAt: "x" } },
measuring: false, measuring: false,
@@ -153,27 +154,40 @@ describe("Administration", () => {
expect((await screen.findAllByTestId("home")).length).toBeGreaterThan(0); expect((await screen.findAllByTestId("home")).length).toBeGreaterThan(0);
}); });
it("opens on the Users tab", async () => { it("shows the users on one page, with no tabs and no machine section", async () => {
authState = { mode: "server", user: { id: 1, username: "boss", role: "admin" } }; authState = { mode: "server", user: { id: 1, username: "boss", role: "admin" } };
renderAt("/admin"); renderAt("/admin");
expect(await screen.findByTestId("users-table")).toBeTruthy(); expect(await screen.findByTestId("users-table")).toBeTruthy();
expect(screen.getByRole("tab", { name: /users/i }).getAttribute("aria-selected")).toBe("true"); expect(screen.queryByTestId("server-facts")).toBeNull();
expect(screen.queryByRole("tab", { name: "Server" })).toBeNull();
expect(screen.queryByRole("tab", { name: "Users" })).toBeNull();
}); });
it("shows hardware and each user's share on the Server tab", async () => { it("puts each user's home and tmux in the users table, but not their CPU", async () => {
authState = { mode: "server", user: { id: 1, username: "boss", role: "admin" } }; authState = { mode: "server", user: { id: 1, username: "boss", role: "admin" } };
renderAt("/admin?tab=server"); api.users.tmux.mockResolvedValue({
const table = await screen.findByTestId("server-usage-table"); available: true,
expect(await screen.findByText("box")).toBeTruthy(); installed: "3.8",
expect(screen.getByTestId("server-disks-table").textContent).toContain("HDD"); matched: true,
const mia = await within(table).findByText("Mia"); users: [
const row = mia.closest("tr")!; {
expect(row.textContent).toContain("25%"); id: 2,
expect(row.textContent).toContain("(13%)"); username: "mia",
// 20 GB home on the 200 GB homes disk. running: true,
expect(row.textContent).toContain("10%"); version: "3.6",
expect(within(table).queryByText("claude")).toBeNull(); sessions: 2,
fireEvent.click(mia.closest("button")!); busy: 1,
expect(within(table).getByText("claude")).toBeTruthy(); outdated: true,
},
],
});
renderAt("/admin");
const users = await screen.findByTestId("users-table");
const row = (await within(users).findByRole("button", { name: /^Mia/ })).closest("tr")!;
// 20 GB home on the 200 GB homes disk (the host read, once).
await waitFor(() => expect(row.textContent).toContain("(10%)"));
expect(row.textContent).not.toContain("25%");
expect(row.textContent).toContain("older than installed");
expect(within(row).getByRole("button", { name: "Restart mia's tmux" })).toBeTruthy();
}); });
}); });
+10 -5
View File
@@ -238,12 +238,17 @@ describe("Artifacts", () => {
within(screen.getByTestId("artifact-menu")).getByRole("menuitem", { name: "Share" }) within(screen.getByTestId("artifact-menu")).getByRole("menuitem", { name: "Share" })
).toBeInTheDocument(); ).toBeInTheDocument();
await user.keyboard("{Escape}"); await user.keyboard("{Escape}");
// One merged list by default; Hub is a filter over it. // One merged list by default; Hub is a filter over it. The Suggested
expect(api.artifacts.list).toHaveBeenLastCalledWith(expect.objectContaining({ scope: "all" })); // count's own `unowned` list runs whenever settings land, so it is left
// out: only the order of the LIST loads is under test.
const listScopes = () =>
vi
.mocked(api.artifacts.list)
.mock.calls.map(([opts]) => opts?.scope)
.filter((s) => s !== "unowned");
expect(listScopes().pop()).toBe("all");
await user.click(screen.getByRole("button", { name: "Hub" })); await user.click(screen.getByRole("button", { name: "Hub" }));
await waitFor(() => await waitFor(() => expect(listScopes().pop()).toBe("hub"));
expect(api.artifacts.list).toHaveBeenLastCalledWith(expect.objectContaining({ scope: "hub" }))
);
}); });
it("splits the Hub and My library into yours and others'", async () => { it("splits the Hub and My library into yours and others'", async () => {
@@ -0,0 +1,133 @@
/**
* @file Covers the Claude Code config page as a MEMBER's own config (server
* mode, a non-admin with their own Linux account): it says whose config it is,
* and the host-wide controls — Switch profile, Update all, AgentHub's own
* Claude settings — are not shown (the server refuses them anyway). An admin
* still sees all three.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
const perms = vi.hoisted(() => ({ isAdmin: false }));
vi.mock("../../components/AuthGate", async (importOriginal) => {
const m = await importOriginal<typeof import("../../components/AuthGate")>();
return {
...m,
usePermissions: () => ({ ...m.usePermissions(), local: false, isAdmin: perms.isAdmin }),
};
});
vi.mock("../../components/settings/ClaudeAgentHubSettings", () => ({
ClaudeAgentHubSettings: () => <div data-testid="agenthub-claude-settings" />,
}));
const HOME = "/srv/users/mia/.claude";
const ccConfigMock = vi.hoisted(() => {
const r = (value: unknown) => vi.fn().mockResolvedValue(value);
return {
target: r({ kind: "own", readOnly: false, claudeHome: "/srv/users/mia/.claude" }),
overview: r({
roots: {
claudeHome: "/srv/users/mia/.claude",
projectClaudeDir: null,
projectRoot: null,
claudeJson: "/srv/users/mia/.claude.json",
},
counts: {
skills: { user: 0, project: 0 },
agents: { user: 0, project: 0 },
commands: { user: 0, project: 0 },
outputStyles: { user: 0, project: 0 },
mcpServers: { user: 0, project: 0 },
hooks: {},
plugins: 0,
marketplaces: 0,
keybindings: 0,
settingsFiles: 0,
memory: 0,
},
}),
skills: r({ items: [] }),
agents: r({ items: [] }),
commands: r({ items: [] }),
outputStyles: r({ items: [] }),
memory: r({ items: [] }),
plugins: r({ manifestPath: null, manifestExists: false, plugins: [] }),
marketplaces: r({ items: [] }),
mcp: r({ user: [], projectScoped: [] }),
hooks: r({ items: [] }),
settings: r({ items: [] }),
keybindings: r({ groups: [] }),
statusline: r({}),
hookScripts: r({ items: [] }),
backups: r({ items: [] }),
pluginsAvailable: r({ installed: [], available: [] }),
updateAllStatus: r({ batch: null }),
};
});
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
api: { ...(actual.api as Record<string, unknown>), ccConfig: ccConfigMock },
};
});
vi.mock("../../lib/eventBus", () => ({
eventBus: {
subscribe: () => () => {},
publish: () => {},
onConnection: () => () => {},
connected: true,
setConnected: () => {},
},
}));
import { CcConfig } from "../CcConfig";
async function settle() {
await act(async () => {
await new Promise((res) => setTimeout(res, 0));
await new Promise((res) => setTimeout(res, 0));
});
}
async function open() {
render(
<MemoryRouter>
<CcConfig />
</MemoryRouter>
);
await settle();
}
beforeEach(() => {
vi.clearAllMocks();
perms.isAdmin = false;
});
describe("CcConfig — a member's own config", () => {
it("says whose config it is and hides the host-wide controls", async () => {
await open();
expect(screen.getByTestId("cc-own-config")).toHaveTextContent(HOME);
expect(screen.queryByRole("link", { name: /switch profile/i })).not.toBeInTheDocument();
expect(screen.queryByTestId("agenthub-claude-settings")).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("tab", { name: /plugins/i }));
await settle();
expect(screen.queryByRole("button", { name: /^update all$/i })).not.toBeInTheDocument();
expect(ccConfigMock.updateAllStatus).not.toHaveBeenCalled();
});
it("keeps all three for an admin", async () => {
perms.isAdmin = true;
await open();
expect(screen.queryByTestId("cc-own-config")).not.toBeInTheDocument();
expect(screen.getByRole("link", { name: /switch profile/i })).toBeInTheDocument();
expect(screen.getByTestId("agenthub-claude-settings")).toBeInTheDocument();
expect(ccConfigMock.target).not.toHaveBeenCalled();
});
});
@@ -21,10 +21,15 @@ import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
import type { AiTool, AiToolsView } from "../../lib/api"; import type { AiTool, AiToolsView } from "../../lib/api";
// Local mode unless a test says otherwise; still an admin either way. // Local mode unless a test says otherwise; still an admin either way.
const perms = vi.hoisted(() => ({ local: true })); const perms = vi.hoisted(() => ({ local: true, isAdmin: true }));
// What GET /api/cc-config/target answers a non-admin.
const ccTarget = vi.hoisted(() => ({ kind: "none" as "own" | "host" | "none" }));
vi.mock("../../components/AuthGate", async (importOriginal) => { vi.mock("../../components/AuthGate", async (importOriginal) => {
const m = await importOriginal<typeof import("../../components/AuthGate")>(); const m = await importOriginal<typeof import("../../components/AuthGate")>();
return { ...m, usePermissions: () => ({ ...m.usePermissions(), local: perms.local }) }; return {
...m,
usePermissions: () => ({ ...m.usePermissions(), local: perms.local, isAdmin: perms.isAdmin }),
};
}); });
vi.mock("../../components/settings/GitIdentitySettings", () => ({ vi.mock("../../components/settings/GitIdentitySettings", () => ({
GitIdentitySettings: () => <div data-testid="git-identity" />, GitIdentitySettings: () => <div data-testid="git-identity" />,
@@ -88,7 +93,14 @@ vi.mock("../../lib/api", async (importOriginal) => {
lanes: { list: vi.fn(async () => ({ lanes: [], counts: {} })) }, lanes: { list: vi.fn(async () => ({ lanes: [], counts: {} })) },
// AI tools → Profiles. // AI tools → Profiles.
toolProfiles: { list: vi.fn(async () => ({ tools: [] })) }, toolProfiles: { list: vi.fn(async () => ({ tools: [] })) },
ccConfig: { profiles: vi.fn(async () => ({ items: [], active: null })) }, ccConfig: {
profiles: vi.fn(async () => ({ items: [], active: null })),
target: vi.fn(async () => ({
kind: ccTarget.kind,
readOnly: ccTarget.kind !== "own",
claudeHome: ccTarget.kind === "own" ? "/srv/users/mia/.claude" : null,
})),
},
}, },
}; };
}); });
@@ -124,6 +136,8 @@ const nav = () => screen.getByRole("navigation", { name: "Settings sections" });
beforeEach(() => { beforeEach(() => {
perms.local = true; perms.local = true;
perms.isAdmin = true;
ccTarget.kind = "none";
__resetAiTools(); __resetAiTools();
view = { view = {
default: "claude", default: "claude",
@@ -202,6 +216,29 @@ describe("SettingsHub", () => {
expect(screen.getByTestId("ai-tools")).toBeInTheDocument(); expect(screen.getByTestId("ai-tools")).toBeInTheDocument();
}); });
it("gives a non-admin with their own account their own Claude Code page, and no other tool's", async () => {
perms.local = false;
perms.isAdmin = false;
ccTarget.kind = "own";
renderAt("/settings/agents/claude-code");
expect(await screen.findByTestId("cc-config")).toBeInTheDocument();
const tabs = within(screen.getByRole("tablist", { name: "AI tool settings" }))
.getAllByRole("tab")
.map((a) => a.textContent);
expect(tabs).toEqual(["All tools", "Your Claude Code config"]);
});
it("keeps the Claude Code page from a non-admin with no account of their own", async () => {
perms.local = false;
perms.isAdmin = false;
ccTarget.kind = "none";
renderAt("/settings/agents/claude-code");
expect(
await screen.findByRole("heading", { level: 1, name: "Appearance" })
).toBeInTheDocument();
expect(screen.queryByTestId("cc-config")).not.toBeInTheDocument();
});
it("navigates from the mobile select", async () => { it("navigates from the mobile select", async () => {
renderAt("/settings/data"); renderAt("/settings/data");
await screen.findByTestId("settings-data"); await screen.findByTestId("settings-data");
+74 -1
View File
@@ -6,7 +6,9 @@
* a wrong password reported in the edit dialog, opens ONE Edit per row (no * a wrong password reported in the edit dialog, opens ONE Edit per row (no
* separate role/password/workspace/disable buttons any more), and sees the * separate role/password/workspace/disable buttons any more), and sees the
* display name and email under a username; right-clicking a row opens that * display name and email under a username; right-clicking a row opens that
* user's home in Files (an admin) — an analyst's menu only copies. * user's home in Files (an admin) — an analyst's menu only copies; clicking a
* user's name expands their detail under the row (`?user=`), with spend and
* pool share, and a second click folds it.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
@@ -41,6 +43,8 @@ const users = vi.hoisted(() => ({
setPassword: vi.fn(), setPassword: vi.fn(),
assignWorkspace: vi.fn(), assignWorkspace: vi.fn(),
get: vi.fn(), get: vi.fn(),
details: vi.fn(),
avatarUrl: vi.fn(),
// No S3 here: the members' tmux card stays hidden. // No S3 here: the members' tmux card stays hidden.
tmux: vi.fn(async () => ({ available: false, users: [] })), tmux: vi.fn(async () => ({ available: false, users: [] })),
})); }));
@@ -96,7 +100,76 @@ beforeEach(() => {
})); }));
}); });
const details = {
user: {
id: 2,
username: "mia",
role: "developer",
disabled: false,
displayName: "Mia Tran",
logins: 1,
lastLoginAt: null,
},
sessions: { total: 3, active: 1, firstAt: null, lastAt: null },
usage: {
today: { cost: 1, tokens: 10 },
last7d: { cost: 2, tokens: 20 },
last30d: { cost: 4, tokens: 40 },
daily: [{ date: "2026-10-27", cost: 4, tokens: 40 }],
byTool: [{ key: "codex", cost: 4, tokens: 40, sessions: 1 }],
byModel: [{ key: "gpt-5.5", cost: 4, tokens: 40, sessions: 1 }],
},
workspaces: [],
panes: [],
recentSessions: [],
pool: {
claude: {
pin: "team-1",
group: null,
accounts: [
{
name: "team-1",
kind: "token",
email: null,
expired: false,
allowed: true,
pinned: true,
quota: null,
quotaPercent: null,
full: false,
livePanes: 0,
spend5h: 0,
spend7d: 10,
userSpend5h: 0,
userSpend7d: 2.5,
userTokens5h: 0,
userTokens7d: 99,
userRuns7d: 2,
userLivePanes: 0,
userLastAt: null,
},
],
},
},
audit: [],
};
describe("Users page", () => { describe("Users page", () => {
it("expands a user's detail under their row, and folds it again", async () => {
authState = { mode: "server", user: { id: 1, username: "boss", role: "admin" } };
users.details.mockResolvedValue(details);
renderPage();
fireEvent.click(await screen.findByRole("button", { name: "mia" }));
const pool = await screen.findByTestId("user-pool-account");
expect(users.details).toHaveBeenCalledWith(2);
const page = screen.getByTestId("user-detail");
expect(within(screen.getByTestId("users-table")).getByTestId("user-detail")).toBe(page);
expect(within(pool).getByText("team-1")).toBeTruthy();
expect(within(pool).getByText(/25%/)).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "mia" }));
await waitFor(() => expect(screen.queryByTestId("user-detail")).toBeNull());
});
it("sends local mode and a member home", async () => { it("sends local mode and a member home", async () => {
authState = { mode: "local" }; authState = { mode: "local" };
renderPage(); renderPage();
File diff suppressed because it is too large Load Diff
+6
View File
@@ -99,6 +99,12 @@ export default defineConfig({
}, },
build: { build: {
outDir: "dist", outDir: "dist",
// ES2021 or newer, never Vite's es2020 default: esbuild 0.25 lowers
// xterm 6's `let r; …(r||={})` (requestMode, DECRQM) to `void 0||(i={})`
// and drops the `let`, so every DECRQM query tmux sends threw
// "i is not defined" inside the terminal's parser. React 19 needs these
// browsers anyway.
target: ["es2021", "chrome100", "edge100", "firefox100", "safari15"],
// Maps are still written (for debugging a build), but no bundle points at // Maps are still written (for debugging a build), but no bundle points at
// them, so a browser never downloads them. // them, so a browser never downloads them.
sourcemap: "hidden", sourcemap: "hidden",
+26 -9
View File
@@ -89,7 +89,7 @@ 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/login` `{username, password}` | `{user}` + the cookie; `401 EBADLOGIN`, `429 ETOOMANY`, `404 ENOTSERVER` in local mode |
| `POST /api/auth/logout` | `{ok: true}`, cookie cleared | | `POST /api/auth/logout` | `{ok: true}`, cookie cleared |
In server mode a **developer** 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 (`displayName`, `email`, `createdAt`, `logins`, `lastLoginAt` — the last login ever, kept after it ends —, `mustChangePassword` included) with its workspaces, sessions and `today` (since the viewer's local midnight — `?tz_offset=` is the browser's `getTimezoneOffset()`, UTC without it) / 7 / 30-day `{cost, tokens}`, `GET /api/users/storage[?refresh=1]` each user's home size (`{storage: {<id>: {bytes, files, partial, measuredAt} | null}, measuring}` — measured in the background, see [SERVER-MODE.md](SERVER-MODE.md)), `GET /api/users/tmux` each member's own tmux server under one Linux user per account (`{available, installed, matched, users: [{id, username, running, stuck?, version, pid, startedAt, sessions, busy, outdated}]}`; an admin's `POST /api/users/:id/tmux-restart` and `POST /api/users/tmux-restart-idle` restart them with `currentPassword`, audited, `409 ETMUXSTUCK` for a server that will not stop), `GET /api/host` the machine and each user's live share of it (`{at, hostname, os, uptime, cpu: {model, cores, load}, memory: {total, available, used, swapTotal, swapUsed}, disks: [{device, mount, type, kind: "HDD"|"SSD"|null, total, used, free}], homesMount, gpus: [{name, utilization, memoryUsed, memoryTotal}], usage: {cpu, sampleMs, groups: [{key, kind: "user"|"host"|"system", userId?, username?, cpu, memory, memoryPercent, processes, top: [{pid, name, cpu, memory}]}]} | null}` — read-only, cached 3 s, `usage` null off Linux), and `GET /api/users/audit[?limit=]` the staff reads of developers' sessions and admins' account changes (kept `AGENTHUB_AUDIT_DAYS` days, default 180, `0` = forever) (`reader`/`owner` are usernames, `reader_display`/`owner_display` their display names or null) (all `403 EADMIN` for a developer). See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees). In server mode a **developer** 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 (`displayName`, `email`, `createdAt`, `logins`, `lastLoginAt` — the last login ever, kept after it ends —, `mustChangePassword` included) with its workspaces, sessions and `today` (since the viewer's local midnight — `?tz_offset=` is the browser's `getTimezoneOffset()`, UTC without it) / 7 / 30-day `{cost, tokens}`, `GET /api/users/storage[?refresh=1]` each user's home size (`{storage: {<id>: {bytes, files, partial, measuredAt} | null}, measuring}` — measured in the background, see [SERVER-MODE.md](SERVER-MODE.md)), `GET /api/users/tmux` each member's own tmux server under one Linux user per account (`{available, installed, matched, users: [{id, username, running, stuck?, version, pid, startedAt, sessions, busy, outdated}]}`; an admin's `POST /api/users/:id/tmux-restart` and `POST /api/users/tmux-restart-idle` restart them with `currentPassword`, audited, `409 ETMUXSTUCK` for a server that will not stop), `GET /api/host` the machine and each user's live share of it (`{at, hostname, os, uptime, cpu: {model, cores, load}, memory: {total, available, used, swapTotal, swapUsed}, disks: [{device, mount, type, kind: "HDD"|"SSD"|null, total, used, free}], homesMount, gpus: [{name, utilization, memoryUsed, memoryTotal}], usage: {cpu, sampleMs, groups: [{key, kind: "user"|"host"|"system", userId?, username?, cpu, memory, memoryPercent, processes, top: [{pid, name, cpu, memory}]}]} | null, bootTime, network: [{name, rx, tx}], diskIo: [{name, read, write}], temperatures: [{chip, label, celsius}], pressure: {cpu, memory, io: {some10, some60, full10} | null} | null (Linux PSI, `/proc/pressure/*`: % of the last 10 / 60 s tasks stalled on it), topProcesses: [{pid, name, cpu, memory, group}], process: {pid, node, uptime, rss, heapUsed}}`, `cpu` also carrying `physicalCores` and `perCore` (busy % per thread), rates in bytes/s over the sample — read-only, cached 3 s, `usage` null and the lists empty off Linux), `GET /api/host/history?range=1h|6h|24h` the CPU/memory history every server on Linux keeps in memory since it started (both read by the Dashboard's System Health tab and open to every signed-in user — `/host` is not staff-only) (`{range, intervalMs, running, startedAt, points: [{at, cpu, memory, memoryUsed, swapUsed, load1, users: {<id>: {cpu, memory}}}]}`, one point per 30 s, averaged to ≤ 360), `GET /api/users/:id/details[?tz_offset=]` one account in full for the Administration page's user detail (`{user (with logins, lastLoginAt), sessions: {total, active, firstAt, lastAt}, usage: {today, last7d, last30d, daily: [{date, cost, tokens}] (30 local days, oldest first), byTool, byModel: [{key, cost, tokens, sessions}]}, workspaces: [{id, title, cwd, tool, kind, branch, status, createdAt, sessions, lastActiveAt, livePanes}], panes: [{id, laneId, laneTitle, laneCwd, tool, model, account, proxy, sessionId, startedAt, status}], recentSessions (15, with cost/tokens), pool: {claude|codex|cursor: {pin, group, accounts: [{name, kind, email, expired, allowed, pinned, quota, quotaPercent, full, livePanes, spend5h, spend7d, userSpend5h, userSpend7d, userTokens5h, userTokens7d, userRuns7d, userLivePanes, userLastAt}]}}, audit}` — `user*` fields are only the runs on that user's workspaces; never a token; `404 ENOUSER`), and `GET /api/users/audit[?limit=]` the staff reads of developers' sessions and admins' account changes (kept `AGENTHUB_AUDIT_DAYS` days, default 180, `0` = forever) (`reader`/`owner` are usernames, `reader_display`/`owner_display` their display names or null) (all `403 EADMIN` for a developer). See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees).
An admin changes accounts with `POST /api/users` (`{username, password, role}` → `201 {user}`; the password may be any non-empty starting one and the account gets `mustChangePassword: true`), `GET /api/users/:id` (`{user, workspaces: [{id, title, cwd}]}`, the account with `logins` and `lastLoginAt`, for the Edit form; admin or analyst), `GET /api/users/:id/avatar` (that account's picture for the sidebar's owner groups — admin or analyst, `nosniff` + `sandbox` CSP, `404 ENOAVATAR`; a workspace in `GET /api/lanes` carries `owner_avatar_at` to version its URL), `PATCH /api/users/:id` (any of `{displayName?, email?, role?, disabled?, password?, addWorkspaces?: [laneId], removeWorkspaces?: [laneId], signOut?: true}` — every field validated before any is written; `removeWorkspaces` leaves the workspace with no owner and answers `409 ENOTOWNED` for one the user does not own; an admin's own `password`/`signOut` keeps the login it came from; a password for SOMEONE ELSE may be anything non-empty and sets their `mustChangePassword`, the admin's OWN must pass every rule below, `400 EWEAKPASSWORD` with `rules`), `POST /api/users/:id/password` (`{password}`; that user's logins end; the same two bars) `POST /api/users/:id/workspaces` (`{laneId}`; the workspace's owner becomes that user) and `DELETE /api/users/:id` (delete for good: the user's logins, panes, workspaces' records, prefs and — under S3 — their Linux account and whole home, removed FIRST so a refusal (`502 EPROVISION`) leaves every row; `409 ESELF` for yourself, `409 ELASTADMIN` for the last admin; session history and the audit log stay). No path — this API or the `agenthub users` CLI — may demote, disable or delete the last enabled admin (`409 ELASTADMIN`). 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`/`EBADEMAIL`/`EBADFIELD`, `404 ENOUSER`/`ENOLANE`; a developer or analyst gets `403 EADMIN`, and local mode `403 ELOCALMODE`. No answer carries a password hash; each change is written to the audit log. An admin changes accounts with `POST /api/users` (`{username, password, role}` → `201 {user}`; the password may be any non-empty starting one and the account gets `mustChangePassword: true`), `GET /api/users/:id` (`{user, workspaces: [{id, title, cwd}]}`, the account with `logins` and `lastLoginAt`, for the Edit form; admin or analyst), `GET /api/users/:id/avatar` (that account's picture for the sidebar's owner groups — admin or analyst, `nosniff` + `sandbox` CSP, `404 ENOAVATAR`; a workspace in `GET /api/lanes` carries `owner_avatar_at` to version its URL), `PATCH /api/users/:id` (any of `{displayName?, email?, role?, disabled?, password?, addWorkspaces?: [laneId], removeWorkspaces?: [laneId], signOut?: true}` — every field validated before any is written; `removeWorkspaces` leaves the workspace with no owner and answers `409 ENOTOWNED` for one the user does not own; an admin's own `password`/`signOut` keeps the login it came from; a password for SOMEONE ELSE may be anything non-empty and sets their `mustChangePassword`, the admin's OWN must pass every rule below, `400 EWEAKPASSWORD` with `rules`), `POST /api/users/:id/password` (`{password}`; that user's logins end; the same two bars) `POST /api/users/:id/workspaces` (`{laneId}`; the workspace's owner becomes that user) and `DELETE /api/users/:id` (delete for good: the user's logins, panes, workspaces' records, prefs and — under S3 — their Linux account and whole home, removed FIRST so a refusal (`502 EPROVISION`) leaves every row; `409 ESELF` for yourself, `409 ELASTADMIN` for the last admin; session history and the audit log stay). No path — this API or the `agenthub users` CLI — may demote, disable or delete the last enabled admin (`409 ELASTADMIN`). 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`/`EBADEMAIL`/`EBADFIELD`, `404 ENOUSER`/`ENOLANE`; a developer or analyst gets `403 EADMIN`, and local mode `403 ELOCALMODE`. No answer carries a password hash; each change is written to the audit log.
@@ -117,7 +117,7 @@ An admin (server mode) adds an account with `POST /api/accounts` `{tool: "claude
The keys are the browser's own localStorage names: `theme`, `agenthub.theme.dark`, `agenthub.theme.light`, `i18nextLng`, `agenthub.terminal.font`, `agenthub.terminal.fontSize`, `agenthub.ui.fontSize`, `agenthub.pet`, `agenthub.pet.size`, `agenthub.pet.names`, `agent-dashboard-sagi-enabled`, `agent-dashboard-sagi-muted`, `agent-monitor-notifications`, `agenthub.defaultTool`, `agenthub.permissionMode` (`bypassPermissions` allowed — set only from Settings → AI tools → Session defaults, behind a confirm), `agenthub.codex.sandbox`, `agenthub.codex.approval`, `agenthub.codex.bypass` (`"true"`/`"false"`: a new Codex session's defaults), `agenthub.tour`, and `agenthub.chatUi` (`"1"` = Claude/Codex sessions open as a chat; read and written on the server only, by `client/src/lib/chatUi.ts`). The request never names an account, and writes are same-origin. An account's save writes only their own row, and under S3 a `theme` of `dark` or `light` also becomes the `theme` in their own `~/.claude/settings.json`, written as them. The keys are the browser's own localStorage names: `theme`, `agenthub.theme.dark`, `agenthub.theme.light`, `i18nextLng`, `agenthub.terminal.font`, `agenthub.terminal.fontSize`, `agenthub.ui.fontSize`, `agenthub.pet`, `agenthub.pet.size`, `agenthub.pet.names`, `agent-dashboard-sagi-enabled`, `agent-dashboard-sagi-muted`, `agent-monitor-notifications`, `agenthub.defaultTool`, `agenthub.permissionMode` (`bypassPermissions` allowed — set only from Settings → AI tools → Session defaults, behind a confirm), `agenthub.codex.sandbox`, `agenthub.codex.approval`, `agenthub.codex.bypass` (`"true"`/`"false"`: a new Codex session's defaults), `agenthub.tour`, and `agenthub.chatUi` (`"1"` = Claude/Codex sessions open as a chat; read and written on the server only, by `client/src/lib/chatUi.ts`). The request never names an account, and writes are same-origin. An account's save writes only their own row, and under S3 a `theme` of `dark` or `light` also becomes the `theme` in their own `~/.claude/settings.json`, written as them.
`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. `GET /api/settings/info` includes `server.version` (the running dashboard release). It also carries what the Dashboard's System Health score reads beyond `/api/host`: `server.event_loop` `{p50, p99, max}` (event-loop delay over the last minute, ms — the same figures as `agenthub_event_loop_delay_seconds` in `/api/metrics`) and `activity` `{last_event_at, working_agents, agents_24h: {completed, error}}` (newest event, agents working now, agents that ended in the last 24 h — scoped to the caller's rows). Pair with `agenthub version` or Settings → System to confirm client and server builds match after deploy.
```mermaid ```mermaid
sequenceDiagram sequenceDiagram
@@ -1345,7 +1345,7 @@ POST /api/lanes/:id/mcp/sync
GET /api/lanes/:id/integrations/:name GET /api/lanes/:id/integrations/:name
``` ```
`profile/init` (same-origin) detects a Node.js project in the workspace's OWN folder and scaffolds `.agenthub/profile/` there — the HTTP twin of `agenthub lanes profile init`. `{ force?: true }` overwrites; answers `{ scaffolded: true, written, todos }`, or `{ scaffolded: false, reason }` (200) when no Node.js project is found; `400 EPROFILEEXISTS` without `force`. `mcp/sync` (same-origin) copies the source repo's `mcpServers` from `~/.claude.json` into `<workspace>/.mcp.json` (paths relocated) and seeds Playwright profiles → `{ servers, profilesSeeded }`; `400 ENOMCPCONFIG` when the source has none. Never automatic — a session calls it. `integrations/:name` → `{ enabled }`, read from `.agenthub/profile/integrations.env`. All three `404 ENOLANE` for an unknown workspace; under S3 the two writes refuse a member's folder the way the runtime routes do. `profile/init` (same-origin) detects a Node.js project in the workspace's OWN folder and scaffolds `.agenthub/profile/` there — the HTTP twin of `agenthub workspace profile init`. `{ force?: true }` overwrites; answers `{ scaffolded: true, written, todos }`, or `{ scaffolded: false, reason }` (200) when no Node.js project is found; `400 EPROFILEEXISTS` without `force`. `mcp/sync` (same-origin) copies the source repo's `mcpServers` from `~/.claude.json` into `<workspace>/.mcp.json` (paths relocated) and seeds Playwright profiles → `{ servers, profilesSeeded }`; `400 ENOMCPCONFIG` when the source has none. Never automatic — a session calls it. `integrations/:name` → `{ enabled }`, read from `.agenthub/profile/integrations.env`. All three `404 ENOLANE` for an unknown workspace; under S3 the two writes refuse a member's folder the way the runtime routes do.
#### CI status and base sync #### CI status and base sync
@@ -1866,6 +1866,10 @@ classDiagram
+string pinned_at "null unless pinned" +string pinned_at "null unless pinned"
+number cost +number cost
+number agent_count +number agent_count
+number owner_id "server mode: workspace owner; null if none"
+string owner_name "server mode: owner's username"
+string owner_display_name "server mode"
+string owner_avatar_at "server mode"
} }
SessionListResponse --> Session SessionListResponse --> Session
@@ -2560,10 +2564,10 @@ curl -X DELETE http://localhost:4820/api/pricing/gpt-5.1-codex
#### Total Cost #### Total Cost
```http ```http
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> 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>&mine=1
``` ```
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). 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 with `mine=1` 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. `mine=1` narrows it to the caller's OWN sessions (those of workspaces they own) even for an admin or analyst, who otherwise see every user's sum — so each user's **Today** (and the Claude Code statusline's, which asks as the pane's workspace owner) is their own; it is a no-op in local mode. `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`. `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`.
@@ -2820,6 +2824,7 @@ curl "http://localhost:4820/api/sessions?sources=local,4d1f0e2a-7b9c-4c33-8a21-9
The `/api/cc-config/*` namespace powers the Claude Config Explorer, now the **Claude Code** tab of Settings (`/settings/agents/claude-code`). All read endpoints are pure file reads under `CLAUDE_HOME` and the project's `.claude/` dir. File mutations cover the low-risk text-file artifacts (skills, subagents, slash commands, output styles, memory), `keybindings.json`, and `settings.json` layers **via a per-key merge only** — each always creates a timestamped backup before writing. Plugin/marketplace management and MCP server add/remove are delegated to the `claude plugin …` / `claude mcp …` CLI, because `~/.claude.json` and the plugin manifests are rewritten concurrently by the running Claude Code CLI. The `/api/cc-config/*` namespace powers the Claude Config Explorer, now the **Claude Code** tab of Settings (`/settings/agents/claude-code`). All read endpoints are pure file reads under `CLAUDE_HOME` and the project's `.claude/` dir. File mutations cover the low-risk text-file artifacts (skills, subagents, slash commands, output styles, memory), `keybindings.json`, and `settings.json` layers **via a per-key merge only** — each always creates a timestamped backup before writing. Plugin/marketplace management and MCP server add/remove are delegated to the `claude plugin …` / `claude mcp …` CLI, because `~/.claude.json` and the plugin manifests are rewritten concurrently by the running Claude Code CLI.
```http ```http
GET /api/cc-config/target
GET /api/cc-config/overview GET /api/cc-config/overview
GET /api/cc-config/skills?scope=user|project|all&cwd=<dir> GET /api/cc-config/skills?scope=user|project|all&cwd=<dir>
GET /api/cc-config/agents GET /api/cc-config/agents
@@ -2861,6 +2866,8 @@ POST /api/cc-config/mcp/remove Body: { name, scope? }
The list reads take an optional `cwd` naming the project whose `.claude/` is read (default: the server's own cwd); the run console's `/` autocomplete passes the directory the run will start in, so a workspace's own project commands and skills are the ones offered. `skills`, `commands` and `plugins` read their USER scope from the account the pane will run as: under S3 the `cwd` folder's account (a member's `~/.claude`, where a Hub install puts a skill), a non-admin with no `cwd` their own, an admin otherwise the host's Claude home, and a non-admin with no account of their own NOTHING (never the host's or another member's) — so a skill a developer installed shows up in their `/` list, and an admin's install into the host's home no longer shows up in a member's. In server mode a non-admin's `cwd` (on `/api/cc-config/*` and `/api/codex-config/*`) must lie inside one of their own workspaces, else `403 EFOLDER`. `GET /api/cc-config/file` and `GET /api/codex-config/file` never serve a credential file (`.credentials.json`, `auth.json`, any non-Markdown name matching the AI Tools secret pattern, anything under `<CLAUDE_HOME>/agenthub-*`): `400 READ_DENIED`. The list reads take an optional `cwd` naming the project whose `.claude/` is read (default: the server's own cwd); the run console's `/` autocomplete passes the directory the run will start in, so a workspace's own project commands and skills are the ones offered. `skills`, `commands` and `plugins` read their USER scope from the account the pane will run as: under S3 the `cwd` folder's account (a member's `~/.claude`, where a Hub install puts a skill), a non-admin with no `cwd` their own, an admin otherwise the host's Claude home, and a non-admin with no account of their own NOTHING (never the host's or another member's) — so a skill a developer installed shows up in their `/` list, and an admin's install into the host's home no longer shows up in a member's. In server mode a non-admin's `cwd` (on `/api/cc-config/*` and `/api/codex-config/*`) must lie inside one of their own workspaces, else `403 EFOLDER`. `GET /api/cc-config/file` and `GET /api/codex-config/file` never serve a credential file (`.credentials.json`, `auth.json`, any non-Markdown name matching the AI Tools secret pattern, anything under `<CLAUDE_HOME>/agenthub-*`): `400 READ_DENIED`.
**Whose config (server mode).** The router gates itself (`server/lib/cc-target.js`): a non-admin reaches only the reads listed above and the writes that act on their own config (`PUT/DELETE /file`, `PATCH /settings`, `PUT /keybindings`, `POST /project-memory`, `/plugins/install|action`, `/marketplaces/add|action`, `/mcp/add|remove`, `/reload`); `/profiles*`, `/plugins/update-all` and any unlisted endpoint answer `403 EADMIN` (an analyst may still `GET /profiles` and `GET /plugins/update-all`). `GET /target` answers `{kind: "own"|"host"|"none", readOnly, claudeHome}`. Every other handler acts on one target: the host's Claude home for an admin and in local mode (unchanged); under S3 a non-admin's OWN `<home>/.claude` + `<home>/.claude.json` — reads through the guarded reader, writes and backups as them through `write-as`, `claude plugin|mcp` as `sudo -n -u ah-<name> HOME=… DISABLE_AUTOUPDATER=1 /opt/agenthub/tools/bin/claude …` (scope `user` only, never `-y`, no MCP `env`/`headers`: `400 EBADARG`; `503 ENOTSHARED` without a shared `claude`); `403 ENOACCOUNT` for a non-admin with no account (an analyst's reads fall back to the host's, read-only, as before; `?view=host` gives an analyst with an account that view). A member's project layer exists only with `?cwd=` naming one of their own workspaces in their own account (writes: `403 EFOLDER` otherwise; no `cwd` = no project layer, `roots.projectRoot: null`). `POST /project-memory` from a member takes only such a folder; under S3 the file is written as the folder's account, an admin's create in a member's folder included. A member's `cc_config_changed` carries `ownerId` and reaches only that user; their `/reload` (and a plugin action's `reload`) types only into panes that run as them.
`scope` is `"user"`, `"project"`, or `"auto-memory"`. `type` is one of `skills`, `agents`, `commands`, `output-styles`, `memory`, `auto-memory`. `name` is required for everything except `memory` (which is `CLAUDE.md` itself). On `PUT`, `name` is validated against `^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$` (for `auto-memory` it must instead be a flat `*.md` filename). Settings are returned with secret-like keys (matching `/token|secret|password|api[_-]?key|auth/i`) replaced by `"<redacted>"`. `scope` is `"user"`, `"project"`, or `"auto-memory"`. `type` is one of `skills`, `agents`, `commands`, `output-styles`, `memory`, `auto-memory`. `name` is required for everything except `memory` (which is `CLAUDE.md` itself). On `PUT`, `name` is validated against `^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$` (for `auto-memory` it must instead be a flat `*.md` filename). Settings are returned with secret-like keys (matching `/token|secret|password|api[_-]?key|auth/i`) replaced by `"<redacted>"`.
`GET /api/cc-config/plugins` returns each installed plugin's `contributes` block: the `skills` / `agents` / `commands` / `outputStyles` / `hooks` counts, plus `contributes.items` listing the first four surfaces **by name** (`[{ name, description, file }]`) so the UI can show *which* artifacts a plugin ships, not only how many. `name` is the file's frontmatter `name` when it declares one — the string Claude Code actually addresses it by — otherwise the filename. Each surface covers both the conventional directory (`skills/`, `agents/`, `commands/`, `output-styles/`) **and** any path `plugin.json` declares under that key: a plugin that lists its commands explicitly and keeps nothing in `commands/` (this repo's own `agenthub` does) previously reported zero. A declared path that resolves outside the plugin's install directory is ignored — a third-party manifest must not make the dashboard read arbitrary files. The counts are always the matching list's length. `hooks` has no `items` list: a plugin's hooks are matcher/command pairs declared inline in `plugin.json`, not named artifacts. `GET /api/cc-config/plugins` returns each installed plugin's `contributes` block: the `skills` / `agents` / `commands` / `outputStyles` / `hooks` counts, plus `contributes.items` listing the first four surfaces **by name** (`[{ name, description, file }]`) so the UI can show *which* artifacts a plugin ships, not only how many. `name` is the file's frontmatter `name` when it declares one — the string Claude Code actually addresses it by — otherwise the filename. Each surface covers both the conventional directory (`skills/`, `agents/`, `commands/`, `output-styles/`) **and** any path `plugin.json` declares under that key: a plugin that lists its commands explicitly and keeps nothing in `commands/` (this repo's own `agenthub` does) previously reported zero. A declared path that resolves outside the plugin's install directory is ignored — a third-party manifest must not make the dashboard read arbitrary files. The counts are always the matching list's length. `hooks` has no `items` list: a plugin's hooks are matcher/command pairs declared inline in `plugin.json`, not named artifacts.
@@ -2957,7 +2964,10 @@ GET /api/run/interrupted {items} — sessions whose pane died under
POST /api/run/interrupted/dismiss {sessionIds: string[]} (≤ 100) → {dismissed}: stop offering them; resumes nothing. 400 EBADFIELD otherwise POST /api/run/interrupted/dismiss {sessionIds: string[]} (≤ 100) → {dismissed}: stop offering them; resumes nothing. 400 EBADFIELD otherwise
GET /api/run/files?cwd=&q=&dirs=1 Fuzzy file search inside cwd for the @-file autocomplete (dirs=1 adds folders as `dir/`, for the + menu) GET /api/run/files?cwd=&q=&dirs=1 Fuzzy file search inside cwd for the @-file autocomplete (dirs=1 adds folders as `dir/`, for the + menu)
POST /api/run/drop-image Raw image body (png/jpeg/gif/webp, ≤20 MB) → {path}: saved 0600 under <data dir>/drops/ (0700; S3: the member's ~/.agenthub/handoff/drops/) with a random name, pruned after a day; the terminal pastes the path (a `claude` pane: an image as its bare path, each in a paste of its own, which Claude Code shows as `[Image #n]`; any other file as an `@` mention) POST /api/run/drop-image Raw image body (png/jpeg/gif/webp, ≤20 MB) → {path}: saved 0600 under <data dir>/drops/ (0700; S3: the member's ~/.agenthub/handoff/drops/) with a random name, pruned after a day; the terminal pastes the path (a `claude` pane: an image as its bare path, each in a paste of its own, which Claude Code shows as `[Image #n]`; any other file as an `@` mention)
POST /api/run/drop-file?name=&laneId= Any raw file body (≤20 MB; ≤2 GB from a loopback browser outside server mode, streamed to disk — Chrome hands a dropped file no `file://` path) → {path}: same dir (S3: the handoff dir of the account workspace `laneId`'s panes run as, else the requester's), 0600, named <random>-<name reduced to a [A-Za-z0-9._-] basename>; what the terminal (drop or paste, Ctrl+V included), the prompt box and its paste use for every dropped file or image — except a browser on a loopback host dropping or pasting a file manager's files, whose `file://` paths are attached as they are, uploading nothing, once `POST /api/run/local-paths` finds the same files here, and an image PASTED into a `claude` pane by a loopback browser of a local-mode dashboard, which is sent as ^V so `claude` reads the desktop clipboard itself (Settings → Appearance → **Paste images natively**, on by default, per browser — off for an SSH tunnel). Both drop routes answer 413 `ETOOBIG` (with `max_bytes`, the limit the UI shows) to a `Content-Length` over their limit before reading the body, and close the connection; of several files only the refused ones are lost POST /api/run/clipboard-files → {files:[{path,size,dir}]}: the files a file manager's Copy put on THIS machine's clipboard (Windows GetFileDropList, macOS NSPasteboard, Linux text/uri-list via wl-paste/xclip), each that exists here (`size` null for a folder) — local mode + loopback only, else 403 `EREMOTE`; a pasted file whose name and size match is attached by that path, no upload and no size limit
POST /api/run/selected-files → {files:[{path,size,dir}]}: the same for a DROP — the items selected in THIS machine's file manager, which is what a drag from it carries (Windows Explorer windows and the desktop via Shell.Application, macOS Finder's selection — macOS asks once to let AgentHub control Finder; Linux file managers publish none, so a Linux drop uses the browser's own `file://` list or uploads). Nothing is searched for on disk
GET /api/run/native-paste → {native, key}: whether a `claude` pane can read this machine's clipboard itself (see drop-file below), and `key` — `ctrl+v`, or `alt+v` on Windows — the chord Claude Code binds its image paste to there, which the terminal sends
POST /api/run/drop-file?name=&laneId= Any raw file body, streamed to disk as it arrives (S3: through `write-as` as the member) — ≤500 MB, like a Files upload; from a loopback browser outside server mode no cap, only the data dir's free space less a 1 GB reserve (Chrome hands a dropped file no `file://` path) → {path}: same dir (S3: the handoff dir of the account workspace `laneId`'s panes run as, else the requester's), 0600, named <random>-<name reduced to a [A-Za-z0-9._-] basename>; what the terminal (drop or paste, Ctrl+V included), the prompt box and its paste use for every dropped file or image — except a browser on a loopback host dropping or pasting a file manager's files, whose `file://` paths are attached as they are, uploading nothing, once `POST /api/run/local-paths` finds the same files here, and an image PASTED into a `claude` pane by a loopback browser of a local-mode dashboard, which is sent as ^V (Alt+V on Windows, Claude Code's binding there) so `claude` reads the desktop clipboard itself (Settings → Appearance → **Paste images natively**, on by default, per browser — off for an SSH tunnel) and only while `GET /api/run/native-paste` answers `{native:true}`: local mode, a loopback request, and on Linux a reachable display plus `wl-paste` (Wayland) or `xclip` (X11) — without one the `^V` pasted nothing, so the image is uploaded instead. Both drop routes answer 413 `ETOOBIG` (with `max_bytes`, the limit the UI shows) to a `Content-Length` over their limit before reading the body (a loopback drop: when it would not fit on the disk), and close the connection; of several files only the refused ones are lost
POST /api/run/local-paths {paths:[<absolute>…]} (1–100) → {sizes:[bytes|null…]}: each path's size when it is a regular file on this machine, else null. The composer and the terminal use a dropped file's own `file://` path only when every size matches the dropped bytes' — "localhost" in the address bar is no proof (an SSH tunnel's browser is on another machine). 403 EREMOTE in server mode (a pane runs as an account that cannot read the host's files) and to anything but a loopback socket without X-Forwarded-For/Forwarded; 400 EBADFIELD otherwise POST /api/run/local-paths {paths:[<absolute>…]} (1–100) → {sizes:[bytes|null…]}: each path's size when it is a regular file on this machine, else null. The composer and the terminal use a dropped file's own `file://` path only when every size matches the dropped bytes' — "localhost" in the address bar is no proof (an SSH tunnel's browser is on another machine). 403 EREMOTE in server mode (a pane runs as an account that cannot read the host's files) and to anything but a loopback socket without X-Forwarded-For/Forwarded; 400 EBADFIELD otherwise
GET /api/run/drop/:name A dropped png/jpg/gif/webp, or txt/md/csv/tsv/log/json/xml/yaml as text/plain, served back (nosniff, sandbox CSP) from the requester's own drop dir (S3: their member account's, owner-checked — for an admin, then the host's, where their host-folder workspaces drop); only a name drop-image/drop-file made → 404 otherwise GET /api/run/drop/:name A dropped png/jpg/gif/webp, or txt/md/csv/tsv/log/json/xml/yaml as text/plain, served back (nosniff, sandbox CSP) from the requester's own drop dir (S3: their member account's, owner-checked — for an admin, then the host's, where their host-folder workspaces drop); only a name drop-image/drop-file made → 404 otherwise
GET /api/run/:id/permission-mode A live claude pane's mode, read off its mode line → {mode} (null while a menu hides it) GET /api/run/:id/permission-mode A live claude pane's mode, read off its mode line → {mode} (null while a menu hides it)
@@ -3008,7 +3018,7 @@ GET /api/ai-tools/:id/icon The tool's favicon, fetched once by the se
Each tool: `{ id, label, command, homepageUrl, installed, path, enabled, source: "builtin"|"custom", launcher, prompt: { mode }, resume: bool, bypass: bool, proxyCapable: bool }` — flags only, never the arg arrays. Each tool also carries `modelFlag` (the CLI takes a model: Claude, Codex, or a catalog `model: { flag, vendor? }` field) and `defaultModel` (what a new run starts with, `null` = the CLI's own; a `PUT` with `""`/`null` clears it, an id that is not a plain model name is `400 EBADMODEL`). The config files are only those a built-in or release catalog file lists in `config` (`~/`-relative, no `..`, no credential-looking name); a read or save answers `404 ENOFILE` (no such index) / `ENOENT` (missing — never created), `403 ESECRET` (the name, or a symlink target's, looks like a credential), `413 ETOOBIG` (> 1 MB), `415 EBINARY`, `400 EBADSYNTAX` (JSON/JSONC that does not parse), `409 ESTALE` (the file is no longer at `baseSha`); a save backs the old text up under `<data dir>/ai-tool-config-backups/<id>/` and is same-origin only. `GET /:id/models` reads each tool's own source: Codex's `$CODEX_HOME/models_cache.json` (listed entries by priority — nothing else from that file), Claude's aliases named after the newest priced version plus the priced pinned versions, a catalog tool's vendor-priced ids plus the models its own sessions used; `flag: false` and `[]` for a tool with no model flag, `404 ENOTOOL` for an unknown id. `default` is the effective default: the stored one if installed and enabled, else `claude` if installed and enabled, else the first installed enabled tool, else `null`. `PUT` answers `404 ENOTOOL` for an unknown id, `400 EBADDEFAULT` for a default that is not installed or disabled, `400 EBADFIELD` for a non-boolean `enabled`; disabling the default clears it. Writes are same-origin only. State lives in `<data dir>/ai-tools.json`. Each tool also carries `install` (`{kind:"npm", package}` or `{kind:"script", url}`, else null) and `update` (`{kind:"npm"}`, `{kind:"script"}`, `{kind:"self", args}`, else null), and the body carries `catalog: { source: "builtin"|"remote", tag, fetchedAt }` — `remote` while a copy of the newest release's catalog, newer than this build, is in use (synced daily, off with `AGENTHUB_AI_TOOLS_SYNC=0`). Each tool: `{ id, label, command, homepageUrl, installed, path, enabled, source: "builtin"|"custom", launcher, prompt: { mode }, resume: bool, bypass: bool, proxyCapable: bool }` — flags only, never the arg arrays. Each tool also carries `modelFlag` (the CLI takes a model: Claude, Codex, or a catalog `model: { flag, vendor? }` field) and `defaultModel` (what a new run starts with, `null` = the CLI's own; a `PUT` with `""`/`null` clears it, an id that is not a plain model name is `400 EBADMODEL`). The config files are only those a built-in or release catalog file lists in `config` (`~/`-relative, no `..`, no credential-looking name); a read or save answers `404 ENOFILE` (no such index) / `ENOENT` (missing — never created), `403 ESECRET` (the name, or a symlink target's, looks like a credential), `413 ETOOBIG` (> 1 MB), `415 EBINARY`, `400 EBADSYNTAX` (JSON/JSONC that does not parse), `409 ESTALE` (the file is no longer at `baseSha`); a save backs the old text up under `<data dir>/ai-tool-config-backups/<id>/` and is same-origin only. `GET /:id/models` reads each tool's own source: Codex's `$CODEX_HOME/models_cache.json` (listed entries by priority — nothing else from that file), Claude's aliases named after the newest priced version plus the priced pinned versions, a catalog tool's vendor-priced ids plus the models its own sessions used; `flag: false` and `[]` for a tool with no model flag, `404 ENOTOOL` for an unknown id. `default` is the effective default: the stored one if installed and enabled, else `claude` if installed and enabled, else the first installed enabled tool, else `null`. `PUT` answers `404 ENOTOOL` for an unknown id, `400 EBADDEFAULT` for a default that is not installed or disabled, `400 EBADFIELD` for a non-boolean `enabled`; disabling the default clears it. Writes are same-origin only. State lives in `<data dir>/ai-tools.json`. Each tool also carries `install` (`{kind:"npm", package}` or `{kind:"script", url}`, else null) and `update` (`{kind:"npm"}`, `{kind:"script"}`, `{kind:"self", args}`, else null), and the body carries `catalog: { source: "builtin"|"remote", tag, fetchedAt }` — `remote` while a copy of the newest release's catalog, newer than this build, is in use (synced daily, off with `AGENTHUB_AI_TOOLS_SYNC=0`).
**Install / update jobs.** Commands come only from built-in or release catalog files, never from the request or `DASHBOARD_AI_TOOLS_DIR`. npm is the login shell's (`npm install -g <package>@latest`, plus `--prefix ~/.local` when npm's global prefix is not writable by the server's account — a system-wide Node; a tool under `~/.local/lib/node_modules` is updated the same way); a script is downloaded by the server (https, same site as the tool's homepage — or, after a redirect, exactly the catalog entry's `install.redirect` URL, e.g. omp's `omp.sh/install` → its GitHub raw script —, ≤ 1 MB) and run as `bash <file>`; `self` runs `<detected path> ...args`. One job server-wide; the job is `{ id, tool, action, status: "running"|"ok"|"failed", startedAt, endedAt, exitCode, log }` (last 256 KB, 10-minute timeout) and lives in memory. A successful install enables the tool. Errors: `404 ENOTOOL`; `400 ENOINSTALL`/`ENOUPDATE`/`ENOTHING`; `409 EINSTALLED`/`ENOTINSTALLED`/`ENOTNPM` (npm update of a tool whose binary is under neither `npm prefix -g` nor `~/.local/lib/node_modules`)/`ENONPM`/`EBUSY`. Icons are the root `/favicon.ico`, raster only by magic bytes (never SVG), ≤ 256 KB, cached in `<data dir>/runtime/ai-tool-icons/`, served with `nosniff` and `Content-Security-Policy: sandbox`; an `<img>` passes the dashboard token as `?token=`. The client shows its own shipped mark instead where it has one (`client/public/ai-tools/<id>.svg` — claude, codex, openclaw, and hermes as a `-black`/`-white` pair swapped by the theme; `BUNDLED` in `client/src/components/AiToolIcon.tsx`), so this route is asked only for the other tools. **Install / update jobs.** Commands come only from built-in or release catalog files, never from the request or `DASHBOARD_AI_TOOLS_DIR`. npm is the login shell's (`npm install -g <package>@latest`, plus `--prefix ~/.local` when npm's global prefix is not writable by the server's account — a system-wide Node; a tool under `~/.local/lib/node_modules` is updated the same way); a script is downloaded by the server (https, same site as the tool's homepage — or, after a redirect, exactly the catalog entry's `install.redirect` URL, e.g. omp's `omp.sh/install` → its GitHub raw script —, ≤ 1 MB) and run as `bash <file>`; `self` runs `<detected path> ...args` — except for an npm-installed tool under `~/.local/lib/node_modules`, which gets the `--prefix ~/.local` npm install instead (`codex update` reinstalls into npm's global prefix and fails `EACCES` there). One job server-wide; the job is `{ id, tool, action, status: "running"|"ok"|"failed", startedAt, endedAt, exitCode, log }` (last 256 KB, 10-minute timeout) and lives in memory. A successful install enables the tool. Errors: `404 ENOTOOL`; `400 ENOINSTALL`/`ENOUPDATE`/`ENOTHING`; `409 EINSTALLED`/`ENOTINSTALLED`/`ENOTNPM` (npm update of a tool whose binary is under neither `npm prefix -g` nor `~/.local/lib/node_modules`)/`ENONPM`/`EBUSY`. Icons are the root `/favicon.ico`, raster only by magic bytes (never SVG), ≤ 256 KB, cached in `<data dir>/runtime/ai-tool-icons/`, served with `nosniff` and `Content-Security-Policy: sandbox`; an `<img>` passes the dashboard token as `?token=`. The client shows its own shipped mark instead where it has one (`client/public/ai-tools/<id>.svg` — claude, codex, openclaw, and hermes as a `-black`/`-white` pair swapped by the theme; `BUNDLED` in `client/src/components/AiToolIcon.tsx`), so this route is asked only for the other tools.
### AI tool login profiles ### AI tool login profiles
@@ -3027,6 +3037,8 @@ The login profiles of the AI tools other than Claude Code (`server/lib/tool-prof
``` ```
GET /api/ai-proxies Every saved profile, the built-in `native` one first GET /api/ai-proxies Every saved profile, the built-in `native` one first
GET /api/ai-proxies/9router `{ installed }` — is a `9router` binary on the server's PATH (nothing is run) GET /api/ai-proxies/9router `{ installed }` — is a `9router` binary on the server's PATH (nothing is run)
GET /api/ai-proxies/9router/update versions, running state, `blocker`, current job (`?fresh=1` re-asks npm)
POST /api/ai-proxies/9router/update shut down → npm update → start hidden; 202 + job (admin)
POST /api/ai-proxies Create — Body: { label, baseUrl, apiKey?, model, models?, behavesAs?, scope? } POST /api/ai-proxies Create — Body: { label, baseUrl, apiKey?, model, models?, behavesAs?, scope? }
POST /api/ai-proxies/test Probe an endpoint — Body: { baseUrl, model, apiKey?, name? } POST /api/ai-proxies/test Probe an endpoint — Body: { baseUrl, model, apiKey?, name? }
PATCH /api/ai-proxies/:name Update the same fields, all optional PATCH /api/ai-proxies/:name Update the same fields, all optional
@@ -3049,6 +3061,11 @@ A **proxy profile** is an endpoint that stands in front of Claude Code or Codex
**`GET /api/ai-proxies/9router`** answers `{ installed: boolean }` from a PATH lookup only (the server's and the login shell's PATH); the AI Proxy page shows a **Config 9router** link beside **Add profile**, opening 9router's own UI in a new tab on the dashboard's own host, port 20128 (`http://<the host the page was opened on>:20128` — e.g. `http://10.1.13.7:20128` from a LAN browser, so the viewer needs no 9router of their own; 9router must listen beyond loopback for that), only when it is `true`. **`GET /api/ai-proxies/9router`** answers `{ installed: boolean }` from a PATH lookup only (the server's and the login shell's PATH); the AI Proxy page shows a **Config 9router** link beside **Add profile**, opening 9router's own UI in a new tab on the dashboard's own host, port 20128 (`http://<the host the page was opened on>:20128` — e.g. `http://10.1.13.7:20128` from a LAN browser, so the viewer needs no 9router of their own; 9router must listen beyond loopback for that), only when it is `true`.
**Update 9router** (`server/lib/nine-router-update.js`; the AI Proxy page's button beside Config 9router, an admin's only — `/ai-proxies/9router` and `/ai-routers/9router` are in `ADMIN_WRITE_PREFIXES`). `GET /api/ai-proxies/9router/update` answers `{ installed, version, latest, updateAvailable, running, managedBy: "systemd"|"process"|null, blocker: {code, message, command?}|null, job }`. `POST` starts one job (202; `EBUSY` while one runs, else 409 with the blocker's code — checked BEFORE anything is stopped) of three steps, each `pending|running|done|failed|skipped`:
1. **shutdown** — a running `9router.service` user unit is `systemctl --user stop`ped; otherwise this account's processes whose argv names the install's `cli.js` get SIGTERM, then they and their descendants SIGKILL after 5 s; the step waits until the port is free. Skipped when nothing ran.
2. **update** — `npm install -g --prefix <the install's own prefix> --prefer-online 9router@latest`; a prefix this account cannot write runs it under `sudo -n` with `--cache /var/cache/agenthub-9router-npm` (root's HOME is inaccessible inside the AgentHub unit; the sudoers rule must name that exact argv), only when `sudo -n <npm> --version` succeeds (else `ENOPERM` with `command: "sudo npm install -g 9router@latest"`).
3. **relaunch** — only when 9router was running before: `systemctl --user start 9router.service`, or `node cli.js --tray --no-browser --skip-update -p <old port> [-H <old host>]` detached in its own `systemd-run --user --scope` (plain detached spawn without one); done once the port answers. Runs even after a failed update.
**`POST /api/ai-proxies/test`** answers the question saving cannot: does this endpoint, with this key, actually serve this model — for each CLI? It sends, in parallel and with a 15 s timeout each, one Anthropic-style message capped at a single output token to `<baseUrl>/v1/messages` (what Claude Code calls) and one OpenAI Responses call (`max_output_tokens: 16`) to `<baseUrl>/v1/responses` (what Codex calls). A proxy that answers is a `200` here whatever it replies: `{ claude: { ok, status, message }, codex: { ok, status, message } }`, each carrying the proxy's own words (truncated to 300 characters), `status` `null` when nothing answered at all. A proxy that serves only one wire shape shows one green line and one red. Only bad input is a 4xx. **`POST /api/ai-proxies/test`** answers the question saving cannot: does this endpoint, with this key, actually serve this model — for each CLI? It sends, in parallel and with a 15 s timeout each, one Anthropic-style message capped at a single output token to `<baseUrl>/v1/messages` (what Claude Code calls) and one OpenAI Responses call (`max_output_tokens: 16`) to `<baseUrl>/v1/responses` (what Codex calls). A proxy that answers is a `200` here whatever it replies: `{ claude: { ok, status, message }, codex: { ok, status, message } }`, each carrying the proxy's own words (truncated to 300 characters), `status` `null` when nothing answered at all. A proxy that serves only one wire shape shows one green line and one red. Only bad input is a 4xx.
The stored key is only ever sent to the endpoint it was stored against. A blank `apiKey` falls back to the key of the profile named by `name`, but **only** while the submitted `baseUrl` still equals the saved one (both compared without `/v1`) — so testing a retyped endpoint needs a retyped key (`400 EBADKEY`). Without that rule this route would be a way to make the server POST a saved API key to any URL a browser names. The stored key is only ever sent to the endpoint it was stored against. A blank `apiKey` falls back to the key of the profile named by `name`, but **only** while the submitted `baseUrl` still equals the saved one (both compared without `/v1`) — so testing a retyped endpoint needs a retyped key (`400 EBADKEY`). Without that rule this route would be a way to make the server POST a saved API key to any URL a browser names.
@@ -3447,11 +3464,11 @@ Sent when a notification is created.
#### /ws-pty/:runId — PTY frames #### /ws-pty/:runId — PTY frames
A dedicated binary WebSocket stream (not JSON-framed) for tmux-backed PTY transport. Established by the Workspace TerminalView component on workspace load; endpoint is `/ws-pty/:runId` where `runId` comes from `POST /api/run`. Frames are raw PTY output (stdin echoes, command output, prompt updates, terminal control sequences) as binary blobs; the client feeds each frame to xterm.js for live rendering. The same tmux session can have multiple simultaneous clients (browser Workspace, `agenthub workspace shell`, other tools), all receiving the same frames live-synced. **The attach URL carries the client's size** (`/ws-pty/:runId?cols=<n>&rows=<n>`, integers in `[2, 1000]`, else 80×24): tmux resizes the window to whichever client attached most recently, so spawning the attach at a default size and correcting it from the first `resize` message a beat later made the pane's program redraw its whole UI at 80 columns — and everything painted at that width stayed in the scrollback, surfacing as torn left-margin fragments the moment the user scrolled back (issue #33). Resize events after that: the client sends a `{"type":"resize","cols","rows"}` text frame and the server applies it as a `TIOCSWINSZ` ioctl down the pane's pty, so window-sensitive commands (e.g. pagers, text editors) adapt to the viewport size. The client reports its size from **xterm's own `onResize`**, not from the container's `ResizeObserver` — cols also move when the webfont finishes loading or the page zooms, neither of which resizes the observed element — and takes the lead whenever the terminal regains focus. **Several browsers on one pane share ONE size** (`server/lib/pty-attach.js`): tmux's `window-size latest` snapped the pane to whoever typed last, so two viewers on screens of different sizes kept resizing it under each other and one saw it cropped. The server names a DRIVER per pane — the first attach, then whichever sends `{"type":"resize","cols","rows","drive":true}` (the client does on focus and on a keystroke, never on a mouse report). Every other attach is a follower: its PTY is held at the driver's size, a follower's own `resize` without `drive` is remembered but not applied, and the server tells it `{"type":"size","cols","rows"}` — the client resizes xterm to that grid and scales it down with CSS to fit, so the browser in use decides the size and the others just shrink. A follower attaches at the driver's size, so joining never resizes the pane. When the driver's socket closes the rest get `{"type":"lead"}` and size themselves again. On Windows every attach is also sent `{"type":"wheel"}`: psmux never turns mouse tracking on for its client, so the browser sends the SGR wheel report (`ESC[<64;1;1M` up, `ESC[<65;1;1M` down) itself while xterm reports no tracking — psmux scrolls, or forwards it to a pane app that tracks the mouse (Claude Code fullscreen). Driver tracking is per server process: a real terminal (`agenthub workspace shell`) or a second dashboard on the same tmux still follows tmux's own `latest` rule. Keeping the two widths equal is not cosmetic: a tmux pane **wider** than xterm makes every over-long row autowrap its tail onto the next row's left margin, and tmux never repaints a row it believes unchanged — so stray characters stay glued to the left edge of blank lines, visible as soon as a scroll repaints history. A zero size (the console collapses with CSS, leaving the container 0×0) is never reported. The WebSocket connection inherits the same loopback same-origin guard and optional token auth as other `/api/*` routes. The client re-attaches automatically 1.5 s after an unexpected close (server restart, network blip) — tmux keeps the session alive, so the re-attach repaints the pane in full. It stops retrying only after the server's one-shot `{"type":"exit"}` frame, which means the pane process itself ended — the Workspace takes that frame as the signal to restore the run setup form, since there is nothing left to attach to. Without that retry a dropped socket left the pane frozen on its last painted frame: it still looked live, but no keystroke and no mouse report (wheel scroll included) reached tmux again. A dedicated binary WebSocket stream (not JSON-framed) for tmux-backed PTY transport. Established by the Workspace TerminalView component on workspace load; endpoint is `/ws-pty/:runId` where `runId` comes from `POST /api/run`. Frames are raw PTY output (stdin echoes, command output, prompt updates, terminal control sequences) as binary blobs; the client feeds each frame to xterm.js for live rendering. The same tmux session can have multiple simultaneous clients (browser Workspace, `agenthub workspace shell`, other tools), all receiving the same frames live-synced. **The attach URL carries the client's size** (`/ws-pty/:runId?cols=<n>&rows=<n>`, integers in `[2, 1000]`). A terminal with no size to give — one on a hidden tab, whose box is 0×0 — names none and reports none until it is shown, and its attach is spawned at the window's CURRENT size (`tmux.windowSize`, 80×24 only when tmux cannot answer), so a background tab never shrinks the pane (it used to fit to xterm's 12×6 minimum and resize tmux to that): tmux resizes the window to whichever client attached most recently, so spawning the attach at a default size and correcting it from the first `resize` message a beat later made the pane's program redraw its whole UI at 80 columns — and everything painted at that width stayed in the scrollback, surfacing as torn left-margin fragments the moment the user scrolled back (issue #33). Resize events after that: the client sends a `{"type":"resize","cols","rows"}` text frame and the server applies it as a `TIOCSWINSZ` ioctl down the pane's pty, so window-sensitive commands (e.g. pagers, text editors) adapt to the viewport size. The client reports its size from **xterm's own `onResize`**, not from the container's `ResizeObserver` — cols also move when the webfont finishes loading or the page zooms, neither of which resizes the observed element — and takes the lead whenever the terminal regains focus. **Several browsers on one pane share ONE size** (`server/lib/pty-attach.js`): tmux's `window-size latest` snapped the pane to whoever typed last, so two viewers on screens of different sizes kept resizing it under each other and one saw it cropped. The server names a DRIVER per pane — the first attach, then whichever sends `{"type":"resize","cols","rows","drive":true}` (the client does on focus and on a keystroke, never on a mouse report). Every other attach is a follower: its PTY is held at the driver's size, a follower's own `resize` without `drive` is remembered but not applied, and the server tells it `{"type":"size","cols","rows"}` — the client resizes xterm to that grid and scales it down with CSS to fit, so the browser in use decides the size and the others just shrink. A follower attaches at the driver's size, so joining never resizes the pane. When the driver's socket closes the rest get `{"type":"lead"}` and size themselves again. On Windows every attach is also sent `{"type":"wheel"}`: psmux never turns mouse tracking on for its client, so the browser sends the SGR wheel report (`ESC[<64;1;1M` up, `ESC[<65;1;1M` down) itself while xterm reports no tracking — psmux scrolls, or forwards it to a pane app that tracks the mouse (Claude Code fullscreen). Driver tracking is per server process: a real terminal (`agenthub workspace shell`) or a second dashboard on the same tmux still follows tmux's own `latest` rule. Keeping the two widths equal is not cosmetic: a tmux pane **wider** than xterm makes every over-long row autowrap its tail onto the next row's left margin, and tmux never repaints a row it believes unchanged — so stray characters stay glued to the left edge of blank lines, visible as soon as a scroll repaints history. A zero size (the console collapses with CSS, leaving the container 0×0) is never reported. The WebSocket connection inherits the same loopback same-origin guard and optional token auth as other `/api/*` routes. The client re-attaches automatically 1.5 s after an unexpected close (server restart, network blip) — tmux keeps the session alive, so the re-attach repaints the pane in full. It stops retrying only after the server's one-shot `{"type":"exit"}` frame, which means the pane process itself ended — the Workspace takes that frame as the signal to restore the run setup form, since there is nothing left to attach to. Without that retry a dropped socket left the pane frozen on its last painted frame: it still looked live, but no keystroke and no mouse report (wheel scroll included) reached tmux again.
#### cc_config_changed #### cc_config_changed
Broadcast whenever Claude Code configuration changes — either by dashboard mutations on `PUT/DELETE /api/cc-config/file` (`source: "dashboard"`) or by `lib/cc-watcher.js` picking up external `fs.watch` events on `~/.claude/` and `~/.claude.json` (`source: "fs"`, debounced at 500 ms). The Config Explorer page subscribes and refetches automatically. Broadcast whenever Claude Code configuration changes — either by dashboard mutations on `PUT/DELETE /api/cc-config/file` (`source: "dashboard"`) or by `lib/cc-watcher.js` picking up external `fs.watch` events on `~/.claude/` and `~/.claude.json` (`source: "fs"`, debounced at 500 ms). The Config Explorer page subscribes and refetches automatically. In server mode a change to the host's config reaches a developer as the bare type (`data: {}`); a change to a member's OWN config carries `ownerId` and reaches that user alone, admins and analysts included (`scope.exactAudience`).
```json ```json
{ "type": "cc_config_changed", "data": { "source": "dashboard", "action": "write", "scope": "user", "type": "skill", "name": "my-skill" } } { "type": "cc_config_changed", "data": { "source": "dashboard", "action": "write", "scope": "user", "type": "skill", "name": "my-skill" } }
+2 -2
View File
@@ -239,7 +239,7 @@ Manage the remote (SSH) machines this dashboard pulls Claude Code history from
A workspace is a durable unit of parallel agent work — one working directory, many sessions over time. Full guide: [`docs/LANES.md`](LANES.md). A workspace is a durable unit of parallel agent work — one working directory, many sessions over time. Full guide: [`docs/LANES.md`](LANES.md).
`agenthub workspace …` (or `agenthub workspaces` for the list) is the name to use. `agenthub lanes …` and `--lane <id>` are the old names and keep working — the same commands as `agenthub workspace …` and `--workspace <id>` — so scripts and skills written before the rename need no change. `agenthub workspace …` (or `agenthub workspaces` for the list) is the name to use. The old names `agenthub lanes …` and `--lane <id>` were removed (step 4 of the rename): they exit 1 with `renamed: use agenthub workspace …` / `renamed: use --workspace <id>`. Your workspaces, sessions and history are unchanged, and the API still answers under both `/api/workspaces` and `/api/lanes`.
| Command | Description | | Command | Description |
| ------- | ----------- | | ------- | ----------- |
@@ -271,7 +271,7 @@ A workspace is a durable unit of parallel agent work — one working directory,
Omit `<id>` and the command addresses the workspace owning the current directory, so a session running inside a workspace never needs to know its own id. Only a leading all-digits argument is read as an id — `agenthub workspace logs web --tail 4096` addresses the workspace by directory, not workspace 4096. Omit `<id>` and the command addresses the workspace owning the current directory, so a session running inside a workspace never needs to know its own id. Only a leading all-digits argument is read as an id — `agenthub workspace logs web --tail 4096` addresses the workspace by directory, not workspace 4096.
Resolution order, shared by every cwd-resolving command: `--workspace` (old name `--lane`)/leading id → `AGENTHUB_LANE_ID` (exported into every tmux pane the dashboard starts) → `--tool <id>`, else the harness's marker (`CLAUDECODE` means claude; Codex has no equivalent marker, so a codex session outside a dashboard pane passes `--tool codex`) → the workspace owning the directory. One directory can back a `claude` workspace and a `codex` workspace at once, so when two candidates remain the command fails and names both instead of picking one — see [Which workspace a CLI command addresses](LANES.md#which-workspace-a-cli-command-addresses). Resolution order, shared by every cwd-resolving command: `--workspace`/leading id → `AGENTHUB_LANE_ID` (exported into every tmux pane the dashboard starts) → `--tool <id>`, else the harness's marker (`CLAUDECODE` means claude; Codex has no equivalent marker, so a codex session outside a dashboard pane passes `--tool codex`) → the workspace owning the directory. One directory can back a `claude` workspace and a `codex` workspace at once, so when two candidates remain the command fails and names both instead of picking one — see [Which workspace a CLI command addresses](LANES.md#which-workspace-a-cli-command-addresses).
Because a workspace's services are fully detached, restarting the dashboard (or applying an update — `agenthub update-check` reports one, the dashboard's Update button applies it) never stops a running stack. Because a workspace's services are fully detached, restarting the dashboard (or applying an update — `agenthub update-check` reports one, the dashboard's Update button applies it) never stops a running stack.
+2 -2
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -374,7 +374,7 @@ both full suites.
| Path | What | | Path | What |
|---|---| |---|---|
| `server/` | Express API, SQLite schema, hook ingest, workspace and worktree libraries | | `server/` | Express API, SQLite schema, hook ingest, workspace and worktree libraries |
| `client/` | React 18 + Vite + Tailwind dashboard | | `client/` | React 19 + Vite + Tailwind dashboard |
| `bin/agenthub.js` | CLI | | `bin/agenthub.js` | CLI |
| `mcp/`, `statusline/` | Read-only MCP server and Claude Code statusline (opt-in) | | `mcp/`, `statusline/` | Read-only MCP server and Claude Code statusline (opt-in) |
| `monitoring/` | Prometheus config/rules and Grafana boards for `/api/metrics` | | `monitoring/` | Prometheus config/rules and Grafana boards for `/api/metrics` |
+1 -1
View File
@@ -293,7 +293,7 @@ chạy cả hai bộ đầy đủ.
| Đường dẫn | Là gì | | Đường dẫn | Là gì |
|---|---| |---|---|
| `server/` | Express API, schema SQLite, hook ingest, thư viện workspace và worktree | | `server/` | Express API, schema SQLite, hook ingest, thư viện workspace và worktree |
| `client/` | Dashboard React 18 + Vite + Tailwind | | `client/` | Dashboard React 19 + Vite + Tailwind |
| `bin/agenthub.js` | CLI | | `bin/agenthub.js` | CLI |
| `docs/` | Architecture, API, workspaces, database | | `docs/` | Architecture, API, workspaces, database |
| `plugins/` | Các Claude Code plugin đi kèm dashboard | | `plugins/` | Các Claude Code plugin đi kèm dashboard |
+2 -2
View File
@@ -194,8 +194,8 @@ graph TB
| Layer | Technology | | Layer | Technology |
|-------|------------| |-------|------------|
| **Frontend** | React 18, TypeScript 5.7, Vite 6, Tailwind CSS | | **Frontend** | React 19, TypeScript 7, Vite 6, Tailwind CSS |
| **Backend** | Node.js 20+, Express 4.21, WebSocket | | **Backend** | Node.js 22.5+, Express 5, WebSocket |
| **Database** | SQLite 3 (better-sqlite3 or node:sqlite) | | **Database** | SQLite 3 (better-sqlite3 or node:sqlite) |
| **Integration** | Claude Code Hooks, MCP Server | | **Integration** | Claude Code Hooks, MCP Server |
+92 -15
View File
@@ -65,7 +65,7 @@ The proxy has to:
- forward WebSocket upgrades (`/ws` for live updates, `/ws-pty/*` for terminals); - forward WebSocket upgrades (`/ws` for live updates, `/ws-pty/*` for terminals);
- set `X-Forwarded-For` and `X-Forwarded-Proto`; - set `X-Forwarded-For` and `X-Forwarded-Proto`;
- accept request bodies as large as AgentHub's own limits (Files uploads up to - accept request bodies as large as AgentHub's own limits (Files uploads up to
500 MB, prompt drops 20 MB). nginx's default `client_max_body_size` is 1 MB, 500 MB, files dropped on a terminal or a prompt box 500 MB, dropped images 20 MB). nginx's default `client_max_body_size` is 1 MB,
and its own 413 is an HTML page the UI can only report as a failed upload. and its own 413 is an HTML page the UI can only report as a failed upload.
nginx: nginx:
@@ -106,11 +106,13 @@ The login cookie gets `Secure` when the request came over HTTPS
start (`server/db.js`), and `member` is no longer accepted. start (`server/db.js`), and `member` is no longer accepted.
"The host's configuration" means a write (anything but GET/HEAD) under "The host's configuration" means a write (anything but GET/HEAD) under
`/api/settings`, `/updates`, `/cc-config`, `/codex-config`, `/api/settings`, `/updates`, `/codex-config`,
`/git-credentials`, `/ai-tools`, `/import`, `/pricing`, `/budgets`, `/git-credentials`, `/ai-tools`, `/import`, `/pricing`, `/budgets`,
`/webhooks`, `/remote-sources`, `/reports`, `/pets`, `/users` and `/webhooks`, `/remote-sources`, `/reports`, `/pets`, `/users` and
`/alerts/rules` (`ADMIN_WRITE_PREFIXES` in `server/lib/auth.js`), plus a write `/alerts/rules` (`ADMIN_WRITE_PREFIXES` in `server/lib/auth.js`), plus a write
to a shared AI Proxy (see [AI Proxy profiles](#ai-proxy-profiles)). A to a shared AI Proxy (see [AI Proxy profiles](#ai-proxy-profiles)) and the
host-wide parts of `/cc-config` (see
[Your own Claude Code config](#your-own-claude-code-config)). A
developer or analyst who tries one gets `403 EADMIN`, shown in the UI as "Only developer or analyst who tries one gets `403 EADMIN`, shown in the UI as "Only
an admin can do this". An analyst's other writes are scoped like a an admin can do this". An analyst's other writes are scoped like a
developer's: one naming another user's row gets `404 ENOTFOUND` (`memberId` in developer's: one naming another user's row gets `404 ENOTFOUND` (`memberId` in
@@ -120,7 +122,8 @@ exactly like a developer's.
The UI hides what a role cannot use (`usePermissions` in The UI hides what a role cannot use (`usePermissions` in
`client/src/components/AuthGate.tsx`): for anyone but an admin, Settings shows `client/src/components/AuthGate.tsx`): for anyone but an admin, Settings shows
no Data, Pricing or per-tool pages, no scheduled reports or git credentials, no Data, Pricing or per-tool pages (except their own Claude Code config,
for a user with their own Linux account), no scheduled reports or git credentials,
no start-at-login/icon/Quit/Uninstall on System, no alert rules or channels, no start-at-login/icon/Quit/Uninstall on System, no alert rules or channels,
no install/update/enable on AI tools, no pet upload or delete, and no update no install/update/enable on AI tools, no pet upload or delete, and no update
check; the AI proxy page shows them only their personal profiles' Edit. An check; the AI proxy page shows them only their personal profiles' Edit. An
@@ -163,20 +166,48 @@ Workspaces made before server mode, or by the host account, have none.
sessions, and the tokens and $ it used today (the browser's local day) and in the last 7 and 30 days sessions, and the tokens and $ it used today (the browser's local day) and in the last 7 and 30 days
(booked like the cost routes' daily split, so Codex/omp/Antigravity and sub-agent usage count) — the (booked like the cost routes' daily split, so Codex/omp/Antigravity and sub-agent usage count) — the
**Administration** page in the sidebar (`/admin`; the old `/users` link **Administration** page in the sidebar (`/admin`; the old `/users` link
redirects there) shows it on its **Users** tab, with the audit log under it. redirects there) shows it — one page, no tabs: the users table, the account
- `GET /api/host` (admin/analyst, `/host` is staff-only) is Administration's pool, then the audit log. The machine itself is on the Dashboard's
**Server** tab: the machine's hostname, OS, uptime, CPU model, cores and **System Health** tab (below).
The table's spend column is today's (the browser's local day), with 7 and 30
days under it. Clicking a user expands a short detail right under their row
(`/admin?user=<id>`, `GET /api/users/:id/details`): last sign-in, last
activity and what runs now, then spend over 30 days by AI tool and top
models, the account pool per tool (pin or group, each account's quota left
beside their own 7-day share of its spend), their live panes and
workspaces, and their five newest sessions.
- `GET /api/host` (every signed-in user — `/host` is deliberately NOT
staff-only, so a developer also sees other users' usernames, CPU / RAM share
and busiest process names) is the machine on the Dashboard's **System
Health** tab — its **Machine** group, between **AgentHub** (score, storage,
concurrency) and **Agent activity** (tokens by model, tools, subagents): the machine's hostname, OS, uptime, CPU model, cores and
load, memory and swap, every real disk (HDD or SSD, how full; the one load, memory and swap, every real disk (HDD or SSD, how full; the one
holding the users' homes is marked), any NVIDIA GPU, and each user's live holding the users' homes is marked), any NVIDIA GPU. Each user's live
share of it — CPU % of the whole machine (100 % = every core busy) and share of it is a **Users · CPU / RAM now** card on the same tab (a tile per
user, their busiest processes on click; their home share stays in
Administration's users table), and the processes of no user are listed apart — CPU % of the whole machine (100 % = every core busy) and
resident memory over a 1 s `/proc` sample, a process count, the busiest resident memory over a 1 s `/proc` sample, a process count, the busiest
processes by their kernel name (never argv or env), and their home's size processes by their kernel name (never argv or env), and their home's size
as a share of the homes disk. A process counts for a user when it runs as as a share of the homes disk. A process counts for a user when it runs as
their Linux account (S3) or, for the host account, when it inherited a their Linux account (S3) or, for the host account, when it inherited a
workspace's `AGENTHUB_LANE_ID` — so without S3 a user's share is only what workspace's `AGENTHUB_LANE_ID` — so without S3 a user's share is only what
runs in their workspaces' panes; the rest is **Host account** or runs in their workspaces' panes; the rest is **Host account** or
**System**. Read-only (`server/lib/host-stats.js`); Linux only for the **System**. The same 1 s sample also gives every CPU thread's busy %,
per-user part. The page re-reads every 5 s while shown. network download/upload per interface (no `lo`), read/write per whole
disk, and the ten busiest processes machine-wide with who they count for;
plus `/sys/class/hwmon` temperatures, physical cores, boot time and the
AgentHub process's own pid, Node version, uptime and RSS — laid out in the
tab's own card style (`client/src/components/health/HostHealth.tsx`): the
machine card (with the V8 heap from `/api/settings/info`), then CPU threads,
temperatures / GPUs, disks with their I/O and network, then history and the
busiest processes. Read-only (`server/lib/host-stats.js`); Linux only for
the per-user part. The tab re-reads it every 5 s while shown.
- `GET /api/host/history?range=1h|6h|24h` (every signed-in user) is the tab's
**History** charts: machine CPU % and memory % (plus swap, load and each
user's CPU/memory in the payload), one point every 30 s for 24 h. Every
server on Linux (local or server mode) keeps it IN MEMORY from the moment it starts
(`startHistory`, server/index.js) — a restart starts it empty, and nothing
is written to disk.
- `GET /api/users/storage` (admin/analyst) is the page's **Storage** column: - `GET /api/users/storage` (admin/analyst) is the page's **Storage** column:
how much disk each user's home takes (their Linux home under S3, their how much disk each user's home takes (their Linux home under S3, their
folder under `AGENTHUB_USERS_ROOT` otherwise; an admin with none reads folder under `AGENTHUB_USERS_ROOT` otherwise; an admin with none reads
@@ -774,6 +805,52 @@ folder under it), and the dark/light mode picked in AgentHub becomes Claude's
theme. Accepting Bypass Permissions mode stays the member's own choice, made theme. Accepting Bypass Permissions mode stays the member's own choice, made
in their terminal. in their terminal.
### Your own Claude Code config
With S3, a developer or analyst who has their own Linux account manages THEIR
OWN Claude Code configuration in Settings → AI tools → **Your Claude Code
config** — skills, subagents, commands, output styles, `CLAUDE.md` and
auto-memory, plugins, marketplaces, MCP servers, `settings.json` (hooks
included) and `keybindings.json` — the same Config Explorer an admin uses for
the host's, pointed at `<USERS_ROOT>/<name>/.claude` and
`<USERS_ROOT>/<name>/.claude.json`. How it runs:
- `/api/cc-config` gates itself (`server/lib/cc-target.js`): a non-admin
reaches only an allowlist of reads and of the writes that run as them.
`/profiles*` (the host's login switching), `/plugins/update-all` (the host's
Update all, AgentHub's own plugin included) and any endpoint not on the list
stay an admin's (`403 EADMIN`; an analyst may still READ the profiles and the
Update-all journal, as before). Every handler then resolves ONE target: the
host's Claude home for an admin (and in local mode) exactly as before, the
member's own home otherwise, and `403 ENOACCOUNT` for a non-admin with no
enabled, provisioned account — never the host's. An analyst with no account
keeps the read-only view of the host's config they had; with one, `?view=host`
gives them that view, and the file reader serves either.
- Every file write is the member's own: `write-as` (content on stdin), the
timestamped backup a `write-as copy` inside their home; their files are read
only through the guarded reader (a regular file they own, size capped), and
a directory, plugin `installPath` or marketplace `installLocation` that leads
out of their home is not followed.
- `claude plugin …`, `claude plugin marketplace …` and `claude mcp …` run as
them: `sudo -n -u ah-<name> HOME=<home> DISABLE_AUTOUPDATER=1
/opt/agenthub/tools/bin/claude …` in their home, with a timeout and the same
argument checks. User scope only, never `-y` (a plugin whose marketplace
declares an install command fails; install it from your own terminal), and
no MCP `env` values or `headers` (sudo logs its argv: add such a server with
`claude mcp add` in your own terminal). `503 ENOTSHARED` until `claude` is
in the shared tools.
- A project layer only in one of their own workspaces, named with `?cwd=`
(`403 EFOLDER` otherwise); with none named there is no project layer at all,
never the dashboard's own folder. An admin's "new CLAUDE.md in a project"
in a member's folder is written as that member too.
- A change is broadcast (`cc_config_changed`) to that member alone, and their
**Reload** (`POST /api/cc-config/reload`, or a plugin update with reload)
types `/reload-plugins` / `/reload-skills` only into the panes that run as
them. Their own config is theirs, so these writes are not audited (like
their own File Explorer writes).
Not verified on a live S3 machine yet.
### Plugin baseline (opt-in) ### Plugin baseline (opt-in)
Write `/etc/agenthub/plugin-baseline.json` (as Write `/etc/agenthub/plugin-baseline.json` (as
@@ -867,12 +944,12 @@ a pane really is. Each case below is refused or bounded, with a test in
an older server. Until `agenthub server install-system` has installed an older server. Until `agenthub server install-system` has installed
it, calls use `TMUX_BIN` directly, as before. A member's server keeps its it, calls use `TMUX_BIN` directly, as before. A member's server keeps its
version until their last pane closes; the next start runs `TMUX_BIN`. version until their last pane closes; the next start runs `TMUX_BIN`.
- **Administration → Server → Members' tmux** (admin/analyst read, admin acts): each member's - **Administration → users table, tmux column** (admin/analyst read, admin acts): each member's
running server with its version beside the installed one (an *older than running server with its version beside the installed one (an *older than
installed* badge), its sessions and whether an agent of theirs is working. installed* badge), its sessions and whether an agent of theirs is working.
**Restart** ends one member's server — every pane of theirs closes, their The row's **Restart** button ends one member's server — every pane of theirs closes, their
Claude sessions resume from the workspace — and **Restart N idle members** Claude sessions resume from the workspace — and **Restart N idle members**
does it for every outdated member with no agent working; both take the (above the table) does it for every outdated member with no agent working; both take the
admin's password again and are audited per member. A server that will not admin's password again and are audited per member. A server that will not
stop answers `409 ETMUXSTUCK` naming its pid: root has to `kill -9` it. stop answers `409 ETMUXSTUCK` naming its pid: root has to `kill -9` it.
- **A member's huge or sparse files.** Transcripts and journals are read line - **A member's huge or sparse files.** Transcripts and journals are read line
@@ -950,7 +1027,7 @@ agenthub server install-system
A member server that a newer tmux already reached before `tmux-member` was A member server that a newer tmux already reached before `tmux-member` was
installed can stay wedged: every call on that socket — `tmux-member`'s too — installed can stay wedged: every call on that socket — `tmux-member`'s too —
prints `server exited unexpectedly`. If Administration → Server → Members' tmux shows it with prints `server exited unexpectedly`. If the users table's tmux column on Administration shows it with
no panes, stop that one server (its pid is the `ah-<name>` process running no panes, stop that one server (its pid is the `ah-<name>` process running
`tmux -S <USERS_ROOT>/<name>/.agenthub/tmux/sock`) with `sudo kill -9 <pid>`; `tmux -S <USERS_ROOT>/<name>/.agenthub/tmux/sock`) with `sudo kill -9 <pid>`;
the next start runs a fresh server on `TMUX_BIN`. Never `tmux kill-server`. the next start runs a fresh server on `TMUX_BIN`. Never `tmux kill-server`.
+1 -1
View File
@@ -13,7 +13,7 @@ Opus 5.5 · main · ███░░░░░░░ 34% · $1.23 · today $45.68
| branch | `git symbolic-ref --short HEAD` in the session's cwd (short sha when detached; 500 ms cap) | | branch | `git symbolic-ref --short HEAD` in the session's cwd (short sha when detached; 500 ms cap) |
| context bar | `context_window.used_percentage`, else `current_usage` (input + cache) ÷ `context_window_size`; green < 50 %, yellow < 80 %, red above | | context bar | `context_window.used_percentage`, else `current_usage` (input + cache) ÷ `context_window_size`; green < 50 %, yellow < 80 %, red above |
| `$1.23` | this session, Claude Code's own `cost.total_cost_usd` | | `$1.23` | this session, Claude Code's own `cost.total_cost_usd` |
| `today $…` | every session today, from the dashboard: `GET /api/pricing/cost?tz_offset=<local>` → today's `daily_costs` row | | `today $…` | your sessions today, from the dashboard: `GET /api/pricing/cost?tz_offset=<local>&mine=1` → today's `daily_costs` row (in server mode only the pane's workspace owner's sessions, even for an admin; locally every session) |
Today's cost has a **1-second timeout** and is cached for 30 s in Today's cost has a **1-second timeout** and is cached for 30 s in
`<os tmpdir>/agenthub-statusline-<uid>.json` — a failed fetch is cached too, so a `<os tmpdir>/agenthub-statusline-<uid>.json` — a failed fetch is cached too, so a
+1 -1
View File
@@ -9,7 +9,7 @@
- [NEVER tmux kill-server](never-tmux-kill-server.md) — $TMUX beats TMUX_TMPDIR; it killed the real server + this session twice - [NEVER tmux kill-server](never-tmux-kill-server.md) — $TMUX beats TMUX_TMPDIR; it killed the real server + this session twice
- [Analytics parity plan](analytics-parity-plan.md) — phases 2–7 shipped in v2.4.0; ../agenthub-analytics now redundant; OpenAPI for new routes still missing - [Analytics parity plan](analytics-parity-plan.md) — phases 2–7 shipped in v2.4.0; ../agenthub-analytics now redundant; OpenAPI for new routes still missing
- [Dev/prod ports share one DB](agenthub-dev-prod-ports.md) — 4820 prod (systemd), 4830 user's dev in Warp; same dashboard.db, dev migrations hit prod - [Dev/prod ports share one DB](agenthub-dev-prod-ports.md) — 4820 prod (systemd), 4830 user's dev in Warp; same dashboard.db, dev migrations hit prod
- [Workspace rename step 4](workspace-rename-step4.md) — remove `agenthub lanes`/`--lane` only ≥3 releases after aliases ship; never rename stored data - [Workspace rename step 4](workspace-rename-step4.md) — DONE 10-26: old CLI names refused, clients call /api/workspaces, /api/lanes kept; release needs EN+VI note
- [No git stash here](no-git-stash-shared-checkout.md) — parallel sessions share this checkout; use a worktree to test clean HEAD - [No git stash here](no-git-stash-shared-checkout.md) — parallel sessions share this checkout; use a worktree to test clean HEAD
- [Update never kills panes](update-never-kills-panes.md) — updates/restarts keep tmux lane panes; only Quit AgentHub (#65) closes them; KillMode=process - [Update never kills panes](update-never-kills-panes.md) — updates/restarts keep tmux lane panes; only Quit AgentHub (#65) closes them; KillMode=process
- [Claude Code changelog ≤2.1.295](claude-code-changelog-2-1-293.md) — 2.1.285–295 (to 10-08): hooks onFailure:block (keep AgentHub fail-open), OSC 7501 tested (no use), Workflow scriptPath rules - [Claude Code changelog ≤2.1.295](claude-code-changelog-2-1-293.md) — 2.1.285–295 (to 10-08): hooks onFailure:block (keep AgentHub fail-open), OSC 7501 tested (no use), Workflow scriptPath rules
+5 -7
View File
@@ -1,17 +1,15 @@
--- ---
name: workspace-rename-step4 name: workspace-rename-step4
description: "Removing the old `agenthub lanes`/`--lane` CLI names is deferred to ≥3 releases after the aliases ship; never touch stored data" description: "Rename step 4 DONE 2026-10-26: `agenthub lanes`/`--lane` now exit 1 'renamed'; client/CLI/MCP call /api/workspaces; /api/lanes kept; never touch stored data"
metadata: metadata:
node_type: memory node_type: memory
type: project type: project
originSessionId: 47dc0201-8e49-4fce-9407-f087d3107588 originSessionId: 47dc0201-8e49-4fce-9407-f087d3107588
modified: 2026-10-06T04:07:56.796Z modified: 2026-10-26T00:00:00.000Z
--- ---
On 2026-10-06 the user had steps 1–3 of the lane→workspace rename built (aliases `/api/workspaces`, `agenthub workspace`, `--workspace`; help/docs on the new name; a stderr deprecation note for `agenthub lanes`/`--lane`). Step 4 (removing the old CLI names, moving client+CLI requests to `/api/workspaces`) is deferred until the THIRD release after the first one that ships the aliases (the first release after v2.0.1). The plan, checklist and "never touch" list are in `docs/plans/workspace-rename/README.md`. Step 4 of the lane→workspace rename was built on 2026-10-26 (after v2.8.25): `agenthub lanes …` and `--lane` exit 1 with `renamed: use …`; client `api.ts`, `bin/agenthub.js` and `mcp/server.js` request `/api/workspaces`; the `/api/lanes` mount is KEPT (4c default). Plan: `docs/plans/workspace-rename/README.md`.
**Why:** the user wants existing installs to update gradually and NOT lose any data — DB tables/columns, response keys, tmux names `agenthub-lane-<id>`, env vars, on-disk paths and browser storage keys must never be renamed. **Why:** existing installs update gradually and must not lose data — DB tables/columns, response keys, tmux names `agenthub-lane-<id>`, env vars, on-disk paths and browser storage keys are never renamed.
**How to apply:** when cutting a release, check whether it is ≥ R+3 (R is recorded in that plan file — fill it in when R is cut) and remind the user that step 4 is due; keep `/api/lanes` unless the user decides otherwise (step 4c, default keep). **How to apply:** the release that ships this needs the EN+VI note from the plan's release checklist ("`agenthub lanes` and `--lane` are removed — use `agenthub workspace` and `--workspace`…"). Do not remove `/api/lanes` unless the user asks (then 410 ERENAMED for one release first).
R = v2.1.0 (released 2026-10-06), so step 4 is allowed from the 3rd release after v2.1.0.
+2 -2
View File
@@ -32,5 +32,5 @@ Decisions (user, 2026-10-08):
- [x] analyst alert feed: `can_ack` per alert, Ack hidden on others' (server test + client test) - [x] analyst alert feed: `can_ack` per alert, Ack hidden on others' (server test + client test)
- [x] Settings search: admin-only entries (autostart, quit, tray, reports, webhooks, git creds) hidden - [x] Settings search: admin-only entries (autostart, quit, tray, reports, webhooks, git creds) hidden
- [x] SessionDetail: an unstamped session's owner resolved by folder (`owningLane`) - [x] SessionDetail: an unstamped session's owner resolved by folder (`owningLane`)
- [ ] prod/dev share one DB: restart prod on the new build together with the first run (migration) - [x] prod/dev share one DB: restart prod on the new build together with the first run (migration) — done with the v2.5.x rollout
- [ ] `agenthub stop` in server mode answers EAUTH (fix was blocked; user to decide) - [x] `agenthub stop` in server mode answers EAUTH — resolved: `cmdStop` (`bin/agenthub.js`) turns EAUTH/EADMIN into a clear message naming the admin's Quit and the `systemctl` stop commands; a host shell carries no login, so that is the intended answer
+1 -1
View File
@@ -21,7 +21,7 @@ Decided: own Linux user per account; skills/plugins = admin baseline + user's ow
- [x] Per-user AI Proxy store (+ admin shared) — S3-PLAN P1/P1b - [x] Per-user AI Proxy store (+ admin shared) — S3-PLAN P1/P1b
- [x] S3 provisioning + spawn/attach as the account — S3-PLAN P2/P3 - [x] S3 provisioning + spawn/attach as the account — S3-PLAN P2/P3
- [x] S4 shared tools in /opt — S3-PLAN P5 - [x] S4 shared tools in /opt — S3-PLAN P5
- [ ] Per-user skills/plugins (cc-config per account HOME, admin baseline) — seed + opt-in baseline built (P6); managed-settings spike not run, member Settings not built - [ ] Per-user skills/plugins (cc-config per account HOME, admin baseline) — seed + opt-in baseline built (P6); managed-settings spike run 2026-10-09 (works from the first interactive session, S3-PLAN P6); member Settings built 2026-10-26 ("Your Claude Code config", writes as the member) — open until verified live on the S3 box
- [x] Codex account pool — S3-PLAN P7 (fake accounts only; no real Codex login tried) - [x] Codex account pool — S3-PLAN P7 (fake accounts only; no real Codex login tried)
- [x] Security review, docs, suites — P8 F1–F11, second review F12–F20 (2026-10-09), docs/SERVER-MODE.md runbook - [x] Security review, docs, suites — P8 F1–F11, second review F12–F20 (2026-10-09), docs/SERVER-MODE.md runbook
+1 -1
View File
@@ -205,7 +205,7 @@ Branch: `server-s3`. Commits happen only when the user asks.
- Not checked: skills actually listed inside a logged-in session (no login on the test user). - Not checked: skills actually listed inside a logged-in session (no login on the test user).
- [x] Fallback (built, opt-in): with `/etc/agenthub/plugin-baseline.json` (`{"marketplaces": ["owner/repo", …], "plugins": ["name@marketplace", …]}`, root's, the admin edits it), creating a user runs `claude plugin marketplace add <source>` for each marketplace, then `claude plugin install --scope user <plugin>` for each plugin, AS the new member (`sudo -n -u ah-<name> HOME=… /opt/agenthub/tools/bin/claude …`, in the background; `agenthub users provision` runs it too, in the foreground, and is the retry). No file = nothing runs. An entry starting with `-` is skipped; `-y` is never passed. Sudoers: `/opt/agenthub/tools/bin/claude` joins the run-as-member list (no new power: the host already runs any command as a member through tmux). `server/lib/member-claude.js`, test `server-s3-provision.test.js` (fake sudo). Not verified live. - [x] Fallback (built, opt-in): with `/etc/agenthub/plugin-baseline.json` (`{"marketplaces": ["owner/repo", …], "plugins": ["name@marketplace", …]}`, root's, the admin edits it), creating a user runs `claude plugin marketplace add <source>` for each marketplace, then `claude plugin install --scope user <plugin>` for each plugin, AS the new member (`sudo -n -u ah-<name> HOME=… /opt/agenthub/tools/bin/claude …`, in the background; `agenthub users provision` runs it too, in the foreground, and is the retry). No file = nothing runs. An entry starting with `-` is skipped; `-y` is never passed. Sudoers: `/opt/agenthub/tools/bin/claude` joins the run-as-member list (no new power: the host already runs any command as a member through tmux). `server/lib/member-claude.js`, test `server-s3-provision.test.js` (fake sudo). Not verified live.
- [ ] A member's own plugins work from their terminal (`/plugin`) as soon as P3 lands, with no code. - [ ] A member's own plugins work from their terminal (`/plugin`) as soon as P3 lands, with no code.
- [ ] Later, optional: Settings for members, using `cc-config` with `home` = `req.user`'s and writes via `sudo -u`. - [x] Settings for members, using `cc-config` with `home` = `req.user`'s and writes via `sudo -u`. Done: one target resolver + the router's own allowlist gate (`server/lib/cc-target.js`; `/cc-config` left `STAFF_ONLY_PREFIXES`/`ADMIN_WRITE_PREFIXES`), the readers/writers take the member's home (`cc-discovery`/`cc-mutate` `home`, `noProject`, `ioFor` → write-as; backups via `write-as copy`), `claude plugin|mcp` as the member's sudo argv (user scope, no `-y`, no env/headers), project writes only in their own workspaces, an admin's project-memory create in a member's folder written as the member, `cc_config_changed` to the owner only (`scope.exactAudience`), `/reload` into their own panes; UI "Your Claude Code config" (`useCcTarget`). Test `server-s3-cc-config.test.js` (fake sudo, fake `claude` spawn). Not verified live.
#### P6 spike: how to run it (a human, on the S3 box) #### P6 spike: how to run it (a human, on the S3 box)
`scripts/server/spike-managed-plugins.sh` (never run by AgentHub). It changes `/etc/claude-code/managed-settings.json`, which EVERY Claude Code on the machine reads, the host's own sessions included, so run it in a quiet moment and undo right after. `scripts/server/spike-managed-plugins.sh` (never run by AgentHub). It changes `/etc/claude-code/managed-settings.json`, which EVERY Claude Code on the machine reads, the host's own sessions included, so run it in a quiet moment and undo right after.
+1 -1
View File
@@ -1,6 +1,6 @@
# Workspace redesign — overview and phase plan # Workspace redesign — overview and phase plan
_Status: specs written 2026-10-01, nothing implemented yet. Each phase is one PR, executed in order; a later phase assumes the earlier ones are merged._ _Status: specs written 2026-10-01; all four phases are built (sidebar tree + status bar + workbench `ba2c4e1`, grouped Settings with search `eb90244`, Orca themes + notification sounds `b71024f`, the lane → workspace copy rename). Kept as the design record. Each phase was one PR, executed in order._
## Why ## Why
+1 -1
View File
@@ -15,7 +15,7 @@ version here when it is cut: R = `v2.1.0`).
| 1 | Aliases: `/api/workspaces` = `/api/lanes` (same two routers mounted twice, `server/index.js` `LANE_MOUNTS`); `agenthub workspace`/`workspaces` = `agenthub lanes` (`COMMAND_ALIASES`/`canonicalArgv` in `bin/agenthub.js`); `--workspace <id>` = `--lane <id>` (`resolveLaneArg`). | R | done | | 1 | Aliases: `/api/workspaces` = `/api/lanes` (same two routers mounted twice, `server/index.js` `LANE_MOUNTS`); `agenthub workspace`/`workspaces` = `agenthub lanes` (`COMMAND_ALIASES`/`canonicalArgv` in `bin/agenthub.js`); `--workspace <id>` = `--lane <id>` (`resolveLaneArg`). | R | done |
| 2 | Help, usage and hint text, the UI's command hints and the user docs say `agenthub workspace …` / `--workspace`; `docs/API.md` documents both paths. | R | done | | 2 | Help, usage and hint text, the UI's command hints and the user docs say `agenthub workspace …` / `--workspace`; `docs/API.md` documents both paths. | R | done |
| 3 | Deprecation: `agenthub lanes` or `--lane` prints ONE stderr line naming the new spelling (`warnOldNames`), stdout unchanged; the help footer and `docs/CLI.md` call them the old names. | R | done | | 3 | Deprecation: `agenthub lanes` or `--lane` prints ONE stderr line naming the new spelling (`warnOldNames`), stdout unchanged; the help footer and `docs/CLI.md` call them the old names. | R | done |
| 4 | Remove the old CLI names (below). | **not before R+3** | open | | 4 | Remove the old CLI names (below). | **not before R+3** | done 2026-10-26 (after v2.8.25): 4a + 4b built, 4c kept `/api/lanes` |
Three releases is the window for an install to update gradually: anyone who Three releases is the window for an install to update gradually: anyone who
runs the CLI in that time sees the step-3 note on every old-name call, and a runs the CLI in that time sees the step-3 note on every old-name call, and a
+1 -1
View File
@@ -142,7 +142,7 @@ const TOOLS = {
list_lanes: { list_lanes: {
description: "Workspaces (lanes): id, title, folder, AI tool, status and liveness.", description: "Workspaces (lanes): id, title, folder, AI tool, status and liveness.",
properties: {}, properties: {},
path: () => "/api/lanes", path: () => "/api/workspaces",
}, },
list_pricing: { list_pricing: {
description: "Model pricing rules (USD per million tokens) the cost estimates use.", description: "Model pricing rules (USD per million tokens) the cost estimates use.",
+369 -17
View File
@@ -3,7 +3,7 @@
openapi: 3.0.3 openapi: 3.0.3
info: info:
title: Agent Dashboard for Claude Code API title: Agent Dashboard for Claude Code API
version: 2.8.24 version: 2.8.28
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence. description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
contact: contact:
name: Nguyễn Ngọc Trí Vĩ name: Nguyễn Ngọc Trí Vĩ
@@ -71,7 +71,7 @@ tags:
- name: Artifacts - name: Artifacts
description: 'What agents produced, shared as a SNAPSHOT: a claude.ai page link, a pinned or uploaded file, a skill or a plugin. A viewer reads only the snapshot copy (sandbox CSP, nosniff); an install writes into the caller''s own account only. Every write is same-origin.' description: 'What agents produced, shared as a SNAPSHOT: a claude.ai page link, a pinned or uploaded file, a skill or a plugin. A viewer reads only the snapshot copy (sandbox CSP, nosniff); an install writes into the caller''s own account only. Every write is same-origin.'
- name: Host - name: Host
description: 'Administration → Server: the machine''s hardware, load, disks and each user''s share of CPU and memory. Read-only, staff only in server mode.' description: 'Dashboard → System Health: the machine''s hardware, load, disks and each user''s share of CPU and memory. Read-only, every signed-in user.'
- name: Locks - name: Locks
description: Cross-lane named locks (server/lib/named-lock.js). Single-shot — the server never waits for a lock; `agenthub lock acquire` owns the polling loop. description: Cross-lane named locks (server/lib/named-lock.js). Single-shot — the server never waits for a lock; `agenthub lock acquire` owns the polling loop.
- name: Projects - name: Projects
@@ -262,6 +262,21 @@ components:
agent_count: agent_count:
type: integer type: integer
nullable: true nullable: true
owner_id:
type: integer
nullable: true
description: 'Server mode, list only: the account owning the session''s workspace (its lane, else the lane of a dashboard run holding it); null when none. Absent in local mode.'
owner_name:
type: string
nullable: true
description: That owner's username.
owner_display_name:
type: string
nullable: true
owner_avatar_at:
type: string
format: date-time
nullable: true
last_activity: last_activity:
type: string type: string
format: date-time format: date-time
@@ -1578,6 +1593,32 @@ components:
type: string type: string
ws_connections: ws_connections:
type: integer type: integer
event_loop:
type: object
description: Event-loop delay over the last minute in ms (p50, p99, max) — how long requests, hooks and terminal echo waited.
properties:
p50:
type: number
p99:
type: number
max:
type: number
activity:
type: object
description: 'For the System Health score: when the newest event (hook) landed, agents working now, and how the agents that ended in the last 24 h ended (scoped to the caller''s rows).'
properties:
last_event_at:
type: string
nullable: true
working_agents:
type: integer
agents_24h:
type: object
properties:
completed:
type: integer
error:
type: integer
transcript_cache: transcript_cache:
type: object type: object
required: required:
@@ -2178,7 +2219,7 @@ components:
type: string type: string
description: Command arguments — present only for `stdio` servers. description: Command arguments — present only for `stdio` servers.
example: example:
- '-y' - -y
- '@modelcontextprotocol/server-github' - '@modelcontextprotocol/server-github'
envNames: envNames:
type: array type: array
@@ -2340,7 +2381,7 @@ components:
project: project:
type: string type: string
description: 'Auto-memory only: the ~/.claude/projects/<slug> directory name the file belongs to.' description: 'Auto-memory only: the ~/.claude/projects/<slug> directory name the file belongs to.'
example: '-Users-son-repo' example: -Users-son-repo
name: name:
type: string type: string
description: 'Auto-memory only: the markdown filename.' description: 'Auto-memory only: the markdown filename.'
@@ -2868,7 +2909,7 @@ components:
project: project:
type: string type: string
description: 'Required for `auto-memory`: the ~/.claude/projects/<slug> directory name. Validated against /^[A-Za-z0-9_-][A-Za-z0-9._-]{0,255}$/ and rejected if it contains `..`.' description: 'Required for `auto-memory`: the ~/.claude/projects/<slug> directory name. Validated against /^[A-Za-z0-9_-][A-Za-z0-9._-]{0,255}$/ and rejected if it contains `..`.'
example: '-Users-son-repo' example: -Users-son-repo
CcConfigFileWriteResponse: CcConfigFileWriteResponse:
type: object type: object
description: Result of a successful create-or-overwrite. description: Result of a successful create-or-overwrite.
@@ -2933,7 +2974,7 @@ components:
project: project:
type: string type: string
description: 'Required for `auto-memory`: the projects/<slug> directory name.' description: 'Required for `auto-memory`: the projects/<slug> directory name.'
example: '-Users-son-repo' example: -Users-son-repo
CcConfigFileDeleteResponse: CcConfigFileDeleteResponse:
type: object type: object
description: Result of a successful delete (a mandatory backup is created first). description: Result of a successful delete (a mandatory backup is created first).
@@ -2984,7 +3025,7 @@ components:
project: project:
type: string type: string
description: 'Auto-memory only: the projects/<slug> directory name.' description: 'Auto-memory only: the projects/<slug> directory name.'
example: '-Users-son-repo' example: -Users-son-repo
type: type:
type: string type: string
enum: enum:
@@ -4362,8 +4403,8 @@ components:
items: items:
type: string type: string
example: example:
- '-Users-son-code-foo' - -Users-son-code-foo
- '-Users-son-code-bar' - -Users-son-code-bar
RemoteSourceSyncResponse: RemoteSourceSyncResponse:
type: object type: object
description: Counters from a pull-now sync against the remote source. description: Counters from a pull-now sync against the remote source.
@@ -8432,6 +8473,52 @@ paths:
text/html: text/html:
schema: schema:
type: string type: string
/api/redoc/redoc.standalone.js:
get:
tags:
- Documentation
summary: ReDoc bundle
description: The ReDoc standalone script the `/api/redoc` page loads, sent from the server's own vendored copy (no CDN).
operationId: getRedocBundle
responses:
'200':
description: JavaScript bundle
content:
application/javascript:
schema:
type: string
'500':
description: The bundle could not be read
/api/cc-config/target:
get:
tags:
- CcConfig
summary: Whose Claude Code config this login manages
description: 'Server mode: `own` for a non-admin with their own Linux account (S3) — every other endpoint then reads and writes THEIR `~/.claude`, writes running as them; `host` for an admin (and in local mode), or an analyst''s read-only view of the host''s; `none` for a non-admin with no account (the other endpoints answer 403 ENOACCOUNT). Never an error.'
operationId: ccConfigGetTarget
responses:
'200':
description: The target.
content:
application/json:
schema:
type: object
properties:
kind:
type: string
enum:
- own
- host
- none
readOnly:
type: boolean
claudeHome:
type: string
nullable: true
example:
kind: own
readOnly: false
claudeHome: /srv/agenthub/mia/.claude
/api/cc-config/overview: /api/cc-config/overview:
get: get:
tags: tags:
@@ -8833,7 +8920,7 @@ paths:
kind: stdio kind: stdio
command: npx command: npx
args: args:
- '-y' - -y
- '@modelcontextprotocol/server-github' - '@modelcontextprotocol/server-github'
envNames: envNames:
- GITHUB_TOKEN - GITHUB_TOKEN
@@ -8892,7 +8979,7 @@ paths:
items: items:
type: string type: string
example: example:
- '-y' - -y
- server-github - server-github
env: env:
type: object type: object
@@ -9116,7 +9203,7 @@ paths:
truncated: false truncated: false
preview: '# Workspace & Productivity System...' preview: '# Workspace & Productivity System...'
- scope: auto-memory - scope: auto-memory
project: '-Users-son-repo' project: -Users-son-repo
name: MEMORY.md name: MEMORY.md
isIndex: true isIndex: true
file: /Users/son/.claude/projects/-Users-son-repo/memory/MEMORY.md file: /Users/son/.claude/projects/-Users-son-repo/memory/MEMORY.md
@@ -13040,8 +13127,8 @@ paths:
ok: true ok: true
message: Connected; found 24 project directories under ~/.claude/projects. message: Connected; found 24 project directories under ~/.claude/projects.
remoteProjects: remoteProjects:
- '-Users-son-code-foo' - -Users-son-code-foo
- '-Users-son-code-bar' - -Users-son-code-bar
failure: failure:
summary: Unreachable / auth failed summary: Unreachable / auth failed
value: value:
@@ -14968,6 +15055,38 @@ paths:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/ErrorResponse' $ref: '#/components/schemas/ErrorResponse'
/api/users/{id}/details:
get:
tags:
- Auth
operationId: getServerUserDetails
summary: 'One account in full for the Administration page (admin/analyst): sign-ins, session counts, spend today/7d/30d, per local day (`tz_offset`), per AI tool and per model, its workspaces and live panes, newest sessions, the audit entries naming it, and per pool tool its pin, group and the accounts it may run on — each account''s quota beside this user''s own 5 h / 7 d share. Never a token'
parameters:
- name: id
in: path
required: true
schema:
type: integer
- name: tz_offset
in: query
schema:
type: integer
description: The browser's getTimezoneOffset(), for the daily buckets
responses:
'200':
description: Account details
'403':
description: EADMIN — a developer
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'404':
description: ENOUSER
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/users/{id}: /api/users/{id}:
get: get:
tags: tags:
@@ -15088,6 +15207,74 @@ paths:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/ErrorResponse' $ref: '#/components/schemas/ErrorResponse'
delete:
tags:
- Auth
operationId: deleteServerUser
summary: 'Admin (Delete user): the account for good — its logins, panes, workspaces'' records and prefs, and under S3 its Linux account and whole home, removed FIRST so a refusal leaves every row. Session history and the audit log stay. Never yourself (ESELF) or the last enabled admin (ELASTADMIN)'
parameters:
- name: id
in: path
required: true
schema:
type: integer
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- currentPassword
properties:
currentPassword:
type: string
responses:
'200':
description: Deleted — the account as it was
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) / 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 — yourself / ELASTADMIN — the last enabled admin
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'
'502':
description: EPROVISION — the Linux account or home could not be removed; nothing deleted
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/users/{id}/password: /api/users/{id}/password:
post: post:
tags: tags:
@@ -16979,16 +17166,17 @@ paths:
description: 'The identity; `sshUsers: [{host, user, alias, key}]`.' description: 'The identity; `sshUsers: [{host, user, alias, key}]`.'
'400': '400':
description: EBADHOST / EBADSSHUSER / EBADALIAS / ENOKEY description: EBADHOST / EBADSSHUSER / EBADALIAS / ENOKEY
/api/me/git/ssh-user/{from}: /api/me/git/ssh-user/{host}:
delete: delete:
tags: tags:
- Git identity - Git identity
summary: Drop the SSH login of a host or alias (and its key link) summary: Drop the SSH login of a host or alias (and its key link)
operationId: deleteSshUser operationId: deleteSshUser
parameters: parameters:
- name: from - name: host
in: path in: path
required: true required: true
description: The host or alias the login was saved under.
schema: schema:
type: string type: string
responses: responses:
@@ -17634,13 +17822,37 @@ paths:
description: 'Cached 3 s. `usage` is null off Linux. Server mode: admin/analyst only (403 EADMIN for a developer).' description: 'Cached 3 s. `usage` is null off Linux. Server mode: admin/analyst only (403 EADMIN for a developer).'
responses: responses:
'200': '200':
description: '`{at, hostname, os, uptime, cpu, memory, disks, homesMount, gpus, usage}` — see docs/API.md.' description: '`{at, hostname, os, uptime, bootTime, cpu (+physicalCores, perCore), memory, disks, homesMount, gpus, usage, network, diskIo, temperatures, pressure, topProcesses, process}` — see docs/API.md.'
content: content:
application/json: application/json:
schema: schema:
type: object type: object
'500': '500':
description: EHOST description: EHOST
/api/host/history:
get:
tags:
- Host
summary: The machine's CPU/memory history since the server started
operationId: getHostHistory
description: 'In memory only: one point every 30 s for 24 h, kept by a server-mode server on Linux (empty otherwise, `running: false`). Averaged down to at most 360 points. Server mode: admin/analyst only.'
parameters:
- name: range
in: query
schema:
type: string
enum:
- 1h
- 6h
- 24h
default: 1h
responses:
'200':
description: '`{range, intervalMs, running, startedAt, points: [{at, cpu, memory, memoryUsed, swapUsed, load1, users: {<userId>: {cpu, memory}}}]}`.'
content:
application/json:
schema:
type: object
/api/lanes: /api/lanes:
get: get:
tags: tags:
@@ -18988,6 +19200,58 @@ paths:
properties: properties:
installed: installed:
type: boolean type: boolean
/api/ai-proxies/9router/update:
get:
tags:
- AI Proxies
summary: 9router update status
description: 'Installed vs latest npm version of the host''s 9router, whether it runs (`managedBy`: the `9router.service` user unit or a plain process), `blocker` — why a start would be refused now (`ENOTINSTALLED`, `EUPTODATE`, `ENOLATEST`, `ENONPM`, `ENOPERM` with the `command` to run by hand, `EUNSUPPORTED`) — and the current/last job. `?fresh=1` re-asks the registry (cached 10 min).'
operationId: getAiProxy9routerUpdate
parameters:
- name: fresh
in: query
schema:
type: string
enum:
- '1'
responses:
'200':
description: Status.
content:
application/json:
schema:
type: object
post:
tags:
- AI Proxies
summary: Update 9router
description: 'Starts the one in-memory job: shut 9router down (stop the unit, or SIGTERM→SIGKILL its processes until the port is free), `npm install -g --prefix <its prefix> 9router@latest` (under `sudo -n` only when the prefix is not writable and sudo needs no password), then — only if it was running — start it again hidden (`--tray --no-browser --skip-update`). A failed update still relaunches. Admin only; same-origin guarded. Poll the GET for the steps.'
operationId: updateAiProxy9router
responses:
'202':
description: The job, running.
content:
application/json:
schema:
type: object
'403':
description: '`EADMIN`.'
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'409':
description: '`EBUSY` or the status `blocker` code.'
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'502':
description: '`ENOLATEST` — the npm registry did not answer.'
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/ai-proxies/test: /api/ai-proxies/test:
post: post:
tags: tags:
@@ -20326,6 +20590,94 @@ paths:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/ErrorResponse' $ref: '#/components/schemas/ErrorResponse'
/api/run/clipboard-files:
post:
tags:
- Run
summary: The files a file manager's Copy put on this machine's clipboard
operationId: runClipboardFiles
description: 'For a browser on this machine: the files the OS clipboard holds (Windows GetFileDropList, macOS NSPasteboard file URLs, Linux text/uri-list via wl-paste/xclip), each that exists here with its size (`null` for a folder), at most 100 — so a pasted copy is attached by its own path, with no upload and no size limit. The client uses a path only when name and size match the pasted file. 403 EREMOTE in server mode or for any non-loopback (or proxied) request.'
responses:
'200':
description: The clipboard's files; empty when it holds none or no reader is installed.
content:
application/json:
schema:
type: object
properties:
files:
type: array
items:
type: object
properties:
path:
type: string
size:
type: integer
nullable: true
dir:
type: boolean
'403':
description: EREMOTE.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/run/selected-files:
post:
tags:
- Run
summary: The files selected in this machine's file manager (what a drag carries)
operationId: runSelectedFiles
description: 'For a browser on this machine: the items selected in Explorer (every window, and the desktop) or Finder — a drag from the file manager carries exactly those — each that exists here with its size (`null` for a folder), at most 100, so a dropped file is attached by its own path with no upload and no size limit. Linux file managers publish no selection: always empty there. Nothing is searched for on disk. 403 EREMOTE in server mode or for any non-loopback (or proxied) request.'
responses:
'200':
description: The selected files; empty when none or no reader is available.
content:
application/json:
schema:
type: object
properties:
files:
type: array
items:
type: object
properties:
path:
type: string
size:
type: integer
nullable: true
dir:
type: boolean
'403':
description: EREMOTE.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/run/native-paste:
get:
tags:
- Run
summary: Whether a claude pane can read this machine's clipboard itself
operationId: runNativePaste
description: '`native`: local mode, a loopback request, and on Linux a reachable display plus `wl-paste` or `xclip` — then the terminal hands a pasted screenshot to `claude` as its image-paste key instead of uploading it. `key`: that chord on this machine — `alt+v` on Windows, `ctrl+v` elsewhere (Claude Code''s own bindings).'
responses:
'200':
description: The answer.
content:
application/json:
schema:
type: object
properties:
native:
type: boolean
key:
type: string
enum:
- ctrl+v
- alt+v
/api/run/interrupted: /api/run/interrupted:
get: get:
tags: tags:

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