Compare commits

...

6 Commits

Author SHA1 Message Date
nntrivi2001 b34b201bd1 chore: v2.8.75 — a page published from Claude's scratchpad keeps a viewable copy
- An artifact page published from the session's own scratchpad (where
  Claude Code's Artifact tool writes pages by default) now keeps a copy
  that viewers can preview here. Before, Preview said the file was gone.
2026-11-05 12:00:49 +07:00
nntrivi2001 c356cf96a1 fix(artifacts): a page published from its session's scratchpad keeps a viewable copy
The Artifact tool writes a page into the session's own Claude Code
scratchpad by default (<tmp>/claude-<uid>/<project>/<session>/scratchpad),
but pageCopy took the file only from the workspace folder, so such a page
got no copy and Preview said "the file ... is gone" while it was still there.

pageCopy now also accepts that ONE scratchpad, named from the publishing
session's id, its cwd and the workspace account's uid; before reading,
each of its four folders must be a real directory of that account, and each
file is realpath-confined, opened O_NOFOLLOW and must be a regular file of
that uid. Supporting files come only from the page's own root. The older-
page backfill (POST /api/artifacts/:id/copy) uses the session's cwd too.
The noCopy message now says the file may also lie outside both.
2026-11-05 11:20:34 +07:00
nntrivi2001 2a31537b80 chore: v2.8.74 — a member's chat no longer lags a turn behind the terminal
- Under server mode with per-user accounts, a member's Claude session chat
  showed the transcript as of the last import snapshot: transcript lookups
  used access(2), which cannot see past the member's 0700 projects folder.
  They now use stat, so the chat reads the live transcript again.
2026-11-05 09:39:30 +07:00
nntrivi2001 0cd853e330 fix(chat): a member's live transcript is found by stat, not access(2)
Under S3 the chat of a member's session lagged a whole turn behind the
terminal: claude-home.js located transcripts with fs.existsSync, which is
access(2) — run by the kernel with capabilities cleared — so the unit's
CAP_DAC_READ_SEARCH could not see past the member's 0700 ~/.claude/projects.
Every lookup fell back to the import-time snapshot, which is only refreshed
now and then. Transcript finders and the /transcripts route now use stat.
2026-11-05 09:29:58 +07:00
nntrivi2001 59533b997b chore: v2.8.73 — Artifacts folder bar restyled; hidden live profile gets a stand-in
- Artifacts: folders are rounded chips with a count badge; rename/delete are
  icons beside the picked folder (double-click renames); New folder is a
  dashed chip; the empty-folder hint mentions drag and drop.
- GET /api/pricing/quota: every Claude row carries its accountUuid.
- Settings → Profiles and the status bar's Usage panel: a profile hidden in
  this browser is left out of the Usage panel too; when the live login is
  hidden, the first visible profile is shown in its place (Logged in now,
  "in use", the % used chip with that profile's own usage). Display only.
2026-11-04 16:14:27 +07:00
nntrivi2001 e21120d51d chore: v2.8.72 — update survives npm-rewritten lockfiles; drag artifacts into folders
- update: before the --ff-only merge, put back package-lock.json /
  client/package-lock.json when they are the ONLY tracked changes (an
  earlier update's npm install rewrote them; v2.8.71 changed
  client/package-lock.json and the merge refused on such installs). Any
  other change still makes the merge refuse.
- Artifacts: drag a row or card onto a folder chip to put it there, onto
  "All folders" to take it out.
2026-11-04 15:27:48 +07:00
25 changed files with 782 additions and 159 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
# Claude Code Working Guide
_Last synced: v2.8.71 (2026-11-04) — artifacts shared with chosen people, per-account folders, and a viewable copy of every page._ 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.74 (2026-11-05) — a page published from its session's scratchpad keeps a viewable copy._ 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.
@@ -61,7 +61,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; a batch upload of many small files is ONE `extract` of a tar the server writes itself — folders and regular files only, names it checked, `tar -k`; a member's single file goes to a hidden temp name and is `write-as move`d onto its name only once every byte arrived, never written in place) — 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: `<data dir>/trash/<user id>/`), restorable 7 days. `write-as` is root-installed: a change to it needs `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`) — except its TRASH, whose restore/purge an admin may run (`TRASH_W` in `server/routes/homes.js`, the owner's trash via `trashKey`, audited with the item id); keep that exception to the trash routes only. A home's Trash is its USER's one recycle bin (`listUserTrash`: home-account store + host store `<data dir>/trash/<user id>/`, items tagged `store`). **An upload survives a dashboard restart because its bytes are staged first** (`server/lib/upload-staging.js`): parts go to `<data dir>/upload-staging/<user>/<id>/` on the HOST, keyed by the caller's user and a browser-made id (never a path), and `?staged=<id>` on `upload`/`upload-batch` writes them through the SAME code and guards — a commit's answer is stored and replayed, never written twice. Do not add a staged write that skips `upload`/`uploadBatch`, nor stage inside a member's home. 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-<id>` 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 `<data dir>/artifacts/<random>/` and every viewer reads only that copy (an owner's edit of a skill — in the Hub, or in the skill's source folder, which the server takes when the Hub is listed and an installer's differing copy is then offered **Update** — writes a NEW snapshot and drops the old one — never a write into the one being read; the newer of the two changes wins; **Share again** (`POST /api/artifacts/:id/reshare`) takes the owner's folder on the server — else a user-scope plugin's same-named skill — on a click, and an upload named like the uploader's own skill, or carrying `id`, replaces that row's snapshot instead of adding a twin) — `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 those shared with everyone (`shared`, the Hub) or with them by name (`artifact_shares`, **Shared with you** — the name list only its owner or an admin sees); only the owner or an admin changes one, and Share again never widens a by-name share to everyone. Artifact folders are the viewer's own (`agenthub.artifacts.folders` preference), never an artifact column. A `page` keeps a COPY anyone it reaches can view without the publishing claude.ai account — its HTML file (and the files the tool named) snapshotted at publish, or the `artifacts-export/<artifact id>.*` Claude saves for a Doc / Slides / Design (`scripts/artifact-export-hint.js`, the one hook that answers Claude: synchronous, matched to those tools only, silent outside AgentHub panes) attached at Stop; it is viewed in `HtmlFrame`'s sandbox or drawn by pdf.js, never fetched from claude.ai — do not add a route that proxies or logs into claude.ai for it. Install writes a skill into BOTH `~/.claude/skills/` and the cross-tool `~/.agents/skills/` of every account the CALLER's sessions run as (`installAccounts`: their S3 member home, plus the host's for an admin, whose host-folder workspaces run as the host; 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`; Uninstall (`POST /api/artifacts/:id/uninstall`) takes it out of the same accounts — a skill MOVED to `agenthub-replaced-skills/`, never deleted, a plugin `claude plugin uninstall --scope user --keep-data`; 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.
- **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 `<data dir>/artifacts/<random>/` and every viewer reads only that copy (an owner's edit of a skill — in the Hub, or in the skill's source folder, which the server takes when the Hub is listed and an installer's differing copy is then offered **Update** — writes a NEW snapshot and drops the old one — never a write into the one being read; the newer of the two changes wins; **Share again** (`POST /api/artifacts/:id/reshare`) takes the owner's folder on the server — else a user-scope plugin's same-named skill — on a click, and an upload named like the uploader's own skill, or carrying `id`, replaces that row's snapshot instead of adding a twin) — `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 those shared with everyone (`shared`, the Hub) or with them by name (`artifact_shares`, **Shared with you** — the name list only its owner or an admin sees); only the owner or an admin changes one, and Share again never widens a by-name share to everyone. Artifact folders are the viewer's own (`agenthub.artifacts.folders` preference), never an artifact column. A `page` keeps a COPY anyone it reaches can view without the publishing claude.ai account — its HTML file (and the files the tool named) snapshotted at publish from the workspace folder or the publishing session's own Claude Code scratchpad (`<tmp>/claude-<uid>/<project>/<session id>/scratchpad`, its folders real dirs of the workspace's account, each file opened `O_NOFOLLOW` and owned by it — never any other path), or the `artifacts-export/<artifact id>.*` Claude saves for a Doc / Slides / Design (`scripts/artifact-export-hint.js`, the one hook that answers Claude: synchronous, matched to those tools only, silent outside AgentHub panes) attached at Stop; it is viewed in `HtmlFrame`'s sandbox or drawn by pdf.js, never fetched from claude.ai — do not add a route that proxies or logs into claude.ai for it. Install writes a skill into BOTH `~/.claude/skills/` and the cross-tool `~/.agents/skills/` of every account the CALLER's sessions run as (`installAccounts`: their S3 member home, plus the host's for an admin, whose host-folder workspaces run as the host; 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`; Uninstall (`POST /api/artifacts/:id/uninstall`) takes it out of the same accounts — a skill MOVED to `agenthub-replaced-skills/`, never deleted, a plugin `claude plugin uninstall --scope user --keep-data`; 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 `<data dir>/workspace-context/<lane id>/` 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 at every spawn as the ONLY file `CLAUDE.md` of `<data dir>/workspace-prompts/<lane id>/` (a member's: `prompt-<lane id>/` in their handoff dir, via `write-as`), which goes in as one more `--add-dir` with `CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD=1` in the pane env — never `--append-system-prompt-file`: a custom system prompt is a launch flag Claude cannot carry over a restart, so it refused `/tui` in every pane with instructions (side effect: a CLAUDE.md in an extra folder is now read too); Codex gets one `--add-dir` per directory and `-c developer_instructions=<JSON string>` — 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 <uuid>` (`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=<id>` — 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. Billing has one more owner: a session NO workspace owns belongs to the user of the Linux account it ran as (`sessions.account` — the hook's peer, else its folder; `server/lib/session-account.js`, the host's account → the admin marked `users.host_account`, else the only admin), so a script's `claude -p` counts in Today, the Users page and spend quotas — a workspace's ownership always wins over it.
- **A session's worktree is `claude --worktree`'s, resolved from the session row — and removing it never forces.** A Worktree start passes `--worktree=<server-made name>` (the `=` form: `-w [name]` eats the prompt otherwise) and Claude Code makes, populates and cleans up `<repo>/.claude/worktrees/<name>`; 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.
+44 -2
View File
@@ -29,7 +29,7 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { Fragment, useCallback, useEffect, useRef, useState } from "react";
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { Link, useLocation } from "react-router-dom";
@@ -38,6 +38,12 @@ import { api, type RunHandle, type UserQuotaStatus } from "../lib/api";
import type { BudgetStatus, CostResult, ToolQuota } from "../lib/types";
import { fmt, fmtCostFull, formatDateTime, formatModelName, timeAgo } from "../lib/format";
import { AiToolIcon } from "./AiToolIcon";
import {
HIDDEN_PROFILES_EVENT,
isHiddenProfilesKey,
readHiddenProfiles,
standIn,
} from "../lib/hiddenProfilesStorage";
import { useLiveRefresh } from "../hooks/useLiveRefresh";
import { useSpin } from "../hooks/useSpin";
import { sessionChangeMatcher } from "../lib/sessionChange";
@@ -276,10 +282,44 @@ function QuotaPanel({
);
}
/** The profiles hidden in Settings → AI tools → Profiles, kept current. */
function useHiddenProfiles(): string[] {
const [hidden, setHidden] = useState<string[]>(readHiddenProfiles);
useEffect(() => {
const again = () => setHidden(readHiddenProfiles());
const onStorage = (e: StorageEvent) => isHiddenProfilesKey(e.key) && again();
window.addEventListener(HIDDEN_PROFILES_EVENT, again);
window.addEventListener("storage", onStorage);
return () => {
window.removeEventListener(HIDDEN_PROFILES_EVENT, again);
window.removeEventListener("storage", onStorage);
};
}, []);
return hidden;
}
/**
* Claude rows without the hidden profiles. When the live login is one of
* them, the first visible profile stands in as the live row — its own usage
* on the chip and the panel — so the panel never names the hidden account.
*/
export function withoutHidden(tools: ToolQuota[], hidden: string[]): ToolQuota[] {
if (!hidden.length) return tools;
const claude = tools.filter((q) => q.id === "claude");
const isLive = (q: ToolQuota) => q.active !== false;
const decoy = standIn(claude, isLive, hidden);
const keep = (q: ToolQuota) =>
q.id !== "claude" || !q.accountUuid || !hidden.includes(q.accountUuid);
const shown = tools.filter(keep);
if (!decoy) return shown;
// The stand-in leads the Claude rows, as the live login does.
return [{ ...decoy, active: true }, ...shown.filter((q) => q !== decoy)];
}
/** The right-end quota chips; nothing at all until a tool reports one. */
function QuotaChips() {
const { t } = useTranslation("nav");
const [tools, setTools] = useState<ToolQuota[]>([]);
const [fetched, setTools] = useState<ToolQuota[]>([]);
const [open, setOpen] = useState(false);
// Leaving the chips closes the panel only after a beat, and entering the
// panel cancels that: the pointer crosses the gap to read it.
@@ -303,6 +343,8 @@ function QuotaChips() {
);
const [refreshing, refresh] = useSpin(() => load(true));
useVisiblePoll(load, QUOTA_POLL_MS, [load]);
const hidden = useHiddenProfiles();
const tools = useMemo(() => withoutHidden(fetched, hidden), [fetched, hidden]);
if (!tools.length) return null;
return (
<>
@@ -162,7 +162,7 @@ vi.mock("../../lib/api", () => ({
}));
import { api } from "../../lib/api";
import { StatusBar } from "../StatusBar";
import { StatusBar, withoutHidden } from "../StatusBar";
import { LIVE_RUNS_EVENT } from "../WorkspaceTree";
const at = (url: string, ui: React.JSX.Element) =>
@@ -307,3 +307,35 @@ describe("StatusBar", () => {
expect(api.pricing.quota).toHaveBeenLastCalledWith(true);
});
});
describe("withoutHidden", () => {
const row = (accountUuid: string, active: boolean, pct: number) => ({
id: "claude" as const,
active,
accountUuid,
account: `${accountUuid}@x.y`,
windows: [{ label: "5h", usedPercent: pct, resetsAt: null }],
});
const codex = { id: "codex" as const, active: true, windows: [] };
it("leaves the list alone when nothing is hidden", () => {
const list = [row("u1", true, 82), row("u2", false, 19), codex];
expect(withoutHidden(list, [])).toBe(list);
});
it("drops a hidden saved profile", () => {
const out = withoutHidden([row("u1", true, 82), row("u2", false, 19), codex], ["u2"]);
expect(out.map((q) => q.accountUuid ?? q.id)).toEqual(["u1", "codex"]);
});
it("puts the first visible profile, with its own usage, in place of a hidden live login", () => {
const out = withoutHidden(
[row("u1", true, 82), row("u2", false, 19), row("u3", false, 0), codex],
["u1"]
);
expect(out.map((q) => q.accountUuid ?? q.id)).toEqual(["u2", "u3", "codex"]);
expect(out[0]?.active).toBe(true);
expect(out[0]?.windows[0]?.usedPercent).toBe(19);
expect(out[1]?.active).toBe(false);
});
});
@@ -41,6 +41,7 @@ import { toast } from "../../lib/toast";
import {
profileKey,
readHiddenProfiles,
standIn,
toggleHidden,
writeHiddenProfiles,
} from "../../lib/hiddenProfilesStorage";
@@ -526,6 +527,17 @@ function ClaudeProfiles({ pooled, local }: { pooled: boolean; local: boolean })
writeHiddenProfiles(next);
};
const activeHidden = !!active && !reveal && isHidden(active);
/** Shown as the live login while the real one is hidden: the first visible
* profile, with its own usage — a "—" there would only draw the eye. */
const decoy =
activeHidden && items
? standIn(
items.map((p) => ({ ...p, accountUuid: p.accountUuid ?? null })),
(p) => p.active || (!!active?.accountUuid && p.accountUuid === active.accountUuid),
hidden
)
: null;
const shownActive = (p: CcProfile) => (decoy ? p.name === decoy.name : p.active);
const load = useCallback(async () => {
try {
@@ -622,7 +634,9 @@ function ClaudeProfiles({ pooled, local }: { pooled: boolean; local: boolean })
<span>
{t("profiles.mismatch", {
config: mismatchEmail,
owner: activeHidden ? "?" : active?.email || active?.accountUuid || "?",
owner: activeHidden
? decoy?.email || "?"
: active?.email || active?.accountUuid || "?",
})}
</span>
</p>
@@ -639,7 +653,16 @@ function ClaudeProfiles({ pooled, local }: { pooled: boolean; local: boolean })
{t("profiles.activeLabel")}
</div>
{active && activeHidden ? (
<div className="text-sm text-fg-muted">—</div>
decoy ? (
<>
<div className="text-sm text-fg-primary">
{decoy.email || decoy.displayName || decoy.accountUuid}
</div>
<IdentityLine identity={decoy} />
</>
) : (
<div className="text-sm text-fg-muted">{t("profiles.noActive")}</div>
)
) : active ? (
<>
<div className="text-sm text-fg-primary">
@@ -650,7 +673,7 @@ function ClaudeProfiles({ pooled, local }: { pooled: boolean; local: boolean })
) : (
<div className="text-sm text-fg-muted">{t("profiles.noActive")}</div>
)}
{active && !activeSaved && (
{active && !activeSaved && !activeHidden && (
<div className="pt-1.5 space-y-1.5">
<div className="text-[11px] text-status-warning">{t("profiles.unsavedActive")}</div>
<button
@@ -673,71 +696,75 @@ function ClaudeProfiles({ pooled, local }: { pooled: boolean; local: boolean })
{t("profiles.empty")}
</div>
)}
{items?.filter((p) => reveal || !isHidden(p)).map((p) => (
<div
key={p.name}
data-testid={`profile-row-${p.name}`}
onContextMenu={(e) => ctx.open(e, p)}
onClickCapture={(e) => toggleRow(e, p)}
className={`${rowCls}${isHidden(p) ? " opacity-50" : ""}`}
>
<div className="flex items-center gap-2 flex-wrap">
<LabelEdit
label={p.label}
busy={busy !== null}
editing={renaming === p.name}
setEditing={(on) => setRenaming(on ? p.name : null)}
onRename={(next) =>
void run(
`rename:${p.name}`,
() => api.ccConfig.renameProfile(p.name, next),
t("profiles.renameSuccess", { name: next })
)
}
{items
?.filter((p) => reveal || !isHidden(p))
.map((p) => (
<div
key={p.name}
data-testid={`profile-row-${p.name}`}
onContextMenu={(e) => ctx.open(e, p)}
onClickCapture={(e) => toggleRow(e, p)}
className={`${rowCls}${isHidden(p) ? " opacity-50" : ""}`}
>
<div className="flex items-center gap-2 flex-wrap">
<LabelEdit
label={p.label}
busy={busy !== null}
editing={renaming === p.name}
setEditing={(on) => setRenaming(on ? p.name : null)}
onRename={(next) =>
void run(
`rename:${p.name}`,
() => api.ccConfig.renameProfile(p.name, next),
t("profiles.renameSuccess", { name: next })
)
}
/>
{shownActive(p) && <ActiveBadge />}
<span className="text-xs text-fg-secondary truncate flex-1 min-w-0">
{p.email || p.displayName || p.accountUuid}
</span>
{!shownActive(p) && (
<button disabled={busy !== null} onClick={() => switchTo(p)} className={btnCls}>
{busy === `switch:${p.name}`
? t("profiles.switching")
: t("profiles.switchButton")}
</button>
)}
{pooled && <MoveToPool tool="claude" name={p.name} />}
{deletable(p) && (
<button
type="button"
disabled={busy !== null}
onClick={() => void remove(p)}
title={t("profiles.deleteButton")}
aria-label={t("profiles.deleteButton")}
className="h-7 w-7 inline-flex items-center justify-center rounded-md text-fg-muted hover:text-status-danger hover:bg-surface-3 disabled:opacity-50"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
<IdentityLine identity={p} />
<ProfileUsageLine
usage={usage[p.name]}
error={usageError[p.name]}
busy={!!usageBusy[p.name]}
disabled={busy !== null || !!usageBusy[p.name]}
onCheck={() => void checkUsage(p.name)}
/>
{p.active && <ActiveBadge />}
<span className="text-xs text-fg-secondary truncate flex-1 min-w-0">
{p.email || p.displayName || p.accountUuid}
</span>
{!p.active && (
<button disabled={busy !== null} onClick={() => switchTo(p)} className={btnCls}>
{busy === `switch:${p.name}` ? t("profiles.switching") : t("profiles.switchButton")}
</button>
)}
{pooled && <MoveToPool tool="claude" name={p.name} />}
{deletable(p) && (
<button
type="button"
disabled={busy !== null}
onClick={() => void remove(p)}
title={t("profiles.deleteButton")}
aria-label={t("profiles.deleteButton")}
className="h-7 w-7 inline-flex items-center justify-center rounded-md text-fg-muted hover:text-status-danger hover:bg-surface-3 disabled:opacity-50"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
{p.savedAt && (
<div className="text-[10px] text-fg-muted mt-1">
{t("profiles.savedAt", { time: new Date(p.savedAt).toLocaleString() })}
</div>
)}
</div>
<IdentityLine identity={p} />
<ProfileUsageLine
usage={usage[p.name]}
error={usageError[p.name]}
busy={!!usageBusy[p.name]}
disabled={busy !== null || !!usageBusy[p.name]}
onCheck={() => void checkUsage(p.name)}
/>
{p.savedAt && (
<div className="text-[10px] text-fg-muted mt-1">
{t("profiles.savedAt", { time: new Date(p.savedAt).toLocaleString() })}
</div>
)}
</div>
))}
))}
{ctx.menu && (
<ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="profile-menu">
<MenuItem onSelect={pick((p) => setRenaming(p.name))}>{t("profiles.rename")}</MenuItem>
{!ctx.menu.item.active && (
{!shownActive(ctx.menu.item) && !ctx.menu.item.active && (
<MenuItem disabled={busy !== null} onSelect={pick(switchTo)}>
{t("profiles.switchButton")}
</MenuItem>
@@ -160,6 +160,19 @@ describe("ToolProfilesSettings — Claude", () => {
expect(screen.getByTestId("profile-row-spare")).toBeInTheDocument();
});
it("shows the first visible profile as the live login while the live one is hidden", async () => {
localStorage.setItem("agenthub.cc.hiddenProfiles", JSON.stringify(["u1"]));
await show();
await settle();
expect(screen.queryByTestId("profile-row-work")).toBeNull();
expect(screen.queryByText("a@b.c")).toBeNull();
expect(screen.queryByText("—")).toBeNull();
const spare = screen.getByTestId("profile-row-spare");
expect(spare).toHaveTextContent(/active/);
expect(screen.queryByRole("button", { name: "Switch to" })).toBeNull();
expect(screen.getAllByText("d@e.f").length).toBe(2);
});
it("does not hide anything in server mode", async () => {
perms.local = false;
localStorage.setItem("agenthub.cc.hiddenProfiles", JSON.stringify(["u2"]));
+7 -3
View File
@@ -262,7 +262,7 @@
"folders.delete": "Delete folder",
"folders.deleteTitle": "Delete the folder “{{name}}”?",
"folders.deleteBody": "Only the folder goes; its artifacts stay where they are.",
"folders.empty": "This folder is empty. Put an artifact here with ⋯ → Move to folder.",
"folders.empty": "This folder is empty. Drag an artifact onto the folder, or use ⋯ → Move to folder.",
"folders.move": "Move to folder…",
"folders.moveTitle": "Move “{{title}}” to a folder",
"folders.private": "Your folders are yours alone: nobody else sees them.",
@@ -270,10 +270,14 @@
"folders.takeOut": "Take it out of its folder",
"page.view": "View",
"page.viewHint": "Its copy, viewable here without the claude.ai account that published it",
"page.noCopy": "No viewable copy: the file this page was published from is gone. Open it on claude.ai instead.",
"page.noCopy": "No viewable copy: the file this page was published from is gone, or lies outside this workspace and its session's scratchpad. Open it on claude.ai instead.",
"pdf.page": "Page {{n}}",
"pdf.failed": "This PDF could not be shown. Download it instead.",
"pdf.more": "Showing the first {{shown}} of {{total}} pages — download it for the rest.",
"exportsSwitch": "Keep a viewable copy of Docs and Slides",
"exportsSwitchHint": "When Claude publishes a Claude Docs doc, a deck or a design from an AgentHub session, it is asked to also save a copy (Markdown, PDF or HTML) that the Hub shows to the people it is shared with — they need no claude.ai access. Applies to sessions started afterwards."
"exportsSwitchHint": "When Claude publishes a Claude Docs doc, a deck or a design from an AgentHub session, it is asked to also save a copy (Markdown, PDF or HTML) that the Hub shows to the people it is shared with — they need no claude.ai access. Applies to sessions started afterwards.",
"folders.moved": "Moved to “{{name}}”",
"folders.takenOut": "Taken out of its folder",
"folders.dropHint": "Drop an artifact here to put it in this folder",
"folders.dropOutHint": "Drop an artifact here to take it out of its folder"
}
+7 -3
View File
@@ -260,7 +260,7 @@
"folders.delete": "Xóa thư mục",
"folders.deleteTitle": "Xóa thư mục “{{name}}”?",
"folders.deleteBody": "Chỉ xóa thư mục; các artifact bên trong vẫn giữ nguyên.",
"folders.empty": "Thư mục này trống. Đưa artifact vào bằng ⋯ → Chuyển vào thư mục.",
"folders.empty": "Thư mục này trống. Kéo artifact thả vào thư mục, hoặc dùng ⋯ → Chuyển vào thư mục.",
"folders.move": "Chuyển vào thư mục…",
"folders.moveTitle": "Chuyển “{{title}}” vào thư mục",
"folders.private": "Thư mục là của riêng bạn: người khác không thấy.",
@@ -268,10 +268,14 @@
"folders.takeOut": "Bỏ khỏi thư mục",
"page.view": "Xem",
"page.viewHint": "Bản sao xem được ngay tại đây, không cần tài khoản claude.ai đã tạo trang",
"page.noCopy": "Chưa có bản xem: file gốc của trang này không còn. Hãy mở trên claude.ai.",
"page.noCopy": "Chưa có bản xem: file gốc của trang này không còn, hoặc nằm ngoài workspace và scratchpad của phiên. Hãy mở trên claude.ai.",
"pdf.page": "Trang {{n}}",
"pdf.failed": "Không hiển thị được PDF này. Hãy tải về.",
"pdf.more": "Đang hiện {{shown}}/{{total}} trang đầu — tải về để xem hết.",
"exportsSwitch": "Giữ bản xem được của Docs và Slides",
"exportsSwitchHint": "Khi Claude tạo một Claude Docs, slide hoặc design trong phiên AgentHub, Claude được nhắc lưu thêm một bản sao (Markdown, PDF hoặc HTML) để Hub hiển thị cho người được chia sẻ — họ không cần quyền trên claude.ai. Áp dụng cho các phiên mở sau đó."
"exportsSwitchHint": "Khi Claude tạo một Claude Docs, slide hoặc design trong phiên AgentHub, Claude được nhắc lưu thêm một bản sao (Markdown, PDF hoặc HTML) để Hub hiển thị cho người được chia sẻ — họ không cần quyền trên claude.ai. Áp dụng cho các phiên mở sau đó.",
"folders.moved": "Đã chuyển vào “{{name}}”",
"folders.takenOut": "Đã bỏ khỏi thư mục",
"folders.dropHint": "Thả artifact vào đây để đưa vào thư mục này",
"folders.dropOutHint": "Thả artifact vào đây để bỏ khỏi thư mục"
}
+38 -1
View File
@@ -4,12 +4,17 @@
* AI tools → Profiles, kept in localStorage with the same read/validate/
* fallback convention as laneOrderStorage.ts. A viewer preference only: the
* profile files, the API and the database are untouched, and another browser
* still lists every profile.
* still lists every profile. The status bar's Usage panel reads the same list
* (`HIDDEN_PROFILES_EVENT` tells it of a change in this tab), and when the
* live login is hidden both show the first visible profile in its place
* (`standIn`).
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const STORAGE_KEY = "agenthub.cc.hiddenProfiles";
/** Fired on `window` after this tab changes the list. */
export const HIDDEN_PROFILES_EVENT = "agenthub:hidden-profiles";
/** A profile's stable key: its account (survives a rename), else its file name. */
export function profileKey(p: { accountUuid: string | null; name?: string }): string {
@@ -34,6 +39,38 @@ export function writeHiddenProfiles(keys: string[]): void {
} catch {
/* ignore quota / disabled storage */
}
try {
window.dispatchEvent(new Event(HIDDEN_PROFILES_EVENT));
} catch {
/* no window (tests outside jsdom) */
}
}
/** True when `key` names a change to the hidden list (a `storage` event's key). */
export const isHiddenProfilesKey = (key: string | null) => key === STORAGE_KEY || key === null;
/**
* The entry shown in place of a hidden live login: the first one in `list`
* that is not hidden, or null when the live one is visible (or none is left).
*/
export function standIn<T extends { accountUuid?: string | null; name?: string }>(
list: T[],
isActive: (x: T) => boolean,
hidden: string[]
): T | null {
const live = list.find(isActive);
if (
!live ||
!hidden.includes(profileKey({ accountUuid: live.accountUuid ?? null, name: live.name }))
)
return null;
return (
list.find(
(x) =>
!isActive(x) &&
!hidden.includes(profileKey({ accountUuid: x.accountUuid ?? null, name: x.name }))
) ?? null
);
}
/** `keys` with `key` added when absent, removed when present. */
+2
View File
@@ -1240,6 +1240,8 @@ export interface ToolQuota {
active?: boolean;
/** The saved profile's label, when the account has one. */
label?: string | null;
/** Claude: the Anthropic account the row is about (hides a profile hidden in Settings). */
accountUuid?: string | null;
/** The signed-in account's email, when the tool's login names one. */
account?: string | null;
/** The subscription's name as the tool reports it (`pro`, `max`, `plus`, `team`…). */
+106 -38
View File
@@ -65,9 +65,11 @@ import {
LayoutGrid,
List,
MoreHorizontal,
Pencil,
Puzzle,
Search,
Sparkles,
Trash2,
X,
} from "lucide-react";
import {
@@ -131,6 +133,17 @@ const PREVIEWABLE =
const PDF = /\.pdf$/i;
/** What a row can do, worked out once for its card, menu and detail sheet. */
/** What a dragged artifact carries: its id, for a folder chip to take. */
const DRAG_TYPE = "application/x-agenthub-artifact";
const dragProps = (a: Artifact) => ({
draggable: true,
onDragStart: (e: React.DragEvent) => {
e.dataTransfer.setData(DRAG_TYPE, String(a.id));
e.dataTransfer.setData("text/plain", a.title);
e.dataTransfer.effectAllowed = "move";
},
});
/** Shared at all: with everyone (the Hub) or with chosen people. */
const isShared = (a: Artifact) => a.shared || (a.sharedWith?.length ?? 0) > 0;
@@ -834,6 +847,11 @@ export function Artifacts() {
active={activeFolder}
counts={(id) => (loaded ?? []).filter((a) => folders.of[a.id] === id).length}
onPick={setFolder}
onDropItem={(id, fid) => {
moveToFolder(id, fid);
const f = fid ? folders.folders.find((x) => x.id === fid) : null;
toast.success(f ? t("folders.moved", { name: f.name }) : t("folders.takenOut"));
}}
/>
{items === null ? (
@@ -1539,6 +1557,7 @@ function ArtifactRow({ inPack, ...props }: ItemProps & { inPack?: boolean }) {
className="flex items-center gap-3 px-3 py-2.5 transition-colors hover:bg-surface-2/60"
data-testid={`artifact-${a.id}`}
onContextMenu={(e) => menu.open(e, null)}
{...dragProps(a)}
>
<KindIcon kind={a.kind} />
<button
@@ -1593,6 +1612,7 @@ function ArtifactCard({ as = "li", ...props }: ItemProps & { as?: "li" | "div" }
className="card flex flex-col gap-2.5 p-4 transition-colors hover:border-border-light"
data-testid={`artifact-${a.id}`}
onContextMenu={(e: React.MouseEvent) => menu.open(e, null)}
{...dragProps(a)}
>
<div className="flex items-start gap-3">
<KindIcon kind={a.kind} />
@@ -2492,13 +2512,35 @@ function FolderBar({
active,
counts,
onPick,
onDropItem,
}: {
folders: { id: string; name: string }[];
active: string;
counts: (id: string) => number;
onPick: (id: string) => void;
/** An artifact dropped on a chip: that folder, or (null, "All folders") none. */
onDropItem: (itemId: number, folderId: string | null) => void;
}) {
const { t } = useTranslation("artifacts");
// The chip an artifact is dragged over ("" = All folders), lit as a target.
const [over, setOver] = useState<string | null>(null);
const dropProps = (target: string) => ({
onDragOver: (e: React.DragEvent) => {
if (!e.dataTransfer.types.includes(DRAG_TYPE)) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
if (over !== target) setOver(target);
},
onDragLeave: () => setOver((cur) => (cur === target ? null : cur)),
onDrop: (e: React.DragEvent) => {
setOver(null);
const id = Number(e.dataTransfer.getData(DRAG_TYPE));
if (!Number.isInteger(id) || id <= 0) return;
e.preventDefault();
onDropItem(id, target || null);
},
});
const lit = (target: string) => (over === target ? " ring-2 ring-accent bg-accent-muted" : "");
// `new`: naming a new folder; a folder id: renaming that one.
const [editing, setEditing] = useState<"new" | string | null>(null);
const [name, setName] = useState("");
@@ -2524,36 +2566,80 @@ function FolderBar({
if (active === f.id) onPick("");
};
const chip = (on: boolean) =>
`inline-flex items-center gap-1 rounded-md px-2.5 py-1 text-xs whitespace-nowrap transition-colors ${
on ? "bg-surface-4 text-fg-primary" : "text-fg-muted hover:text-fg-secondary"
`inline-flex h-7 items-center gap-1.5 rounded-full border px-3 text-xs whitespace-nowrap transition-colors ${
on
? "border-accent/40 bg-accent-muted text-fg-primary"
: "border-border text-fg-secondary hover:border-border-light hover:bg-surface-2 hover:text-fg-primary"
}`;
const activeFolder = folders.find((f) => f.id === active);
const count = (n: number, on: boolean) => (
<span
className={`min-w-[1.25rem] rounded-full px-1.5 text-center text-[10px] leading-4 tabular-nums ${
on ? "bg-accent/20 text-accent" : "bg-surface-3 text-fg-muted"
}`}
>
{n}
</span>
);
const iconBtn =
"inline-flex h-7 w-7 items-center justify-center rounded-full text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary";
return (
<div className="flex flex-wrap items-center gap-1" data-testid="artifact-folders">
<Folder className="mr-1 h-3.5 w-3.5 text-fg-muted" aria-hidden />
<div className="flex flex-wrap items-center gap-1.5" data-testid="artifact-folders">
<button
type="button"
aria-pressed={!active}
className={chip(!active)}
className={chip(!active) + lit("")}
onClick={() => onPick("")}
title={folders.length ? t("folders.dropOutHint") : undefined}
{...dropProps("")}
>
<Folder className="h-3.5 w-3.5" aria-hidden />
{t("folders.all")}
</button>
{folders.map((f) => (
<button
key={f.id}
type="button"
aria-pressed={active === f.id}
className={chip(active === f.id)}
onClick={() => onPick(f.id)}
>
{f.name}
<span className="text-fg-muted">{counts(f.id)}</span>
</button>
))}
{folders.map((f) => {
const on = active === f.id;
return editing === f.id ? null : (
<span key={f.id} className="inline-flex items-center">
<button
type="button"
aria-pressed={on}
className={chip(on) + lit(f.id)}
onClick={() => onPick(f.id)}
onDoubleClick={() => begin(f.id, f.name)}
title={t("folders.dropHint")}
data-testid={`artifact-folder-${f.id}`}
{...dropProps(f.id)}
>
<span className="max-w-[12rem] truncate">{f.name}</span>
{count(counts(f.id), on)}
</button>
{on && !editing && (
<>
<button
type="button"
className={`${iconBtn} ml-0.5`}
onClick={() => begin(f.id, f.name)}
title={t("folders.rename")}
aria-label={t("folders.rename")}
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
type="button"
className={`${iconBtn} hover:!text-status-danger`}
onClick={() => void remove(f)}
title={t("folders.delete")}
aria-label={t("folders.delete")}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</>
)}
</span>
);
})}
{editing ? (
<input
className="input h-7 w-40 text-xs"
className="input h-7 w-44 rounded-full px-3 text-xs"
autoFocus
maxLength={MAX_FOLDER_NAME}
aria-label={t(editing === "new" ? "folders.newName" : "folders.rename")}
@@ -2574,7 +2660,7 @@ function FolderBar({
) : (
<button
type="button"
className={chip(false)}
className="inline-flex h-7 items-center gap-1 rounded-full border border-dashed border-border px-3 text-xs text-fg-muted transition-colors hover:border-accent/50 hover:text-fg-primary"
onClick={() => begin("new")}
data-testid="artifact-folder-new"
>
@@ -2582,24 +2668,6 @@ function FolderBar({
{t("folders.new")}
</button>
)}
{activeFolder && !editing && (
<span className="ml-1 flex gap-1">
<button
type="button"
className="btn-ghost px-2 py-1 text-xs"
onClick={() => begin(activeFolder.id, activeFolder.name)}
>
{t("folders.rename")}
</button>
<button
type="button"
className="btn-ghost px-2 py-1 text-xs text-status-danger"
onClick={() => void remove(activeFolder)}
>
{t("folders.delete")}
</button>
</span>
)}
</div>
);
}
@@ -506,6 +506,43 @@ describe("Artifacts", () => {
localStorage.removeItem("agenthub.artifacts.folders");
});
it("drags an artifact onto a folder to put it there, and onto All folders to take it out", async () => {
localStorage.setItem(
"agenthub.artifacts.folders",
JSON.stringify({ folders: [{ id: "f1", name: "Slides" }], of: {} })
);
open();
const row = await screen.findByTestId("artifact-2");
expect(row).toHaveAttribute("draggable", "true");
const data = new Map<string, string>();
const dataTransfer = {
setData: (k: string, v: string) => data.set(k, v),
getData: (k: string) => data.get(k) ?? "",
get types() {
return [...data.keys()];
},
effectAllowed: "",
dropEffect: "",
};
const stored = () => JSON.parse(localStorage.getItem("agenthub.artifacts.folders")!);
fireEvent.dragStart(row, { dataTransfer });
const chip = screen.getByTestId("artifact-folder-f1");
fireEvent.dragOver(chip, { dataTransfer });
expect(chip.className).toMatch(/ring-2/);
fireEvent.drop(chip, { dataTransfer });
expect(stored().of["2"]).toBe("f1");
expect(chip.className).not.toMatch(/ring-2/);
// Something that is not an artifact (a file from the desktop) is ignored.
fireEvent.drop(chip, {
dataTransfer: { ...dataTransfer, getData: () => "", types: ["Files"] },
});
expect(stored().of["2"]).toBe("f1");
fireEvent.dragStart(row, { dataTransfer });
fireEvent.drop(screen.getByRole("button", { name: "All folders" }), { dataTransfer });
expect(stored().of["2"]).toBeUndefined();
localStorage.removeItem("agenthub.artifacts.folders");
});
it("keeps a shared row in the Hub when its owner deletes it, and removes it from there on ask", async () => {
vi.mocked(api.artifacts.settings).mockResolvedValue({
serverMode: true,
@@ -7316,7 +7316,7 @@ exports[`screen snapshots > Settings — about 1`] = `
<span
class="text-status-warning/90"
>
UI build v2.8.71 (rebuild client to match)
UI build v2.8.75 (rebuild client to match)
</span>
</p>
</div>
+2 -2
View File
@@ -2961,7 +2961,7 @@ curl "http://localhost:4820/api/pricing/cost?tz_offset=-420&from=2026-09-01&to=2
GET /api/pricing/quota[?force=1]
```
How much of each native tool's plan quota is spent: `{ tools: [{ id: "claude"|"codex"|"antigravity"|"cursor", active, label?, account?, plan?, expiresAt?, windows: [{ label, usedPercent, resetsAt, group? }], error?, asOf? }], checkedAt }` (`server/lib/quota.js`). **Claude** asks Anthropic's `GET /api/oauth/usage` with the access token the CLI holds right now — never refreshed, so an expired token answers `error: "ELIVEEXPIRED"` until Claude Code refreshes it — cached 5 min (`force=1`, the status bar's click, re-asks at most every 30 s); no OAuth login, no entry. That live account is `active: true`; every OTHER saved auth profile follows as `active: false` with its `label`, asked with its OWN stored token only while that token is valid — never refreshed here, since a refresh rotates the saved credentials (only the Profiles modal's **Check quota** does that) — and, once expired, showing the last figure this server read (`asOf`) or `error: "EPROFILEEXPIRED"`. Each Claude token's owner is also asked (`GET /api/oauth/profile`): the live row carries the label and email of the account its token belongs to (`.claude.json` can name another), and a saved profile whose token belongs to a different account answers `error: "EWRONGACCOUNT"` with no windows, never that other account's usage. **Codex** reads the newest `rate_limits` from its rollout journals, no network (`asOf` = when the journal recorded it; a window past its reset reads 0). **Antigravity** runs the catalog-detected `agy` as `agy --print /usage --output-format json` — the CLI's own `/usage` command, no model call (`num_turns` 0, no conversation) — in a private empty temp directory with a 60 s timeout, only while the tool is enabled in AI Tools and `agy --version` is ≥ 1.1.11 (an older agy may bill `/usage` as a model request); cached like Claude, a failed run shows the last figure (`asOf`) or `error: "EUPSTREAM"`. One window per pool bucket (`5h`, `wk`), `group` naming the pool ("Gemini Models", "Claude and GPT models"); the report names no account and no plan. Running agy may let it update itself; `AGENTHUB_AGY_USAGE=0` never runs it (no entry). **Cursor** reads the Cursor CLI's (`cursor-agent`) own login, `$XDG_CONFIG_HOME/cursor/auth.json` (default `~/.config`), and sends it ONLY to cursor.com's undocumented `GET /api/usage-summary` as the `WorkosCursorSessionToken=<user id>::<token>` cookie (user id from `~/.cursor/cli-config.json` `authInfo`), while its JWT is valid and never refreshed — expired or a 401 is `error: "ETOKENEXPIRED"`; cached like Claude; no file (macOS keeps it in the keychain), no entry. One `mo` window: `individualUsage.plan.totalPercentUsed`, resetting at `billingCycleEnd` (an Enterprise seat without `plan` shows its plan name and no window). `plan` is the subscription's name — Claude's as `GET /api/oauth/profile` reports it NOW (`organization.organization_type` / `rate_limit_tier` / `seat_tier`, remembered per account so saved profiles and pool accounts show it too), else read from the stored rate-limit tier and seat ("Max 5x", "Team Standard", "Team Premium", else `subscriptionType` title-cased; the stored fields are written at sign-in, so they keep `pro` after an upgrade to Max), Codex's `chatgpt_plan_type` id-token claim, Cursor's `membershipType` — shown as a badge beside the account. `account` is the signed-in email and `expiresAt` when the stored access token runs out — Claude's `claudeAiOauth.expiresAt`, Codex's and Cursor's access-token `exp` (JWT claims read unverified; no token leaves the server but Cursor's own, to cursor.com). Labels: `5h`, `wk`, `mo`, else `<n>d`/`<n>h` (the hover spells `wk`/`mo` out as week/month). The status bar shows one "N% used" chip per tool (its live account) at its right end and, on hover, every account grouped per tool with a row per window — bar, percent, reset time. **Server mode with S3** (one Linux user per account): a developer or analyst gets ONLY their own account's rows, read from their Linux home at the tools' default paths (`~/.claude`, `~/.codex`, `~/.config/cursor` + `~/.cursor`) — never the host's — and only from files that account owns (open + `fstat` owner check, a FIFO refused). No saved Claude profiles and no Antigravity row for them (agy's `/usage` needs the self-updating binary, so it stays the host's); an account that is not provisioned gets `tools: []`. An admin gets the host's, as before.
How much of each native tool's plan quota is spent: `{ tools: [{ id: "claude"|"codex"|"antigravity"|"cursor", active, label?, accountUuid?, account?, plan?, expiresAt?, windows: [{ label, usedPercent, resetsAt, group? }], error?, asOf? }], checkedAt }` (`server/lib/quota.js`). **Claude** asks Anthropic's `GET /api/oauth/usage` with the access token the CLI holds right now — never refreshed, so an expired token answers `error: "ELIVEEXPIRED"` until Claude Code refreshes it — cached 5 min (`force=1`, the status bar's click, re-asks at most every 30 s); no OAuth login, no entry. That live account is `active: true`; every OTHER saved auth profile follows as `active: false` with its `label`, asked with its OWN stored token only while that token is valid — never refreshed here, since a refresh rotates the saved credentials (only the Profiles modal's **Check quota** does that) — and, once expired, showing the last figure this server read (`asOf`) or `error: "EPROFILEEXPIRED"`. Each Claude token's owner is also asked (`GET /api/oauth/profile`): the live row carries the label and email of the account its token belongs to (`.claude.json` can name another), and a saved profile whose token belongs to a different account answers `error: "EWRONGACCOUNT"` with no windows, never that other account's usage. Every Claude row carries its Anthropic `accountUuid` (null when unknown). **Codex** reads the newest `rate_limits` from its rollout journals, no network (`asOf` = when the journal recorded it; a window past its reset reads 0). **Antigravity** runs the catalog-detected `agy` as `agy --print /usage --output-format json` — the CLI's own `/usage` command, no model call (`num_turns` 0, no conversation) — in a private empty temp directory with a 60 s timeout, only while the tool is enabled in AI Tools and `agy --version` is ≥ 1.1.11 (an older agy may bill `/usage` as a model request); cached like Claude, a failed run shows the last figure (`asOf`) or `error: "EUPSTREAM"`. One window per pool bucket (`5h`, `wk`), `group` naming the pool ("Gemini Models", "Claude and GPT models"); the report names no account and no plan. Running agy may let it update itself; `AGENTHUB_AGY_USAGE=0` never runs it (no entry). **Cursor** reads the Cursor CLI's (`cursor-agent`) own login, `$XDG_CONFIG_HOME/cursor/auth.json` (default `~/.config`), and sends it ONLY to cursor.com's undocumented `GET /api/usage-summary` as the `WorkosCursorSessionToken=<user id>::<token>` cookie (user id from `~/.cursor/cli-config.json` `authInfo`), while its JWT is valid and never refreshed — expired or a 401 is `error: "ETOKENEXPIRED"`; cached like Claude; no file (macOS keeps it in the keychain), no entry. One `mo` window: `individualUsage.plan.totalPercentUsed`, resetting at `billingCycleEnd` (an Enterprise seat without `plan` shows its plan name and no window). `plan` is the subscription's name — Claude's as `GET /api/oauth/profile` reports it NOW (`organization.organization_type` / `rate_limit_tier` / `seat_tier`, remembered per account so saved profiles and pool accounts show it too), else read from the stored rate-limit tier and seat ("Max 5x", "Team Standard", "Team Premium", else `subscriptionType` title-cased; the stored fields are written at sign-in, so they keep `pro` after an upgrade to Max), Codex's `chatgpt_plan_type` id-token claim, Cursor's `membershipType` — shown as a badge beside the account. `account` is the signed-in email and `expiresAt` when the stored access token runs out — Claude's `claudeAiOauth.expiresAt`, Codex's and Cursor's access-token `exp` (JWT claims read unverified; no token leaves the server but Cursor's own, to cursor.com). Labels: `5h`, `wk`, `mo`, else `<n>d`/`<n>h` (the hover spells `wk`/`mo` out as week/month). The status bar shows one "N% used" chip per tool (its live account) at its right end and, on hover, every account grouped per tool with a row per window — bar, percent, reset time. **Server mode with S3** (one Linux user per account): a developer or analyst gets ONLY their own account's rows, read from their Linux home at the tools' default paths (`~/.claude`, `~/.codex`, `~/.config/cursor` + `~/.cursor`) — never the host's — and only from files that account owns (open + `fstat` owner check, a FIFO refused). No saved Claude profiles and no Antigravity row for them (agy's `/usage` needs the self-updating binary, so it stays the host's); an account that is not provisioned gets `tools: []`. An admin gets the host's, as before.
#### Analytics
```http
@@ -3658,7 +3658,7 @@ POST /api/updates/check
POST /api/updates/apply
```
Release-based update detection (`server/lib/update-check.js`). `status` and `check` answer the same object — `{ git_repo, update_available, install_kind: "checkout"|"plugin", local_version, latest_release, latest_release_url, latest_release_notes, release_check, commits_behind, commits_behind_release, manual_command, situation, last_plugin_update, plugin_update_progress, repo_root, … }` (`plugin_update_progress` is `{ step, index, total, started_at }` while a plugin-update chain runs — `marketplace_update` → `plugin_update` → `bootstrap`, from `plugin-update-progress.json` in the runtime dir — else `null`) (`repo_root` is `null` for a developer); `check` also broadcasts it as `update_status`. `apply` (admin-only in server mode) picks its mode by install kind, never from the request: a fast-forwardable checkout merges the release `--ff-only`, rebuilds, answers `{ …, applied: true }` and then restarts the server; a plugin-cache install with a newer release hands off to the detached plugin-update chain → `{ …, applied: true, mode: "plugin" }`. Nothing to apply is `409 UPDATE_NOT_APPLICABLE` (`reason`: `up_to_date`, `plugin_managed`, `not_fast_forwardable`). `500 UPDATE_STATUS_FAILED` / `UPDATE_CHECK_FAILED` / `UPDATE_APPLY_FAILED`.
Release-based update detection (`server/lib/update-check.js`). `status` and `check` answer the same object — `{ git_repo, update_available, install_kind: "checkout"|"plugin", local_version, latest_release, latest_release_url, latest_release_notes, release_check, commits_behind, commits_behind_release, manual_command, situation, last_plugin_update, plugin_update_progress, repo_root, … }` (`plugin_update_progress` is `{ step, index, total, started_at }` while a plugin-update chain runs — `marketplace_update` → `plugin_update` → `bootstrap`, from `plugin-update-progress.json` in the runtime dir — else `null`) (`repo_root` is `null` for a developer); `check` also broadcasts it as `update_status`. `apply` (admin-only in server mode) picks its mode by install kind, never from the request: a fast-forwardable checkout merges the release `--ff-only` (first putting back `package-lock.json` / `client/package-lock.json` when they are the ONLY tracked changes — an earlier update's `npm install` rewrote them; any other change still makes the merge refuse), rebuilds, answers `{ …, applied: true }` and then restarts the server; a plugin-cache install with a newer release hands off to the detached plugin-update chain → `{ …, applied: true, mode: "plugin" }`. Nothing to apply is `409 UPDATE_NOT_APPLICABLE` (`reason`: `up_to_date`, `plugin_managed`, `not_fast_forwardable`). `500 UPDATE_STATUS_FAILED` / `UPDATE_CHECK_FAILED` / `UPDATE_APPLY_FAILED`.
### Import history
+1 -1
View File
@@ -14,7 +14,7 @@ One place to keep what agents PRODUCE, and (in server mode) to share it:
| `plugin` | A plugin by marketplace source + name (no files) | picked from the list a marketplace link answers (`POST /api/artifacts/plugin/inspect`), or typed as `name@marketplace` |
- **Local mode:** one personal library, no sharing UI.
- **Server mode:** every artifact is the OWNER's and private by default. The owner can **Share** it with everyone — it appears in the **Hub** for every signed-in user — or with chosen people only (`artifact_shares`; they find it under **Shared with you**). A `page` keeps a viewable copy — its HTML file, or the copy Claude exports of a Doc / Slides / Design (`artifacts-export/`) — so the people it is shared with need no access to the claude.ai account that published it. A shared skill or plugin can be **installed** into the viewer's own account.
- **Server mode:** every artifact is the OWNER's and private by default. The owner can **Share** it with everyone — it appears in the **Hub** for every signed-in user — or with chosen people only (`artifact_shares`; they find it under **Shared with you**). A `page` keeps a viewable copy — its HTML file (from the workspace folder, or from the publishing session's own Claude Code scratchpad, where the Artifact tool writes a page by default), or the copy Claude exports of a Doc / Slides / Design (`artifacts-export/`) — so the people it is shared with need no access to the claude.ai account that published it. A shared skill or plugin can be **installed** into the viewer's own account.
## Rules (binding, same spirit as CLAUDE.md)
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "agenthub",
"version": "2.8.71",
"version": "2.8.75",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agenthub",
"version": "2.8.71",
"version": "2.8.75",
"hasInstallScript": true,
"license": "UNLICENSED",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "agenthub",
"version": "2.8.71",
"version": "2.8.75",
"bin": {
"agenthub": "bin/agenthub.js"
},
+88
View File
@@ -30,6 +30,8 @@ process.env.CLAUDE_HOME = path.join(ROOT, "claude");
process.env.DASHBOARD_DATA_DIR = path.join(ROOT, "data");
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
process.env.DASHBOARD_LIVENESS_PROBE = "0";
// Claude Code's scratchpads (`<tmp>/claude-<uid>/…`) go under this test's own folder.
process.env.CLAUDE_CODE_TMPDIR = ROOT;
const { createApp } = require("../index");
const { db } = require("../db");
@@ -1223,6 +1225,92 @@ describe("a page's own copy, viewable without its claude.ai account", () => {
}
});
// The Artifact tool writes a page into the session's own scratchpad by default.
const scratchpad = (sid, cwd = ws) =>
path.join(
ROOT,
`claude-${process.getuid()}`,
cwd.replace(/[^A-Za-z0-9]/g, "-"),
sid,
"scratchpad"
);
const SID = "5f0c1e2a-6b7d-4c8e-9f10-112233445566";
it("copies a page published from its session's scratchpad", async () => {
db.prepare(
"INSERT OR IGNORE INTO sessions (id, name, status, cwd, lane_id) VALUES (?, 'sp', 'active', ?, ?)"
).run(SID, ws, lane.id);
const dir = scratchpad(SID);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, "themes.html"), "<h1>themes</h1>");
fs.writeFileSync(path.join(dir, "app.js"), "1");
const row = artifacts.captureFromHook(SID, {
cwd: ws,
tool_input: {
action: "publish",
file_path: path.join(dir, "themes.html"),
files: { "app.js": path.join(dir, "app.js"), "x.html": path.join(ws, "pub", "deck.html") },
},
tool_response: "https://claude.ai/artifact/scratch-1",
});
await Promise.all(artifacts.__pageCopies());
const kept = db.prepare("SELECT files FROM artifacts WHERE id = ?").get(row.id);
assert.ok(kept.files, "the page has a copy");
// A supporting file comes only from the page's own root, never the workspace.
assert.deepEqual(
JSON.parse(kept.files).map((f) => f.path),
["themes.html", "app.js"]
);
assert.match(fs.readFileSync(artifacts.snapshotFile(null, row.id).abs, "utf8"), /themes/);
});
it("never copies from another session's scratchpad or through a linked one", async () => {
const other = scratchpad("other-session-1");
fs.mkdirSync(other, { recursive: true });
fs.writeFileSync(path.join(other, "x.html"), "<p>not mine</p>");
const foreign = artifacts.captureFromHook(SID, {
cwd: ws,
tool_input: { action: "publish", file_path: path.join(other, "x.html") },
tool_response: "https://claude.ai/artifact/scratch-foreign",
});
// A scratchpad that is a link to somewhere else is refused too.
const sid2 = "linked-session-2";
const real = path.join(ROOT, "elsewhere");
fs.mkdirSync(real, { recursive: true });
fs.writeFileSync(path.join(real, "y.html"), "<p>elsewhere</p>");
fs.mkdirSync(path.dirname(scratchpad(sid2)), { recursive: true });
fs.symlinkSync(real, scratchpad(sid2));
const linked = artifacts.captureFromHook(sid2, {
cwd: ws,
tool_input: { action: "publish", file_path: path.join(scratchpad(sid2), "y.html") },
tool_response: "https://claude.ai/artifact/scratch-linked",
});
await Promise.all(artifacts.__pageCopies());
for (const r of [foreign, linked]) {
assert.equal(
db.prepare("SELECT snapshot FROM artifacts WHERE id = ?").get(r.id).snapshot,
null
);
}
});
it("takes an older scratchpad page's copy on first view", async () => {
fs.writeFileSync(path.join(scratchpad(SID), "older.html"), "<p>older</p>");
const id = Number(
db
.prepare(
"INSERT INTO artifacts (kind, title, lane_id, session_id, source, url) VALUES ('page', 'older', ?, ?, ?, 'https://claude.ai/artifact/older-sp')"
)
.run(lane.id, SID, path.join(scratchpad(SID), "older.html")).lastInsertRowid
);
const r = await request("POST", `/api/artifacts/${id}/copy`, {});
assert.equal(r.status, 200, r.text);
assert.deepEqual(
r.body.item.files.map((f) => f.path),
["older.html"]
);
});
it("takes an older page's copy on first view, once", async () => {
fs.writeFileSync(path.join(ws, "pub", "old.html"), "<p>old</p>");
const id = Number(
@@ -0,0 +1,66 @@
/**
* @file Regression: transcript lookups in claude-home.js find a file by
* `stat`, never `fs.existsSync`. existsSync is access(2), which the kernel runs
* with the capabilities cleared, so under S3 it reported a member's live
* transcript (behind their 0700 `~/.claude/projects`) as missing and the chat
* served the stale import-time snapshot — a whole turn shown in the terminal
* never reached the chat. Here `fs.existsSync` is stubbed to answer "no" for
* everything under the temp Claude home, as access(2) does for the unit.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const os = require("node:os");
const fs = require("node:fs");
const path = require("node:path");
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-access-blind-"));
const SAVED_HOME = process.env.CLAUDE_HOME;
process.env.CLAUDE_HOME = ROOT;
const ch = require("../lib/claude-home");
const SID = "11111111-2222-3333-4444-555555555555";
const CWD = "/work/demo-repo";
const PROJECT = path.join(ROOT, "projects", ch.encodeCwd(CWD));
const MAIN = path.join(PROJECT, `${SID}.jsonl`);
const SUB = path.join(PROJECT, SID, "subagents", "agent-abc.jsonl");
const NESTED = path.join(PROJECT, SID, "subagents", "workflows", "wf_1", "agent-xyz.jsonl");
const realExists = fs.existsSync;
describe("claude-home transcript lookups when access(2) is blind", () => {
before(() => {
for (const f of [MAIN, SUB, NESTED]) {
fs.mkdirSync(path.dirname(f), { recursive: true });
fs.writeFileSync(f, "{}\n");
}
fs.existsSync = (p) => (String(p).startsWith(ROOT) ? false : realExists(p));
});
after(() => {
fs.existsSync = realExists;
if (SAVED_HOME === undefined) delete process.env.CLAUDE_HOME;
else process.env.CLAUDE_HOME = SAVED_HOME;
fs.rmSync(ROOT, { recursive: true, force: true });
});
it("getTranscriptPath finds the live main transcript", () => {
assert.equal(ch.getTranscriptPath(SID, CWD), MAIN);
});
it("findTranscriptPath finds it by scanning", () => {
assert.equal(ch.findTranscriptPath(SID, CWD), MAIN);
});
it("subagent transcripts (flat and nested Workflow) are found", () => {
assert.equal(ch.findSubagentTranscriptPath(SID, "abc", null, CWD), SUB);
assert.equal(ch.findSubagentTranscriptPath(SID, "xyz", "wf_1", CWD), NESTED);
assert.equal(ch.findSubagentTranscriptPath(SID, "xyz", null, CWD), NESTED);
});
it("a missing transcript is still reported missing", () => {
assert.equal(ch.getTranscriptPath("no-such-session", CWD), null);
assert.equal(ch.exists(path.join(ROOT, "nope")), false);
});
});
+2
View File
@@ -281,6 +281,8 @@ test("the live row is the account its token belongs to, not the one .claude.json
assert.strictEqual(live.label, "Victor");
assert.strictEqual(live.account, "victor@x.y");
assert.strictEqual(live.ownerUuid, undefined);
assert.strictEqual(live.accountUuid, "uuid-v");
assert.ok(claude.filter((t) => !t.active).every((t) => "accountUuid" in t));
assert.strictEqual(live.plan, "Max");
assert.deepStrictEqual(
claude.filter((t) => !t.active).map((t) => t.label),
@@ -18,6 +18,7 @@ const { execFileSync } = require("child_process");
const {
getUpdatesStatus,
applyUpdate,
restoreNpmLockfiles,
compareVersions,
releaseApiUrl,
detectPluginInstall,
@@ -512,3 +513,48 @@ describe("applyUpdate — plugin cache is Claude Code's to replace", () => {
assert.equal(git(repo, ["rev-parse", "HEAD"]), headBefore, "HEAD must be untouched");
});
});
describe("restoreNpmLockfiles — npm's rewrite never blocks an update", () => {
const git = (cwd, ...args) =>
execFileSync("git", args, {
cwd,
encoding: "utf8",
env: {
...process.env,
GIT_AUTHOR_NAME: "t",
GIT_AUTHOR_EMAIL: "t@t",
GIT_COMMITTER_NAME: "t",
GIT_COMMITTER_EMAIL: "t@t",
},
});
let repo;
before(() => {
repo = fs.mkdtempSync(path.join(os.tmpdir(), "lockfiles-"));
git(repo, "init", "-q");
fs.mkdirSync(path.join(repo, "client"));
fs.writeFileSync(path.join(repo, "package-lock.json"), "{}\n");
fs.writeFileSync(path.join(repo, "client", "package-lock.json"), "{}\n");
fs.writeFileSync(path.join(repo, "app.js"), "v1\n");
git(repo, "add", ".");
git(repo, "commit", "-qm", "init");
});
after(() => fs.rmSync(repo, { recursive: true, force: true }));
it("puts lockfiles npm rewrote back, and only when nothing else changed", async () => {
const lock = path.join(repo, "client", "package-lock.json");
fs.writeFileSync(lock, '{"npm":"rewrote"}\n');
assert.deepEqual(await restoreNpmLockfiles(repo), ["client/package-lock.json"]);
assert.equal(fs.readFileSync(lock, "utf8"), "{}\n");
// A real change beside it: nothing is touched, the merge refuses as before.
fs.writeFileSync(lock, '{"npm":"rewrote"}\n');
fs.writeFileSync(path.join(repo, "app.js"), "my edit\n");
assert.deepEqual(await restoreNpmLockfiles(repo), []);
assert.equal(fs.readFileSync(lock, "utf8"), '{"npm":"rewrote"}\n');
assert.equal(fs.readFileSync(path.join(repo, "app.js"), "utf8"), "my edit\n");
// A clean tree: nothing to do.
git(repo, "checkout", "--", ".");
assert.deepEqual(await restoreNpmLockfiles(repo), []);
});
});
+122 -21
View File
@@ -494,7 +494,8 @@ function captureFromHook(sessionId, data, peer = null) {
const file = typeof input.file_path === "string" ? input.file_path : "";
// A publish of a page from a file: keep a copy anyone it is shared with can
// view here, without a claude.ai login (a republish takes the new version).
const copy = laneId != null && file ? pageCopy(laneId, data?.cwd, file, input.files) : null;
const copy =
laneId != null && file ? pageCopy(laneId, data?.cwd, file, input.files, sessionId) : null;
if (same) {
if (copy) schedulePageSnapshot(same.id, copy);
return same;
@@ -522,6 +523,9 @@ function captureFromHook(sessionId, data, peer = null) {
// such file: Claude is asked (scripts/artifact-export-hint.js) to export a
// copy to `artifacts-export/<artifact id>.(md|pdf)`, which the Stop capture
// attaches to the row instead (`captureOutputsNow`).
// The file is taken from the workspace folder OR from the publishing
// session's own Claude Code scratchpad — where the Artifact tool writes a
// page by default — and from nowhere else.
const PAGE_FILE_EXT = /\.html?$/i;
const PAGE_FILES_MAX = 50;
@@ -530,29 +534,118 @@ const PUBLISHED_RE =
/** Where Claude is asked to export a typed artifact's copy. */
const EXPORT_DIR = "artifacts-export";
const SESSION_ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
/** Claude Code's name for a project folder in its temp tree: every non-alphanumeric is "-". */
const projectKey = (cwd) => String(cwd).replace(/[^A-Za-z0-9]/g, "-");
/** The uid a workspace's sessions run as (a member's under S3, else this process's), or null. */
function workspaceUid(lane) {
try {
const acct = require("./run-as").accountForPath(lane.cwd);
return acct ? acct.uid : typeof process.getuid === "function" ? process.getuid() : null;
} catch {
return null; // EMEMBERGONE: nobody to read as
}
}
/**
* What to copy for a page: `{laneId, main, extra: [{rel, as}]}` with every
* path workspace-relative, or null when the main file is not an HTML file
* inside the workspace. `cwd` is the session's folder (a relative path in
* the hook is relative to it); `files` is the Artifact tool's supporting
* files map (`{published: source | {from}}`) or list (`[{path}]`).
* The scratchpad Claude Code gives ONE session —
* `<tmp>/claude-<uid>/<project key>/<session id>/scratchpad`, `<tmp>` being
* `/tmp`, `os.tmpdir()` or `CLAUDE_CODE_TMPDIR` — as `{dir, uid}`, when `abs`
* lies inside it; else null. Only the text is judged here: `scratchpadSafe`
* checks the folders on disk before anything is read.
*/
function pageCopy(laneId, cwd, file, files) {
function scratchpadOf(lane, sessionId, cwd, abs) {
if (!SESSION_ID_RE.test(String(sessionId ?? ""))) return null;
const uid = workspaceUid(lane);
if (uid == null) return null;
const tail = path.join(`claude-${uid}`, projectKey(cwd), String(sessionId), "scratchpad");
const bases = new Set(
[require("node:os").tmpdir(), "/tmp", process.env.CLAUDE_CODE_TMPDIR]
.filter((b) => typeof b === "string" && path.isAbsolute(b))
.map((b) => path.resolve(b))
);
for (const b of bases) {
const dir = path.join(b, tail);
const rel = path.relative(dir, abs);
if (rel && !rel.startsWith("..") && !path.isAbsolute(rel)) return { dir, uid };
}
return null;
}
/** Each of the scratchpad's four own folders is a real directory of its account (never a link). */
async function scratchpadSafe(sp) {
let d = sp.dir;
for (let i = 0; i < 4; i += 1) {
let st;
try {
st = await fs.promises.lstat(d);
} catch {
return false;
}
if (!st.isDirectory() || st.uid !== sp.uid) return false;
d = path.dirname(d);
}
return true;
}
/**
* Open `rel` inside a scratchpad like a pinned file is opened in a
* workspace: confined by its REAL path, not a link, a regular file of the
* scratchpad's account. `{size, stream}`.
*/
async function openScratch(sp, rel) {
const refused = () =>
Object.assign(new Error("not a file of this scratchpad"), { code: "ENOFILE" });
const root = await fs.promises.realpath(sp.dir);
const real = await fs.promises.realpath(path.resolve(sp.dir, rel));
const r = path.relative(root, real);
if (!r || r.startsWith("..") || path.isAbsolute(r)) throw refused();
const { O_RDONLY, O_NOFOLLOW = 0, O_NONBLOCK = 0 } = fs.constants;
const fh = await fs.promises.open(real, O_RDONLY | O_NOFOLLOW | O_NONBLOCK);
const st = await fh.stat();
if (!st.isFile() || st.uid !== sp.uid) {
await fh.close();
throw refused();
}
return {
size: st.size,
stream: fh.createReadStream({ start: 0, end: Math.max(0, st.size - 1) }),
};
}
/**
* What to copy for a page: `{laneId, main, extra: [{rel, as}], scratch?}`,
* every path relative to its root — the workspace folder, or (`scratch`,
* `{dir, uid}`) the publishing session's own scratchpad — or null when the
* main file is not an HTML file inside either. `cwd` is the session's folder
* (a relative path in the hook is relative to it); `files` is the Artifact
* tool's supporting files map (`{published: source | {from}}`) or list
* (`[{path}]`), each taken only from the main file's root.
*/
function pageCopy(laneId, cwd, file, files, sessionId) {
const lane = require("./lanes").getLane(Number(laneId));
if (!lane || !PAGE_FILE_EXT.test(file)) return null;
const base = typeof cwd === "string" && path.isAbsolute(cwd) ? cwd : lane.cwd;
const inLane = (p) => {
const rel = path.relative(lane.cwd, path.resolve(base, p));
const under = (root) => (p) => {
const rel = path.relative(root, path.resolve(base, p));
return rel && !rel.startsWith("..") && !path.isAbsolute(rel)
? rel.split(path.sep).join("/")
: null;
};
const main = inLane(file);
if (!main) return null;
let inRoot = under(lane.cwd);
let main = inRoot(file);
let scratch = null;
if (!main) {
scratch = scratchpadOf(lane, sessionId, base, path.resolve(base, file));
if (!scratch) return null;
inRoot = under(scratch.dir);
main = inRoot(file);
}
const extra = [];
const add = (as, src) => {
if (typeof as !== "string" || !PUBLISHED_RE.test(as) || typeof src !== "string") return;
const rel = inLane(src);
const rel = inRoot(src);
if (rel && extra.length < PAGE_FILES_MAX) extra.push({ rel, as });
};
if (Array.isArray(files)) {
@@ -562,7 +655,7 @@ function pageCopy(laneId, cwd, file, files) {
add(as, typeof v === "string" ? v : v && typeof v.from === "string" ? v.from : null);
}
}
return { laneId: lane.id, main, extra };
return scratch ? { laneId: lane.id, main, extra, scratch } : { laneId: lane.id, main, extra };
}
/**
@@ -571,9 +664,11 @@ function pageCopy(laneId, cwd, file, files) {
* written at its published path `as`. A file that is a secret, gone or
* outside the workspace is skipped; the total stays under FILE_MAX_BYTES.
* Returns `{snap, files: [{path, size}]}`, or null when nothing was copied.
* `open(rel)` → `{size, stream}` reads one entry (default: the workspace's
* guarded download).
*/
async function snapshotLaneFiles(lane, entries) {
const { openDownload } = require("./lane-file-transfer");
async function snapshotLaneFiles(lane, entries, open) {
const read = open || ((rel) => require("./lane-file-transfer").openDownload(lane.cwd, rel));
const snap = newSnapshotDir();
const files = [];
let total = 0;
@@ -587,7 +682,7 @@ async function snapshotLaneFiles(lane, entries) {
continue;
let file;
try {
file = await openDownload(lane.cwd, rel);
file = await read(rel);
} catch {
continue;
}
@@ -633,11 +728,14 @@ function setSnapshot(rowId, shot) {
async function snapshotPage(rowId, copy) {
const lane = require("./lanes").getLane(Number(copy.laneId));
if (!lane) return null;
const sp = copy.scratch;
if (sp && !(await scratchpadSafe(sp))) return null;
const mainAs = path.posix.basename(copy.main);
const shot = await snapshotLaneFiles(lane, [
{ rel: copy.main, as: mainAs },
...copy.extra.filter((e) => e.as !== mainAs),
]);
const shot = await snapshotLaneFiles(
lane,
[{ rel: copy.main, as: mainAs }, ...copy.extra.filter((e) => e.as !== mainAs)],
sp ? (rel) => openScratch(sp, rel) : undefined
);
if (!shot || shot.files[0].path !== mainAs) {
if (shot) dropSnapshot(shot.snap.id);
return null;
@@ -681,7 +779,10 @@ async function ensurePageCopy(req, id) {
}
if (backfillTried.has(row.id)) return view(row, req);
backfillTried.add(row.id);
const copy = pageCopy(row.lane_id, null, row.source, null);
const cwd = row.session_id
? sql("SELECT cwd FROM sessions WHERE id = ?").get(row.session_id)?.cwd
: null;
const copy = pageCopy(row.lane_id, cwd || null, row.source, null, row.session_id);
const next = copy ? await schedulePageSnapshot(row.id, copy) : null;
return view(next || row, req);
}
+28 -11
View File
@@ -11,6 +11,22 @@ const path = require("path");
const os = require("os");
const fs = require("fs");
/**
* Whether `p` exists, by `stat`. Not `fs.existsSync`: that is access(2), which
* checks the REAL uid with capabilities cleared, so under S3 it says "no" for a
* member's transcript behind their 0700 `~/.claude/projects` — and the chat
* fell back to a stale snapshot while the unit itself reads the live file
* through its CAP_DAC_READ_SEARCH.
*/
function exists(p) {
try {
fs.statSync(p);
return true;
} catch {
return false;
}
}
function getClaudeHome() {
return process.env.CLAUDE_HOME || path.join(os.homedir(), ".claude");
}
@@ -143,7 +159,7 @@ function getTranscriptPath(sessionId, cwd) {
if (!cwd) return null;
const encoded = encodeCwd(cwd);
const candidate = path.join(projectsDirFor(cwd), encoded, `${sessionId}.jsonl`);
if (fs.existsSync(candidate)) return candidate;
if (exists(candidate)) return candidate;
// Fallback: scan the owner's projects/ subdirectories
return findTranscriptPath(sessionId, cwd);
}
@@ -170,14 +186,14 @@ function getTranscriptPath(sessionId, cwd) {
function resolveAgentTranscriptInDir(subagentsDir, agentId, runId = null) {
if (!subagentsDir) return null;
const flat = path.join(subagentsDir, `agent-${agentId}.jsonl`);
if (fs.existsSync(flat)) return flat;
if (exists(flat)) return flat;
const workflowsDir = path.join(subagentsDir, "workflows");
if (!fs.existsSync(workflowsDir)) return null;
if (!exists(workflowsDir)) return null;
if (runId) {
const nested = path.join(workflowsDir, runId, `agent-${agentId}.jsonl`);
return fs.existsSync(nested) ? nested : null;
return exists(nested) ? nested : null;
}
// Unknown run: accept only an unambiguous single match across all runs.
@@ -186,7 +202,7 @@ function resolveAgentTranscriptInDir(subagentsDir, agentId, runId = null) {
for (const d of fs.readdirSync(workflowsDir, { withFileTypes: true })) {
if (!d.isDirectory()) continue;
const cand = path.join(workflowsDir, d.name, `agent-${agentId}.jsonl`);
if (fs.existsSync(cand)) matches.push(cand);
if (exists(cand)) matches.push(cand);
if (matches.length > 1) break;
}
return matches.length === 1 ? matches[0] : null;
@@ -218,13 +234,13 @@ function getSubagentTranscriptPath(sessionId, cwd, agentId, runId = null) {
*/
function findTranscriptPath(sessionId, cwd = null) {
for (const projectsDir of sessionProjectsDirs(cwd)) {
if (!fs.existsSync(projectsDir)) continue;
if (!exists(projectsDir)) continue;
try {
const dirs = fs.readdirSync(projectsDir, { withFileTypes: true });
for (const d of dirs) {
if (!d.isDirectory()) continue;
const candidate = path.join(projectsDir, d.name, `${sessionId}.jsonl`);
if (fs.existsSync(candidate)) return candidate;
if (exists(candidate)) return candidate;
}
} catch {
// Permission or IO error, ignore
@@ -242,7 +258,7 @@ function findTranscriptPath(sessionId, cwd = null) {
*/
function getSnapshotTranscriptPath(sessionId) {
const candidate = path.join(getTranscriptSnapshotDir(), `${sessionId}.jsonl`);
return fs.existsSync(candidate) ? candidate : null;
return exists(candidate) ? candidate : null;
}
/**
@@ -255,7 +271,7 @@ function getSnapshotTranscriptPath(sessionId) {
*/
function getSnapshotSubagentTranscriptPath(sessionId, agentId, runId = null) {
const subDir = path.join(getTranscriptSnapshotDir(), sessionId, "subagents");
if (!fs.existsSync(subDir)) return null;
if (!exists(subDir)) return null;
const hit = resolveAgentTranscriptInDir(subDir, agentId, runId);
if (hit) return hit;
if (agentId.startsWith("acompact-")) {
@@ -287,13 +303,13 @@ function findSubagentTranscriptPath(sessionId, agentId, runId = null, cwd = null
}
function findSubagentIn(projectsDir, sessionId, agentId, runId) {
if (!fs.existsSync(projectsDir)) return null;
if (!exists(projectsDir)) return null;
try {
const dirs = fs.readdirSync(projectsDir, { withFileTypes: true });
for (const d of dirs) {
if (!d.isDirectory()) continue;
const subagentsDir = path.join(projectsDir, d.name, sessionId, "subagents");
if (!fs.existsSync(subagentsDir)) continue;
if (!exists(subagentsDir)) continue;
// Exact match (flat or nested Workflow-tool layout)
const hit = resolveAgentTranscriptInDir(subagentsDir, agentId, runId);
@@ -358,4 +374,5 @@ module.exports = {
allProjectsDirs,
sessionProjectsDirs,
setClaudeHome,
exists,
};
+14 -4
View File
@@ -210,8 +210,8 @@ async function claudeQuotas(force, home) {
liveClaude(() => memberClaudeAuth(home))
);
if (!live) return [];
const { ownerUuid: _uuid, ...shown } = live;
return [{ id: "claude", active: true, label: null, ...shown }];
const { ownerUuid, ...shown } = live;
return [{ id: "claude", active: true, label: null, accountUuid: ownerUuid || null, ...shown }];
}
let items = [];
try {
@@ -231,12 +231,22 @@ async function claudeQuotas(force, home) {
id: "claude",
active: false,
label: it.label,
accountUuid: it.accountUuid || null,
...(await cached(`profile:${it.name}`, force, () => profileClaude(it))),
}))
);
if (!live) return others;
const { ownerUuid: _uuid, ...shown } = live;
return [{ id: "claude", active: true, label: mine?.label || null, ...shown }, ...others];
const { ownerUuid, ...shown } = live;
return [
{
id: "claude",
active: true,
label: mine?.label || null,
accountUuid: ownerUuid || mine?.accountUuid || null,
...shown,
},
...others,
];
}
/** The newest `rate_limits` in the last `TAIL_BYTES` of a rollout, or null. */
+26
View File
@@ -837,6 +837,30 @@ async function getUpdatesStatus(gitRoot = DEFAULT_ROOT, options = {}) {
* @returns {Promise<object>} the refreshed status, plus `applied` (and
* `reason` when `applied` is false).
*/
// The lockfiles `npm run setup` (an earlier update's `npm install`) may have
// rewritten — another npm version normalizes them. Left modified, the next
// update's `merge --ff-only` refuses the moment a release changes one of them.
const NPM_LOCKFILES = ["package-lock.json", "client/package-lock.json"];
/**
* Before a fast-forward: when the ONLY tracked changes are npm lockfiles, put
* them back to HEAD (npm wrote them, not the user) and return their paths.
* Any other change leaves everything alone (returns []), so the merge still
* refuses rather than lose real work.
*/
async function restoreNpmLockfiles(root) {
const changed = (await execGit(root, ["diff", "--name-only", "HEAD", "--"], { timeout: 30_000 }))
.split("\n")
.map((f) => f.trim())
.filter(Boolean);
if (!changed.length || !changed.every((f) => NPM_LOCKFILES.includes(f))) return [];
await execGit(root, ["checkout", "HEAD", "--", ...changed], { timeout: 30_000 });
console.log(
`[updates] restored ${changed.join(", ")} (rewritten by npm install) before updating`
);
return changed;
}
async function applyUpdate(gitRoot = DEFAULT_ROOT) {
// A plugin-cache install is Claude Code's to replace. Pulling here would
// leave a cache directory named after one commit but holding another, and
@@ -866,6 +890,7 @@ async function applyUpdate(gitRoot = DEFAULT_ROOT) {
}
const root = path.resolve(gitRoot);
await restoreNpmLockfiles(root);
if (status.situation === "release_available") {
await execGit(root, ["fetch", status.canonical_remote, "--tags"], { timeout: 120_000 });
// --ff-only is the guard: a release that isn't a descendant of HEAD (a
@@ -903,4 +928,5 @@ module.exports = {
isShallowClone,
npmInvocation,
execNpm,
restoreNpmLockfiles,
};
+6 -5
View File
@@ -21,6 +21,7 @@ const {
getSnapshotSubagentTranscriptPath,
findTranscriptPath,
findSubagentTranscriptPath,
exists,
} = require("../lib/claude-home");
const {
classifyTranscriptSender,
@@ -908,7 +909,7 @@ router.get("/:id/transcripts", async (req, res) => {
getTranscriptPath(req.params.id, session.cwd) ||
findTranscriptPath(req.params.id, session.cwd) ||
getSnapshotTranscriptPath(req.params.id);
if (mainPath && fs.existsSync(mainPath)) {
if (mainPath && exists(mainPath)) {
// Main agent database ID format: <sessionId>-main
const mainDbAgent = dbAgents.find((a) => a.type === "main");
result.push({
@@ -928,18 +929,18 @@ router.get("/:id/transcripts", async (req, res) => {
if (encoded) {
// A member's session (S3) wrote under their own home.
const directDir = path.join(projectsDirFor(session.cwd), encoded, req.params.id, "subagents");
if (fs.existsSync(directDir)) subagentDirs.push(directDir);
if (exists(directDir)) subagentDirs.push(directDir);
}
// Fallback: scan the owner's project directory when the direct path doesn't exist
if (subagentDirs.length === 0) {
for (const projectsDir of sessionProjectsDirs(session.cwd)) {
if (fs.existsSync(projectsDir)) {
if (exists(projectsDir)) {
try {
for (const d of fs.readdirSync(projectsDir, { withFileTypes: true })) {
if (!d.isDirectory()) continue;
const candidate = path.join(projectsDir, d.name, req.params.id, "subagents");
if (fs.existsSync(candidate)) subagentDirs.push(candidate);
if (exists(candidate)) subagentDirs.push(candidate);
}
} catch {
/* ignore */
@@ -958,7 +959,7 @@ router.get("/:id/transcripts", async (req, res) => {
// Try reading meta.json for agent type info
let meta = null;
const metaPath = path.join(dir, file.replace(".jsonl", ".meta.json"));
if (fs.existsSync(metaPath)) {
if (exists(metaPath)) {
try {
meta = JSON.parse(
require("../lib/run-as").readFileSafe(metaPath, "utf8", { max: 1 << 20 })