From 0f17efe3b4d0d96c6d4a69f956171f85e9c12443 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Wed, 28 Oct 2026 10:41:53 +0700 Subject: [PATCH] feat: choose your own pool account; skills with "token" in a doc name upload; System Health off Linux MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Skill snapshot/upload: a key file's name (.env, *.pem, id_rsa, .netrc, .credentials.json…) is always refused, but a word like token/secret/ password/credential only in a data file's name — design-tokens.md or count_tokens.py is skill content, not a key. - System Health: off Linux the History, CPU threads and Busiest processes cards (all read from /proc) are left out instead of shown empty; the CPU bar without a sample and Windows' always-zero Load row go too. - Users pick their own pool account per tool (/api/me/pool-accounts, Settings → AI tools), within their groups, refused while pinned. - Admin user detail, 9router update, session replay and Antigravity catalog changes from parallel sessions. --- CLAUDE.md | 4 +- client/src/components/health/HostHealth.tsx | 388 +++++++++--------- .../health/__tests__/HostHealth.test.tsx | 32 +- client/src/components/lanes/SessionReplay.tsx | 2 +- .../components/settings/AiToolsSettings.tsx | 6 +- .../components/settings/NineRouterUpdate.tsx | 2 +- .../settings/PoolAccountSettings.tsx | 120 ++++++ .../__tests__/PoolAccountSettings.test.tsx | 85 ++++ client/src/components/users/UserDetail.tsx | 8 +- client/src/i18n/locales/en/settings.json | 14 + client/src/i18n/locales/en/users.json | 1 + client/src/i18n/locales/vi/settings.json | 14 + client/src/i18n/locales/vi/users.json | 1 + client/src/lib/api.ts | 27 ++ client/src/lib/settingsSearch.ts | 6 + client/src/pages/AiProxyConfig.tsx | 4 +- client/src/pages/SettingsHub.tsx | 3 + docs/API.md | 10 +- docs/SERVER-MODE.md | 27 +- openapi.yaml | 140 ++++++- server/__tests__/artifacts.test.js | 12 + server/__tests__/pool-account-choice.test.js | 194 +++++++++ server/__tests__/pty-run.test.js | 21 +- server/__tests__/server-mode-auth.test.js | 41 ++ server/data/ai-tools/README.md | 2 +- server/data/ai-tools/antigravity.json | 3 + server/db.js | 12 + server/lib/account-pins.js | 108 ++++- server/lib/artifacts.js | 15 +- server/lib/auth.js | 28 +- server/lib/claude-pool.js | 6 +- server/lib/codex-pool.js | 6 +- server/lib/codex-usage.js | 12 +- server/lib/cursor-pool.js | 2 +- server/openapi-extra/auth.js | 98 +++++ server/routes/ai-proxies.js | 7 +- server/routes/me.js | 54 +++ server/routes/sessions.js | 18 +- server/routes/users.js | 3 + 39 files changed, 1306 insertions(+), 230 deletions(-) create mode 100644 client/src/components/settings/PoolAccountSettings.tsx create mode 100644 client/src/components/settings/__tests__/PoolAccountSettings.test.tsx create mode 100644 server/__tests__/pool-account-choice.test.js diff --git a/CLAUDE.md b/CLAUDE.md index 495f4ad8..2d4f2aae 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # Claude Code Working Guide -_Last synced: v2.8.28 (2026-10-27) — System Health score from lag (event-loop p99 `server/lib/loop-delay.js`, PSI `pressure` on `/api/host`) + `client/src/lib/healthScore.ts`._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session. +_Last synced: v2.8.29 (2026-10-28) — a skill upload refuses a word like "token" only in a data file's name; System Health hides what off-Linux hosts cannot report._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session. ## Project mission - Maintain a reliable local-first dashboard for Claude Code session monitoring. @@ -82,7 +82,7 @@ See `docs/LANES.md` for full guide: stage reporting, custom templates, lane acti - **A headless entry point requires `server/lib/windows-hide.js` before anything reads `child_process`.** The dashboard and its detached helpers have no console on Windows, so every git/npm call without `windowsHide` opened a console window that flashed and stole focus on each poll. `install()` (in `server/index.js`, `plugin-bootstrap.js`, `plugin-update-helper.js`, `cc-update-all-helper.js`, `restart-helper.js`) forces it for every spawn; a new detached script needs the same first line, or a `windowsHide: true` of its own (the statusline's one git call has that). - **Server mode is a MODE of this codebase, never a fork, and local mode must not notice it.** Everything server-only checks `isServerMode()` (`AGENTHUB_MODE=server`, `server/lib/auth.js`) and is a no-op otherwise; `auth.js` opens the database only on first use, so a module that merely loads it never touches the DB in local mode. Accounts are made from the host's shell (`agenthub server setup`, `agenthub users …`) or by an admin on the Users page (`server/routes/users.js`), where EVERY change needs the admin's own password again (`confirmPassword` in `auth.js`, throttled — EREAUTH/ETOOMANY), never lets an admin demote or disable themselves (ESELF), is written to `audit_log`, and refuses in local mode (ELOCALMODE: no login to check) — do not add an account route without that step-up, and never one that returns a password hash. Rows are OWNED (phase S2, `server/lib/scope.js`): `lanes.owner_id` is the creator (never from the request — `ownerFor(req)`), a session belongs to its workspace (`sessions.lane_id` or a `dashboard_runs` row), and everything else hangs off the session. A `developer` sees only their own rows, and an `analyst` reads every row but writes only their own (`memberId` scopes an analyst's non-GET requests like a developer's — `member` was renamed `developer`, `db.js` migrates it): every list/aggregate passes `req` into `sessionWhere`/`bySessionWhere`/`laneWhere`; every route naming ONE row is covered by `scopeGuard`'s `PATH_RULES` + the query/body id names in `namedIds` (404 `ENOTFOUND`) — a new id route under a new prefix needs a rule there; every WebSocket broadcast type needs a `broadcastAudience` case (an unlisted type reaches no developer); a staff-wide view joins `STAFF_ONLY_PREFIXES`. `/cc-config` is in neither: its router gates itself (`server/lib/cc-target.js` — a non-admin reaches only an ALLOWLIST of reads and own-config writes; `/profiles*`, `/plugins/update-all` and every unlisted endpoint stay an admin's) and every handler acts on ONE target from `resolveCcTarget` — the host's home for an admin/local mode, a member's own `~/.claude` under S3 (writes through `write-as`, `claude plugin|mcp` as their sudo argv: user scope, no `-y`, no env/headers), `403 ENOACCOUNT` otherwise, never the host's; a new endpoint there joins `MEMBER_ROUTES` only if it acts on the target. Admin/analyst reads of a developer's session are written to `audit_log` (`server/lib/audit.js`). A pane carries `AGENTHUB_PANE_TOKEN` (hash in `dashboard_runs.pane_token_hash`, never listed) so the CLI/MCP/statusline inside it act as the workspace owner — accepted only from loopback and only while the pane lives (`userFromPaneToken`). Every write in server mode passes the Origin check in `authGuard`; prefix checks go through `apiPath()` (decoded, case-insensitive), because Express routes `/api/Webhooks` and `%77ebhooks` too. Named locks are namespaced per user, and `named-lock.js` takes only a plain name (a lock name was a path). It is NOT OS isolation unless S3 is installed (next rule); without it every pane runs as the host account, so do not call that multi-tenant. A new route that writes the host's own configuration joins `ADMIN_WRITE_PREFIXES`, and its controls hide behind `usePermissions().isAdmin` (`client/src/components/AuthGate.tsx`) — a hidden button is a courtesy, the server's refusal is the rule; hooks stay loopback-only in server mode. - **With S3 installed (server mode + `/etc/agenthub/server.conf`, `s3Enabled`), a pane runs as its workspace owner's Linux user, and the dashboard reaches a member only through sudo.** `ah-`, home `/`; the FOLDER decides (`accountForPath` in `server/lib/run-as.js`), and a folder under the users root with no enabled, provisioned user is `EMEMBERGONE` — never fall back to the host there. Every tmux call on a member's socket (attach included), every git/`gh` call in a member's folder and every write is `sudo -n -u ah- ` by the absolute paths in `server.conf` (`sudoArgv`; tmux through `/opt/agenthub/libexec/tmux-member` once installed, which runs the member's RUNNING server's own binary so a tmux upgrade never mixes versions); sudoers allows exactly those, plus `/opt/agenthub/tools/bin/claude` for the opt-in plugin baseline (`applyPluginBaseline`), as `%agenthub-members` and NEVER root as a run-as target. **The only root commands are `/opt/agenthub/sbin/agenthub-provision` (create/disable/enable/delete — `delete` is an admin's step-up-confirmed Delete user (`DELETE /api/users/:id`, `auth.deleteUser`: Linux account + home FIRST, rows only after it succeeded) and removes only an `agenthub-members` account and exactly `/` as a real directory, `rm -rf --one-file-system`, never through a link; it never adopts a non-member account, and otherwise root never touches a path inside an existing home) and `systemctl start|stop|restart|enable|disable agenthub.service`;** do not add a third — a host compromise is already every member's. **sudo logs argv, so nothing secret or private crosses it:** a member pane's env (pane token, pool token) and command (prompt) go in a launch file written by `write-as` from stdin and run by `pane-launch`; file content always goes on stdin. **The host never writes, chmods or deletes inside a member's home itself — only through `write-as` as the member** (`writeFileAs`, `handoffWrite`, `mkdirAs`, `removeAs`, `scratchFile`): the member can swap any name there for a link, and the host's write would follow it. A member's file is READ only through `openSafe`/`readFileSafe*`/`readStreamSafe` (open, then `fstat`: a regular file that member owns, `O_NONBLOCK`), never a plain `fs` read. **Hooks are authenticated by the peer uid** of the loopback connection (`/proc/net/tcp*`, `server/lib/hook-auth.js`), never by anything in the body: host or root = trusted, an enabled `ah-*` = only work, sessions, runs and transcript paths inside its own home (`EFOREIGN`, foreign paths dropped BEFORE any tool mapping reads them), anyone else `EPEER`. **`hidepid=invisible` (gid `agenthub-proc` exempt) is required** — panes' argv/env would otherwise be readable by every account — and `install-system` stops without it. The unit's ambient `CAP_DAC_READ_SEARCH` is dropped for every child (`server/lib/drop-caps.js`, installed first in `server/index.js`, before anything reads `child_process`) — keep it there, and a spawn outside `child_process` (node-pty) takes `ptyArgv`. **The account comes from a path's TEXT, so a path a member can redirect is checked before it is used:** a workspace's real path (`laneRoot` in `git-diff.js`, `EFOREIGN`) and a git dir their git prints (`git-exclude.js`) must stay the same account's (`sameOwner`), a member's home is no workspace, and a READ uses `ownerForRead`, never the throwing `accountForPath`. **A workspace inside a member's home is owned by that member only, and one outside the users root (it runs as the HOST) by an admin only, by the LIVE role** (`assertOwnerFits`: create, assign, every pane start — `EOWNERFOLDER`; demoting or disabling a user ends their host panes): its panes run as the FOLDER's account but carry the OWNER's pane token, which that account can read; a pane's cwd must be that same account's (`ECWDACCOUNT`), and a pane token is accepted only from that account's uid (`peerIsPaneAccount`). A path a non-admin names in ANY request (an analyst's GET too) stays in their own folder or workspaces (`ownFolder`, `insideOwnWorkspace`, `scope.ownCwdQuery`), and the config `file` reads never serve a credential file or anything in the data dir (`isSecretConfigPath`). **A member can stall or flood what the dashboard reads, and it all runs on one thread:** every synchronous sudo into a member is time-bounded (`MEMBER_EXEC`, tmux `memberCall` with a back-off) and a member's tmux failure makes only THEIR panes unknown, never everyone's; a member's file is read with a size cap (`readFileSafe*` `max`), line by line only through `readLines` (never `readline`, which a one-line 512 MB file crashes), and a sparse one is never copied out as real blocks. **S3 fails closed, never to the host:** `serverConf` keeps the last good `server.conf` in server mode if the file stops reading, `tmux.accountOf` throws (never "the host's server") when it cannot tell a session's account under S3, and every non-GET request naming a workspace re-checks `assertOwnerFits` by the LIVE role (`scopeGuard`, `409 EOWNERFOLDER`) — a demoted admin keeps READING their host-folder workspaces but `/up`, `/hook`, a commit or a file write there no longer runs as the host. The AI Proxy gateway marks what it relays (`x-agenthub-via-gateway`) and the dashboard refuses any request so marked (`403 EGATEWAY`), since a personal profile may point back at it from our own trusted uid; a member's launch file refuses a NUL in argv/env (`EBADARG`). Operator steps: `docs/SERVER-MODE.md` § Operator runbook. -- **A pool token reaches a pane only through tmux `-e` (a member's pane under S3: its launch file), and only for the account `pickAccount` chose.** Never type it into a pane, never put it in the CLI's argv, never return it from a route (`poolStatus` has no token field), and never add a route that reads one back — the pool is filled from the host's shell (`agenthub accounts add [--tool codex|cursor]`) or by an admin on the Users page (`POST`/`DELETE /api/accounts`, `/accounts` in `ADMIN_WRITE_PREFIXES`, their own password again, audited by account NAME), where the token only goes in. An empty pool must leave every spawn exactly as before. The two exceptions are a Claude login (`server/lib/claude-pool.js` kind `login`, moved in from a saved switch-user profile — the host CLI's own moves with its live login, and the host's home becomes one more placement — or a pasted `.credentials.json`) and a Codex ChatGPT login (`server/lib/codex-pool.js`), which neither CLI reads from an env var: it is written as `auth.json` into a MEMBER's own CODEX_HOME, as the member (`write-as`, 0600), never into a host pane's CODEX_HOME, never over a login AgentHub did not place, and never a second account into a home where a live pane of that member runs on one — except an admin's switch of that user (`account-pins.js` → `claude-pool.switchHome`, Claude only), which first copies every home's newer pair back (`refreshLogins`), never writes over the member's own login, and restarts no pane; `syncBack` copies the newest back. A Claude login's refresh token is single use, so the server is its one refresher (`refreshLogins`, ahead of expiry, pushing the new pair into every home it placed it in), and a pair a member's Claude refreshed first is copied back only once Anthropic confirms the same account (`fetchOwner`). A Codex API key is `-e AGENTHUB_POOL_KEY` plus a `-c` provider whose `env_key` only names it; a Cursor API key (`server/lib/cursor-pool.js`) is `-e CURSOR_API_KEY` and nothing else. **Pool groups** (`server/lib/pool-groups.js`) narrow every balancer through `allowed(ownerId, tool)`: a group's member takes only its accounts, a user in no group only the accounts no group lists, a pin wins; a login already live in a home is never swapped for a group change. The tool list is `account-pins.js` `TOOLS` (no DB CHECK). +- **A pool token reaches a pane only through tmux `-e` (a member's pane under S3: its launch file), and only for the account `pickAccount` chose.** Never type it into a pane, never put it in the CLI's argv, never return it from a route (`poolStatus` has no token field), and never add a route that reads one back — the pool is filled from the host's shell (`agenthub accounts add [--tool codex|cursor]`) or by an admin on the Users page (`POST`/`DELETE /api/accounts`, `/accounts` in `ADMIN_WRITE_PREFIXES`, their own password again, audited by account NAME), where the token only goes in. An empty pool must leave every spawn exactly as before. The two exceptions are a Claude login (`server/lib/claude-pool.js` kind `login`, moved in from a saved switch-user profile — the host CLI's own moves with its live login, and the host's home becomes one more placement — or a pasted `.credentials.json`) and a Codex ChatGPT login (`server/lib/codex-pool.js`), which neither CLI reads from an env var: it is written as `auth.json` into a MEMBER's own CODEX_HOME, as the member (`write-as`, 0600), never into a host pane's CODEX_HOME, never over a login AgentHub did not place, and never a second account into a home where a live pane of that member runs on one — except an admin's switch of that user (`account-pins.js` → `claude-pool.switchHome`, Claude only), which first copies every home's newer pair back (`refreshLogins`), never writes over the member's own login, and restarts no pane; `syncBack` copies the newest back. A Claude login's refresh token is single use, so the server is its one refresher (`refreshLogins`, ahead of expiry, pushing the new pair into every home it placed it in), and a pair a member's Claude refreshed first is copied back only once Anthropic confirms the same account (`fetchOwner`). A Codex API key is `-e AGENTHUB_POOL_KEY` plus a `-c` provider whose `env_key` only names it; a Cursor API key (`server/lib/cursor-pool.js`) is `-e CURSOR_API_KEY` and nothing else. **Pool groups** (`server/lib/pool-groups.js`) narrow every balancer through `allowed(ownerId, tool)`: a group's member takes only its accounts, a user in no group only the accounts no group lists, a pin wins, then the user's OWN choice (`account_choices`, `/api/me/pool-accounts` — only an account `allowed` names, re-checked at every spawn, refused while pinned); a login already live in a home is never swapped for a group change. The tool list is `account-pins.js` `TOOLS` (no DB CHECK). - **A shared Oh My Pi login is reached only through omp's gateway, by a per-pane key, never handed out.** `server/lib/omp-share.js` (S3 only, an admin's switch, off by default) runs `omp auth-broker serve` + `omp auth-gateway serve --clients=` as the host on loopback; both tokens stay in the host's home and the broker's reaches only the gateway's env — never a pane, a route, a log or argv. A member's omp gets the `agenthub` provider in its `models.yml` (written as them) whose `apiKey` is the env var NAME `AGENTHUB_OMP_KEY`; that key is one already QUEUED in the clients file (the gateway re-reads it only every 10 s, and an omp started on a refused key hangs for good), and the clients file holds only SHA-256s — of the queue and of LIVE omp panes' keys. Do not give a pane the broker or gateway token, `OMP_AUTH_BROKER_*`, or a copy of the host's `agent.db`. - Preserve existing behavior unless explicitly asked to change it. - Prefer minimal, reversible diffs. diff --git a/client/src/components/health/HostHealth.tsx b/client/src/components/health/HostHealth.tsx index c7064d95..eb3f8726 100644 --- a/client/src/components/health/HostHealth.tsx +++ b/client/src/components/health/HostHealth.tsx @@ -9,7 +9,8 @@ * 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 + * busiest processes with who each counts for (history, CPU threads and + * processes are Linux-only reads, so off Linux those cards are left out); 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. @@ -250,19 +251,23 @@ export function HostCard({ host, heap }: { host: HostState; heap?: HeapInfo }) { -
- {t("host.load")} -
- {s.cpu.load.slice(0, 3).map((load, i) => ( - s.cpu.cores ? "bg-status-danger/20 text-status-danger" : "bg-surface-3 text-fg-secondary"}`} - > - {load.toFixed(2)} - - ))} + {s.os.platform !== "win32" && ( +
+ + {t("host.load")} + +
+ {s.cpu.load.slice(0, 3).map((load, i) => ( + s.cpu.cores ? "bg-status-danger/20 text-status-danger" : "bg-surface-3 text-fg-secondary"}`} + > + {load.toFixed(2)} + + ))} +
-
+ )} {s.process && ( @@ -274,11 +279,9 @@ export function HostCard({ host, heap }: { host: HostState; heap?: HeapInfo }) { )}
- + {s.usage && ( + + )} g.kind !== "user"); const people = groups.filter((g) => g.kind === "user"); + // History, per-thread CPU and processes are read from /proc: on Windows and + // macOS their cards would only ever be empty, so they are left out. + const linux = stats.os.platform === "linux"; + const showCores = perCore.length > 0 || temps.length > 0 || stats.gpus.length > 0; + const showProcs = !!stats.usage || others.length > 0; // 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 @@ -408,132 +416,136 @@ export function HostSections({ host, heap }: { host: HostState; heap?: HeapInfo {/* CPU and memory over time. */} -
- - {RANGES.map((r) => ( - - ))} + {linux && ( +
+ + {RANGES.map((r) => ( + + ))} +
+ } + /> +
+
+
{t("host.historyCpu")}
+ +
+
+
{t("host.historyMemory")}
+
- } - /> -
-
-
{t("host.historyCpu")}
- -
-
-
{t("host.historyMemory")}
-
+ {history?.startedAt && ( +

+ {t("host.historyHelp", { + s: history.intervalMs / 1000, + at: formatDateTime(history.startedAt), + })} +

+ )}
- {history?.startedAt && ( -

- {t("host.historyHelp", { - s: history.intervalMs / 1000, - at: formatDateTime(history.startedAt), - })} -

- )} -
+ )}
-
+
{/* CPU: every thread as a compact cell, then temperatures and GPUs. */} -
- - {t("host.threads", { n: perCore.length || stats.cpu.cores })} - - } - /> - {perCore.length > 0 && ( -
- {perCore.map((v, i) => ( - - ))} -
- )} - {temps.length > 0 && ( -
-
- - {t("host.temperatures")} + {showCores && ( +
+ + {t("host.threads", { n: perCore.length || stats.cpu.cores })} + + } + /> + {perCore.length > 0 && ( +
+ {perCore.map((v, i) => ( + + ))}
- {temps.map((s, i) => ( -
- - {s.label} · {s.chip} - - = 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 - + )} + {temps.length > 0 && ( +
+
+ + {t("host.temperatures")}
- ))} -
- )} - {stats.gpus.length > 0 && ( -
- {stats.gpus.map((g, i) => ( - - ))} -
- )} -
+ {temps.map((s, i) => ( +
+ + {s.label} · {s.chip} + + = 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 + +
+ ))} +
+ )} + {stats.gpus.length > 0 && ( +
+ {stats.gpus.map((g, i) => ( + + ))} +
+ )} +
+ )} {/* Disks (with their I/O) and network interfaces. */} {(diskRows.length > 0 || loneIo.length > 0 || network.length > 0) && ( @@ -587,60 +599,62 @@ export function HostSections({ host, heap }: { host: HostState; heap?: HeapInfo )} {/* The busiest processes, and what belongs to no user. */} -
- - {t("host.sampled", { s: (stats.usage?.sampleMs ?? 1000) / 1000 })} - - } - /> - {!stats.usage ? ( -

{t("host.usageUnavailable")}

- ) : ( -
- {topProcs.map((p) => ( - -
- - {p.name} - {ownerOf(p.group)} + {showProcs && ( +
+ + {t("host.sampled", { s: (stats.usage?.sampleMs ?? 1000) / 1000 })} + + } + /> + {!stats.usage ? ( +

{t("host.usageUnavailable")}

+ ) : ( +
+ {topProcs.map((p) => ( + +
+ + {p.name} + {ownerOf(p.group)} + + + {formatBytes(p.memory)} + + = 50 ? "bg-status-warning/20 text-status-warning" : "bg-surface-3 text-fg-secondary"}`} + > + {fmtPct(p.cpu)} + +
+
+ ))} +
+ )} + {others.length > 0 && ( +
+ {others.map((g) => ( +
+ + {groupName(g)} - - {formatBytes(p.memory)} - - = 50 ? "bg-status-warning/20 text-status-warning" : "bg-surface-3 text-fg-secondary"}`} - > - {fmtPct(p.cpu)} + + {fmtPct(g.cpu)} · {formatBytes(g.memory)} ·{" "} + {t("host.processCount", { n: g.processes })}
- - ))} -
- )} - {others.length > 0 && ( -
- {others.map((g) => ( -
- - {groupName(g)} - - - {fmtPct(g.cpu)} · {formatBytes(g.memory)} ·{" "} - {t("host.processCount", { n: g.processes })} - -
- ))} -
- )} -
+ ))} +
+ )} +
+ )}
{/* Each user's live share of the machine (server mode). */} diff --git a/client/src/components/health/__tests__/HostHealth.test.tsx b/client/src/components/health/__tests__/HostHealth.test.tsx index d01b2338..55f7a769 100644 --- a/client/src/components/health/__tests__/HostHealth.test.tsx +++ b/client/src/components/health/__tests__/HostHealth.test.tsx @@ -7,7 +7,8 @@ * 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). + * each user's live CPU / RAM as a tile (their busiest processes on click); + * off Linux the cards it can never fill are left out. * @author Nguyễn Ngọc Trí Vĩ */ @@ -179,4 +180,33 @@ describe("HostHealth", () => { fireEvent.click(within(tile).getByRole("button", { expanded: false })); expect(within(tile).getByText("claude")).toBeTruthy(); }); + + it("leaves out what Windows cannot report instead of empty cards", async () => { + api.host.stats.mockResolvedValue({ + ...stats, + os: { platform: "win32", release: "10.0.26300", distro: null, arch: "x64" }, + cpu: { ...stats.cpu, load: [0, 0, 0], perCore: [] }, + disks: [], + usage: null, + }); + api.host.history.mockResolvedValue({ + range: "1h", + intervalMs: 30000, + running: false, + points: [], + }); + render(); + const card = await screen.findByTestId("server-facts"); + expect(await within(card).findByText(/win32/)).toBeTruthy(); + expect(within(card).queryByRole("meter", { name: "CPU" })).toBeNull(); + expect(card.textContent).not.toContain("Load"); + expect(within(card).getByRole("meter", { name: "Memory" })).toBeTruthy(); + for (const id of [ + "server-history", + "server-cores", + "server-top-processes", + "server-disks-table", + ]) + expect(screen.queryByTestId(id)).toBeNull(); + }); }); diff --git a/client/src/components/lanes/SessionReplay.tsx b/client/src/components/lanes/SessionReplay.tsx index fcf129d8..c5d6aa4d 100644 --- a/client/src/components/lanes/SessionReplay.tsx +++ b/client/src/components/lanes/SessionReplay.tsx @@ -10,7 +10,7 @@ * second `--resume` (the server refuses one too, `ESESSIONLIVE`) — the typed * message is pasted there (never sent: Enter is the user's), with a toast; while * it is live the composer is off. Only Claude, Codex and a catalog tool with - * `resumeById` (omp) can resume a session by id — any other tool's session + * `resumeById` (omp, Cursor, Antigravity) can resume a session by id — any other tool's session * offers a new session from the workspace home * instead. The header carries the transcript page's Resume/Fork * (`SessionResumeButton`, behind its warning). The session's tool and folder diff --git a/client/src/components/settings/AiToolsSettings.tsx b/client/src/components/settings/AiToolsSettings.tsx index d975ba56..9993fd2d 100644 --- a/client/src/components/settings/AiToolsSettings.tsx +++ b/client/src/components/settings/AiToolsSettings.tsx @@ -5,7 +5,8 @@ * Default badge or Set default, and Update whenever the catalog names an * updater — no version lookup, the updater itself knows whether it has work. * The ones available to install fold behind a disclosure, with Install where - * the catalog knows how. Install/Update/Update all confirm the exact command + * the catalog knows how (an admin's; Update all is anyone's — auth.js + * MEMBER_WRITE_EXCEPTIONS). Install/Update/Update all confirm the exact command * first, run as ONE server job at a time and show its log under the row * (polled from GET /api/ai-tools/job; a finished job folds to one line). * A right-click on an installed row opens its Update / Set default / @@ -231,7 +232,8 @@ export function AiToolsSettings({ {t("aiTools.installed", { count: installed.length })}

- {isAdmin && updateAllTools.length > 0 && ( + {/* Every signed-in user may run Update all (auth.js MEMBER_WRITE_EXCEPTIONS). */} + {updateAllTools.length > 0 && (
{p.accounts.map((a) => { diff --git a/client/src/i18n/locales/en/settings.json b/client/src/i18n/locales/en/settings.json index df7257fb..35f1fa5c 100644 --- a/client/src/i18n/locales/en/settings.json +++ b/client/src/i18n/locales/en/settings.json @@ -1410,5 +1410,19 @@ "apikey": "API key", "credentials": "Logins" } + }, + "poolAccount": { + "title": "Pool account", + "description": "Which account of the shared account pool your own new sessions run on. Automatic lets AgentHub pick the least-used one.", + "automatic": "Automatic", + "select": "{{tool}} pool account", + "count_one": "{{count}} account you may use", + "count_other": "{{count}} accounts you may use", + "pinned": "An admin pinned you to {{account}}", + "expired": "expired", + "full": "quota full", + "automaticSaved": "Back to automatic — AgentHub picks the account", + "switchedNow": "Switched to {{account}} — running sessions follow it", + "nextSession": "{{account}} is used from your next session" } } diff --git a/client/src/i18n/locales/en/users.json b/client/src/i18n/locales/en/users.json index d1d29d00..cadbe926 100644 --- a/client/src/i18n/locales/en/users.json +++ b/client/src/i18n/locales/en/users.json @@ -343,6 +343,7 @@ "noUsage": "No spend in the last 30 days.", "pool": "Account pool & quota", "poolPinned": "pinned to {{account}}", + "poolChosen": "chose {{account}}", "poolGroup": "group {{name}}", "poolShared": "shared pool", "poolNone": "No account in this tool's pool.", diff --git a/client/src/i18n/locales/vi/settings.json b/client/src/i18n/locales/vi/settings.json index 6d1f6b51..234a9513 100644 --- a/client/src/i18n/locales/vi/settings.json +++ b/client/src/i18n/locales/vi/settings.json @@ -1398,5 +1398,19 @@ "apikey": "API key", "credentials": "Đăng nhập" } + }, + "poolAccount": { + "title": "Tài khoản pool", + "description": "Tài khoản nào trong pool tài khoản dùng chung chạy các phiên mới của bạn. Tự động để AgentHub chọn tài khoản ít dùng nhất.", + "automatic": "Tự động", + "select": "Tài khoản pool của {{tool}}", + "count_one": "{{count}} tài khoản bạn được dùng", + "count_other": "{{count}} tài khoản bạn được dùng", + "pinned": "Admin đã ghim bạn vào {{account}}", + "expired": "hết hạn", + "full": "hết quota", + "automaticSaved": "Đã về Tự động — AgentHub tự chọn tài khoản", + "switchedNow": "Đã chuyển sang {{account}} — các phiên đang chạy theo luôn", + "nextSession": "{{account}} được dùng từ phiên tiếp theo" } } diff --git a/client/src/i18n/locales/vi/users.json b/client/src/i18n/locales/vi/users.json index 1da350c8..c601c313 100644 --- a/client/src/i18n/locales/vi/users.json +++ b/client/src/i18n/locales/vi/users.json @@ -343,6 +343,7 @@ "noUsage": "Không có chi phí trong 30 ngày qua.", "pool": "Account pool & quota", "poolPinned": "ghim vào {{account}}", + "poolChosen": "tự chọn {{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.", diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index 6b913dee..f4a28769 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -616,6 +616,8 @@ export type UserDetails = { PoolTool, { pin: string | null; + /** The user's own choice (below a pin); absent from an older server. */ + choice?: string | null; group: { id: number; name: string } | null; accounts: UserPoolAccount[]; } @@ -865,6 +867,22 @@ export type PoolGroupPatch = { accounts?: Partial>; }; export type PoolTool = "claude" | "codex" | "cursor"; +/** One tool of `GET /api/me/pool-accounts`: the accounts this user may use — never a token. */ +export type MyPoolTool = { + tool: PoolTool; + /** An admin's pin: while set, the user cannot choose. */ + pin: string | null; + /** The user's own choice; null = the balancer picks. */ + choice: string | null; + accounts: { + name: string; + kind: string; + email: string | null; + expired: boolean; + quotaPercent: number | null; + full: boolean; + }[]; +}; /** A saved switch-user profile that can be moved into the pool (no token). */ export type PoolProfile = { name: string; @@ -1226,6 +1244,15 @@ export const api = { request("/me/avatar/url", { method: "POST", body: JSON.stringify({ url }) }), /** DELETE /api/me/avatar — back to the initials. */ removeAvatar: () => request("/me/avatar", { method: "DELETE" }), + /** GET /api/me/pool-accounts — per tool, the pool accounts this account may use (server mode). */ + poolAccounts: () => request<{ tools: MyPoolTool[] }>("/me/pool-accounts"), + /** PUT /api/me/pool-accounts — run new sessions of `tool` on `account` (`null` = automatic). */ + choosePoolAccount: (tool: PoolTool, account: string | null) => + request<{ + tool: PoolTool; + choice: string | null; + switched: "switched" | "kept" | "own-login" | "expired" | "next-session" | null; + }>("/me/pool-accounts", { method: "PUT", body: JSON.stringify({ tool, account }) }), }, /** GET /api/host — System Health's machine cards and the Administration users table's CPU/RAM (local mode, or staff). */ host: { diff --git a/client/src/lib/settingsSearch.ts b/client/src/lib/settingsSearch.ts index f9d36b69..24be7165 100644 --- a/client/src/lib/settingsSearch.ts +++ b/client/src/lib/settingsSearch.ts @@ -329,6 +329,12 @@ export const SETTINGS_INDEX: { ], to: "#tool-profiles", }, + { + section: "agents", + titleKey: "poolAccount.title", + keywords: ["pool", "account", "switch", "quota", "tài khoản", "pool account"], + to: "#pool-account", + }, { section: "agents/claude-code", titleKey: "ccConfig:backups.title", keywords: ["restore"] }, { section: "agents/codex", titleKey: "codexHooks.title" }, { section: "agents/codex", titleKey: "codexHome.title" }, diff --git a/client/src/pages/AiProxyConfig.tsx b/client/src/pages/AiProxyConfig.tsx index cd8e6f2c..92746a80 100644 --- a/client/src/pages/AiProxyConfig.tsx +++ b/client/src/pages/AiProxyConfig.tsx @@ -30,7 +30,7 @@ * — 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. * It is shown only when the server finds a `9router` binary installed. - * Beside it an admin gets **Update 9router** (`NineRouterUpdate`): shut it + * Beside it every user 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 @@ -274,7 +274,7 @@ export function AiProxyConfig() { {t("aiProxy.config9router")} )} - {nineRouter && isAdmin && } + {nineRouter && }