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 = ( + <> +