From 7a88b74016f5c0c14e944039663134d12482300d Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Wed, 14 Oct 2026 17:21:33 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20v2.8.0=20=E2=80=94=20Artifacts=20upload?= =?UTF-8?q?s=20and=20plugin=20links,=20console=20tab=20combos,=20account?= =?UTF-8?q?=20avatars?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Artifacts hub: upload a file from the computer; make a skill by uploading a Markdown file, files, a folder or a .zip/.skill, or by writing it in the dialog; add plugins by picking from the list a marketplace link answers (POST /api/artifacts/plugin/inspect); Settings → AI tools → Artifacts Hub. - Console tabs are combos of up to four split panes (ConsoleTabs, PaneGrid). - Accounts: an own avatar (PUT/DELETE /api/me/avatar, raster only, ≤ 1 MB, /avatars/), shown with name and role at the sidebar's foot and on the workspace owner groups (GET /api/users/:id/avatar, owner_avatar_at). - Users page: per-user storage (GET /api/users/storage, measured in a worker) and account-pool spend/tokens per account (server/lib/pool-usage.js). Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 4 +- bin/agenthub.js | 8 +- client/src/components/Sidebar.tsx | 72 +- client/src/components/WorkspaceTree.tsx | 19 +- .../__tests__/Sidebar.account.test.tsx | 73 ++ .../__tests__/WorkspaceTree.owners.test.tsx | 23 +- .../artifacts/ArtifactAddPanels.tsx | 928 ++++++++++++++++++ client/src/components/run/ConsoleTabs.tsx | 76 +- client/src/components/run/PaneGrid.tsx | 71 +- .../run/__tests__/ConsoleTabs.test.tsx | 126 +++ .../components/run/__tests__/PaneGrid.test.ts | 17 +- .../components/settings/AccountSettings.tsx | 106 +- .../settings/ArtifactsHubSettings.tsx | 45 + .../__tests__/AccountSettings.test.tsx | 29 +- .../__tests__/ArtifactsHubSettings.test.tsx | 46 + .../src/components/users/AccountPoolCard.tsx | 15 +- client/src/components/users/UserAvatar.tsx | 59 ++ .../users/__tests__/AccountPoolCard.test.tsx | 6 + client/src/i18n/locales/en/artifacts.json | 41 +- client/src/i18n/locales/en/lanes.json | 8 +- client/src/i18n/locales/en/settings.json | 7 + client/src/i18n/locales/en/users.json | 10 +- client/src/i18n/locales/vi/artifacts.json | 41 +- client/src/i18n/locales/vi/lanes.json | 8 +- client/src/i18n/locales/vi/settings.json | 7 + client/src/i18n/locales/vi/users.json | 10 +- client/src/lib/api.ts | 122 ++- client/src/lib/settingsSearch.ts | 9 + client/src/lib/types.ts | 2 + client/src/pages/Artifacts.tsx | 183 ++-- client/src/pages/SettingsHub.tsx | 3 + client/src/pages/Users.tsx | 91 +- client/src/pages/__tests__/Artifacts.test.tsx | 108 +- .../src/pages/__tests__/SettingsHub.test.tsx | 3 + client/src/pages/__tests__/Users.test.tsx | 26 +- .../screens.snapshot.test.tsx.snap | 2 +- docs/API.md | 25 +- docs/CLI.md | 2 +- docs/LANES.md | 4 +- docs/SERVER-MODE.md | 35 +- docs/plans/artifacts/README.md | 6 +- package-lock.json | 4 +- package.json | 2 +- server/__tests__/artifacts.test.js | 182 +++- server/__tests__/claude-pool-login.test.js | 22 + server/__tests__/claude-pool.test.js | 54 +- server/__tests__/me-avatar.test.js | 137 +++ server/__tests__/user-storage.test.js | 107 ++ server/db.js | 6 +- server/lib/artifacts.js | 498 +++++++++- server/lib/auth.js | 3 + server/lib/avatars.js | 71 ++ server/lib/claude-pool.js | 71 +- server/lib/codex-pool.js | 31 +- server/lib/lanes.js | 15 +- server/lib/pets.js | 1 + server/lib/pool-usage.js | 64 ++ server/lib/user-storage.js | 172 ++++ server/openapi-extra/auth.js | 63 ++ server/routes/artifacts.js | 93 +- server/routes/me.js | 51 +- server/routes/pricing.js | 40 +- server/routes/users.js | 24 +- 63 files changed, 3833 insertions(+), 354 deletions(-) create mode 100644 client/src/components/__tests__/Sidebar.account.test.tsx create mode 100644 client/src/components/artifacts/ArtifactAddPanels.tsx create mode 100644 client/src/components/run/__tests__/ConsoleTabs.test.tsx create mode 100644 client/src/components/settings/ArtifactsHubSettings.tsx create mode 100644 client/src/components/settings/__tests__/ArtifactsHubSettings.test.tsx create mode 100644 client/src/components/users/UserAvatar.tsx create mode 100644 server/__tests__/me-avatar.test.js create mode 100644 server/__tests__/user-storage.test.js create mode 100644 server/lib/avatars.js create mode 100644 server/lib/pool-usage.js create mode 100644 server/lib/user-storage.js diff --git a/CLAUDE.md b/CLAUDE.md index 4974bb50..2aa4d10f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # Claude Code Working Guide -_Last synced: v2.7.9 (2026-10-14) — Add workspace has no AI tool picker (default tool)._ 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.0 (2026-10-14) — Artifacts uploads and plugin links, tab combos, account avatars._ 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. @@ -59,7 +59,7 @@ A **workspace** — called a **lane** in the API, CLI, database and code, which - **The File Explorer (`server/lib/file-explorer.js`, `/api/lanes/:id/fs/*`) reuses the read path's guards and never replaces silently.** Every path is `lanePath` + the PARENT `resolveInLane`-confined with the last segment kept as a name (so a symlink is moved/deleted as a link, never followed); never in or under `.git`, never a secret, never the workspace folder itself. Writes run as the folder's account — a member's only through `write-as` (`move`/`copy` modes; `writeAsAsync` streams an upload to its stdin) — and a move/copy never crosses accounts (`sameOwner`, `EFOREIGN`). A taken name is settled by `conflict` (`fail` → 409 with `names`, `skip`, `keep`, `replace` = the old one to the trash first). Delete is a move into the account's trash (member: `~/.local/share/agenthub/trash/`, host: `/trash//`), restorable 7 days. `write-as` is root-installed: a change to it needs `sudo agenthub server install-system` again (`EUPGRADE` until then). A user's HOME is browsed through the same module inside `inHome` (`/api/homes/:who/fs/*`, `server/lib/homes.js` picks the folder): every dot entry at the TOP of a home stays hidden and refused — through a symlink, a download, a zip or as a copy source too — and another user's home is an admin's READ-ONLY, audited view (`EREADONLY`). Do not add a delete that skips the trash, an overwrite without `conflict`, or a request path that is not run through `locate`/`folder`. - **The whole-file editor and the Files tab's upload are the two non-git writes on the lane file surface, and both live outside `git-ops.js` on purpose.** `git-ops.js` only touches paths `git status` reports as changed; `lane-file-write.js` may save any existing text file, so it must reuse the READ path's guards (`lanePath`, `resolveInLane`, `isSecretOnDisk`) and never be weaker than them, and it saves only over the `baseSha` it was opened at — do not let it create files or touch a binary. `lane-file-transfer.js`'s upload (`POST /api/lanes/:id/git/upload`, issue #78) is the opposite and only that: it CREATES one file (any bytes, ≤ 25 MB) under the same guards, into an existing folder, refuses a taken name (`EEXISTS`, a symlink included) and anything into or named `.git` (a hook there runs on the dashboard's next git call), and writes as the folder's member through `write-as` under S3 — do not let it overwrite, make folders or take a path from anything but `dir` + one plain name. Its `GET …/git/download` twin streams any file through `openSafe` as a `sandbox`ed attachment. The tree, expand-all and quick-open read the disk (`walkFolder`) for a folder that is not a repo. - **Server tests never touch the user's tmux.** Run them through `npm run test:server` (`scripts/test-server.js`), never a bare `node --test server/__tests__/*.test.js` from inside a lane pane — the suites create and kill real `agenthub-lane-` sessions. -- **An artifact is shared as a SNAPSHOT and installed only into the viewer's own account.** `server/lib/artifacts.js` (`/api/artifacts`): a pinned file or a skill is copied once into `/artifacts//` and every viewer reads only that copy — `raw` serves only a file the snapshot recorded, under a `sandbox` CSP + `nosniff`; a `page` is a `https://claude.ai/…` link only. A developer sees their own rows plus shared ones; only the owner or an admin changes one. Install writes a skill into the CALLER's `~/.claude/skills/` (S3: as them through `write-as`), never over an existing one without `replace` (the old one is moved to `~/.claude/agenthub-replaced-skills/`, never deleted), and runs a plugin's `claude plugin marketplace add`/`install --scope user` as the caller, never with `-y`; an admin's switch turns installs off. Do not add a route that serves a live file of another user, installs into someone else's account, or takes a plugin command from the request. +- **An artifact is shared as a SNAPSHOT and installed only into the viewer's own account.** `server/lib/artifacts.js` (`/api/artifacts`): a pinned file or a skill is copied once into `/artifacts//` and every viewer reads only that copy — `raw` serves only a file the snapshot recorded, under a `sandbox` CSP + `nosniff`; a `page` is a `https://claude.ai/…` link only. A developer sees their own rows plus shared ones; only the owner or an admin changes one. Install writes a skill into the CALLER's `~/.claude/skills/` (S3: as them through `write-as`), never over an existing one without `replace` (the old one is moved to `~/.claude/agenthub-replaced-skills/`, never deleted), and runs a plugin's `claude plugin marketplace add`/`install --scope user` as the caller, never with `-y`; an admin's switch (Settings → AI tools, `ArtifactsHubSettings`) turns installs off. An uploaded or written skill is snapshotted under the same checks (paths relative, no `..`, no secret names, zip sizes read before inflating). `POST /api/artifacts/plugin/inspect` clones a user-named https link only to read `marketplace.json`, with every credential helper OFF — never send a login with it. Do not add a route that serves a live file of another user, installs into someone else's account, or takes a plugin command from the request. - **A workspace's instructions, extra folders and context files are lane facts, and a pane reads them only from the lane's rows.** The one addition is the workspace OWNER's own agent instructions (`user_prefs.instructions`, `PUT /api/me/instructions`, Settings → AI tools), which `launchExtras` puts first — keyed by `lanes.owner_id` (`local` in local mode), never by whoever clicked Start. `lanes.instructions` and `lane_sources` (`server/lib/lane-sources.js`) reach a pane through `launchExtras(laneId, tool)`, which `spawnRun` calls ITSELF — so neither spawn path can take them from a request — and every write is same-origin (`server/routes/lane-sources.js`). A folder must be absolute, exist, be a directory, not be or contain the OS home (`validateHomeDir`) and not be the workspace's own folder; it is the user's, and AgentHub never deletes it. An uploaded file is stored under `/workspace-context//` under a name AgentHub derives (never a path from the request), and those copies are the only thing here AgentHub deletes (`ownsFile`): with their row, and with the workspace in `deleteLane`. Claude's `--add-dir` is VARIADIC, so it goes first in the argv where `--permission-mode` ends it, and the text (instructions + context file list + extra folders) is written to `/workspace-prompts/.md` at every spawn and passed as `--append-system-prompt-file=` (one `=` argv element, so nothing in it is read as an option); Codex gets one `--add-dir` per directory and `-c developer_instructions=` — the text itself up to 6 KB, else a line pointing at that file. Only `claude`/`codex` take them. Never write them into the user's repo (CLAUDE.md/AGENTS.md). - **A workspace's sessions are the ones it OWNS, never the ones that ran in its folder.** `sessions.lane_id` is stamped by `bindRunSession` (`server/routes/hooks.js`) for the session a dashboard pane holds — first writer wins, a nested `claude -p` excluded — and no route sets it. A Codex/Antigravity session (their hooks are opt-in, so a pane may never learn its session) is also stamped by `claimToolSessions` (`server/lib/dashboard-runs.js`, called by both pollers and once at start): the lane of the newest same-tool dashboard run in the session's folder that was live when the session started, never a Claude session. A new Claude pane is started with `--session-id ` (`spawnRun`), so its `dashboard_runs.session_id` is known before any hook and a hook that carries no `run_id` (a hook handler older than `AGENTHUB_RUN_ID` forwarding) is still stamped from that run (`laneForSession`). Every workspace session list reads `GET /api/sessions?lane=` — which also counts a still-unstamped session one of the lane's `dashboard_runs` holds, because hooks reach only the lowest-port dashboard on a shared DB and that one may be an older build that never stamps — and shows nothing else (no "other sessions in this folder"). Do not go back to listing a workspace by `cwd`, and do not guess ownership for old history. - **A session's worktree is `claude --worktree`'s, resolved from the session row — and removing it never forces.** A Worktree start passes `--worktree=` (the `=` form: `-w [name]` eats the prompt otherwise) and Claude Code makes, populates and cleans up `/.claude/worktrees/`; AgentHub only excludes `/.claude/worktrees/` at the repo root. A git route, a resume or a removal may work in a session's worktree only through `sessionWorktree` (`server/lib/session-worktree.js`: the session owned by the lane, its cwd not the lane's folder, registered by `git worktree list`) — `?session=` on the git routes answers `ENOWORKTREE` rather than falling back to the main folder. `POST /api/lanes/:id/session-worktree/remove` (same-origin, `confirm: true`, refused while a live pane holds the session) is the ONE dashboard path that deletes a worktree: `git worktree remove` with no `--force`, `git branch -d` never `-D`, a live-pid lock refused. Do not add a force, and do not let it touch a lane's own folder. diff --git a/bin/agenthub.js b/bin/agenthub.js index ff677756..6a0d6b93 100755 --- a/bin/agenthub.js +++ b/bin/agenthub.js @@ -4232,7 +4232,7 @@ async function cmdCodexAccounts(sub, name, flags) { } pool.resetQuota(); table( - ["codex account", "kind", "live", "$ 5h", "$ 7d", "quota", "state"], + ["codex account", "kind", "live", "$ 5h", "$ 7d", "tok 5h", "tok 7d", "quota", "state"], pool .poolStatus() .map((a) => [ @@ -4241,6 +4241,8 @@ async function cmdCodexAccounts(sub, name, flags) { a.livePanes, a.spend5h.toFixed(2), a.spend7d.toFixed(2), + a.tokens5h, + a.tokens7d, a.quota ? a.quota.windows.map((w) => `${w.label} ${Math.round(w.usedPercent)}%`).join(" ") : "–", @@ -4281,7 +4283,7 @@ async function cmdAccounts(sub, positional, flags = {}) { await pool.refreshQuotas({ force: true }); const pct = (w) => (w?.utilization != null ? `${Math.round(w.utilization)}%` : "–"); table( - ["account", "live", "$ 5h", "$ 7d", "quota 5h", "quota wk", "state"], + ["account", "live", "$ 5h", "$ 7d", "tok 5h", "tok 7d", "quota 5h", "quota wk", "state"], pool .poolStatus() .map((a) => [ @@ -4289,6 +4291,8 @@ async function cmdAccounts(sub, positional, flags = {}) { a.livePanes, a.spend5h.toFixed(2), a.spend7d.toFixed(2), + a.tokens5h, + a.tokens7d, pct(a.quota?.fiveHour), pct(a.quota?.sevenDay), a.expired diff --git a/client/src/components/Sidebar.tsx b/client/src/components/Sidebar.tsx index cc979d54..594635af 100644 --- a/client/src/components/Sidebar.tsx +++ b/client/src/components/Sidebar.tsx @@ -1,6 +1,6 @@ /** * @file Sidebar.tsx - * @description The app's left sidebar: the brand, the navigation links and the workspace list, with Settings (on a workspace, that workspace's Settings tab — `lib/settingsLink.ts`) and the collapse/expand button pinned to its foot. It also owns the connection details modal (opened from the status bar), which carries the version and the update check — the sidebar itself has no footer, language or theme controls (those live in Settings → General / Appearance). The collapsed state is kept in localStorage; with none stored it starts collapsed below Tailwind's `lg`. Below `md` the Layout turns it into an off-canvas drawer (`drawer` prop): a modal dialog with the shared `useDialog` focus contract. + * @description The app's left sidebar: the brand, the navigation links and the workspace list, with Settings (on a workspace, that workspace's Settings tab — `lib/settingsLink.ts`) and the collapse/expand button pinned to its foot — in server mode with the signed-in account above them (its avatar, name and role, linking to Settings → Account, and Log out). It also owns the connection details modal (opened from the status bar), which carries the version and the update check — the sidebar itself has no footer, language or theme controls (those live in Settings → General / Appearance). The collapsed state is kept in localStorage; with none stored it starts collapsed below Tailwind's `lg`. Below `md` the Layout turns it into an off-canvas drawer (`drawer` prop): a modal dialog with the shared `useDialog` focus contract. * @author Nguyễn Ngọc Trí Vĩ */ /* ============================================================================= @@ -79,6 +79,7 @@ import type { LucideIcon } from "lucide-react"; import { api } from "../lib/api"; import { BRAND_LOGO, BRAND_MARK } from "../lib/brand"; import { useAuth, usePermissions } from "./AuthGate"; +import { UserAvatar } from "./users/UserAvatar"; import { eventBus } from "../lib/eventBus"; import { WorkspaceTree } from "./WorkspaceTree"; import type { UpdateStatusPayload, WSMessage } from "../lib/types"; @@ -540,6 +541,59 @@ export function Sidebar({ )} + {/* Server mode: who is signed in — avatar, name and role, opening + Settings → Account — and Log out. On the rail they stack. */} + {auth.state.mode === "server" && + auth.state.user && + (() => { + const user = auth.state.user; + const name = user.displayName || user.username; + const role = t(`users:roles.${user.role}`); + const signedIn = t("common:auth.signedInAs", { name, role }); + return ( +
+ + + {!collapsed && ( + + + {name} + + {role} + + )} + + +
+ ); + })()} + {/* Settings and the collapse toggle stick to the foot, out of the scrolling nav. On the rail they stack. */}
{!collapsed && {t("nav:settings")}} - {auth.state.mode === "server" && auth.state.user && ( - - )} + ))} +
+ ); +} + +/** The name field with the folder/command it becomes. */ +function SkillNameField({ + value, + onChange, + disabled, + required, +}: { + value: string; + onChange: (v: string) => void; + disabled?: boolean; + required?: boolean; +}) { + const { t } = useTranslation("artifacts"); + const slug = skillSlug(value); + return ( +
+ + {value.trim() && ( +

+ {slug ? t("skill.slugHint", { slug }) : t("skill.slugBad")} +

+ )} +
+ ); +} + +// ── Skill ───────────────────────────────────────────────────────────────── + +export function SkillAddPanel({ + lanes, + onAdded, + onClose, +}: { + lanes: Lane[]; + onAdded: () => void; + onClose: () => void; +}) { + const { t } = useTranslation("artifacts"); + const [mode, setMode] = useState<"upload" | "write" | "existing">("upload"); + const [busy, setBusy] = useState(false); + const [name, setName] = useState(""); + const [nameTouched, setNameTouched] = useState(false); + const [description, setDescription] = useState(""); + const [descTouched, setDescTouched] = useState(false); + const [title, setTitle] = useState(""); + const [content, setContent] = useState(""); + const [files, setFiles] = useState([]); + const [dragging, setDragging] = useState(false); + const [laneId, setLaneId] = useState(null); + const [skills, setSkills] = useState<{ name: string; from: "user" | "workspace" }[] | null>(null); + const fileInput = useRef(null); + const folderInput = useRef(null); + + // `webkitdirectory` is not in React's typings. + useEffect(() => { + folderInput.current?.setAttribute("webkitdirectory", ""); + }, [mode]); + + useEffect(() => { + if (mode !== "existing") return; + api.artifacts + .skills(laneId ?? undefined) + .then((r) => setSkills(r.items)) + .catch(() => setSkills([])); + }, [mode, laneId]); + + const shape = useMemo(() => describeUpload(files), [files]); + + // Fill the name and description from what was picked, unless typed. + useEffect(() => { + if (!files.length) return; + const md = + files.find((f) => isSkillMd(f.path) && !JUNK.test(f.path)) ?? + (shape.kind === "single-md" ? files.find((f) => !JUNK.test(f.path)) : undefined); + const stem = (p: string) => (p.split("/").pop() ?? "").replace(/\.[^.]+$/, ""); + let live = true; + void (async () => { + const text = md ? await readText(md.file) : ""; + if (!live) return; + const fmName = frontmatterField(text, "name"); + const fmDesc = frontmatterField(text, "description"); + if (!nameTouched) { + const guess = + shape.kind === "single-md" + ? fmName || stem(md?.path ?? "") + : shape.kind === "one" + ? fmName || shape.folder + : shape.kind === "archive" + ? stem(files[0]!.path) + : ""; + setName(guess); + } + if (!descTouched && fmDesc) setDescription(fmDesc); + })(); + return () => { + live = false; + }; + // Refill only when the pick changes, never on a typed name. + }, [files, shape]); + + const pick = (list: FileList | null) => { + if (!list?.length) return; + setFiles( + Array.from(list).map((file) => ({ + file, + path: (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name, + })) + ); + }; + + const run = async (fn: () => Promise) => { + setBusy(true); + try { + const n = await fn(); + toast.success(n > 1 ? t("skill.addedMany", { count: n }) : t("added")); + onAdded(); + onClose(); + } catch (err) { + toast.error(err); + } finally { + setBusy(false); + } + }; + + const many = shape.kind === "many"; + const total = files.reduce((n, f) => n + f.file.size, 0); + const meta = ( + <> +