Compare commits

..

7 Commits

Author SHA1 Message Date
nntrivi2001 890cd08f57 chore: v2.8.76 — four new colour themes: Sunset Cloud, Terminal, Night Sea and Sakura 2026-11-05 13:54:01 +07:00
nntrivi2001 aabc95768c feat(appearance): Sunset Cloud, Terminal, Night Sea and Sakura colour themes
Four named themes from ai-chat-themes (MIT; colours only — no wallpapers,
fonts or custom CSS): three dark ones beside Darcula / Apple Dark / Orca
Dark and Sakura beside the light ones. Each theme's background, text and
accent colour become the full token ramp, held to the existing contrast
bar (text and fg-muted >= 4.5:1 on every surface, accent on its /15 chip,
white on accent-solid, status labels on their /20 chip). Old Paper was
left out.

The terminal took its ANSI blue from --accent, which would print blue as
green (Terminal) or coral next to red (Sunset Cloud); a theme can now set
--ansi-blue, which lib/terminalTheme.ts prefers.
2026-11-05 12:15:21 +07:00
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
30 changed files with 754 additions and 164 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
# Claude Code Working Guide
_Last synced: v2.8.72 (2026-11-04) — an update puts back npm-rewritten lockfiles before its `--ff-only` merge; artifacts drag into folders._ 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.76 (2026-11-05) — four colour themes (Sunset Cloud, Terminal, Night Sea, Sakura); `--ansi-blue` for a non-blue accent._ 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);
});
});
@@ -60,6 +60,10 @@ export const THEME_LABELS: Record<string, string> = {
canvas: "Canvas",
"orca-dark": "Orca Dark",
"orca-light": "Orca Light",
"sunset-cloud": "Sunset Cloud",
terminal: "Terminal",
"night-sea": "Night Sea",
sakura: "Sakura",
};
const MODES: { mode: ThemeMode; Icon: typeof Moon }[] = [
@@ -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>
@@ -53,6 +53,10 @@ describe("AppearanceSettings", () => {
"Apple Light",
"Canvas",
"Orca Light",
"Sunset Cloud",
"Terminal",
"Night Sea",
"Sakura",
]) {
expect(screen.getByText(name)).toBeInTheDocument();
}
@@ -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"]));
@@ -103,6 +103,19 @@ describe("useTheme", () => {
expect(document.documentElement.dataset.theme).toBe("orca-light");
});
it("applies the ai-chat-themes palettes by id, each in its own mode only", () => {
const { result } = renderHook(() => useTheme());
for (const name of ["sunset-cloud", "terminal", "night-sea"]) {
act(() => result.current.setThemeFor("dark", name));
expect(document.documentElement.dataset.theme).toBe(name);
}
act(() => result.current.setThemeFor("dark", "sakura"));
expect(result.current.themeFor.dark).toBe("night-sea");
act(() => result.current.setThemeFor("light", "sakura"));
act(() => result.current.toggleTheme());
expect(document.documentElement.dataset.theme).toBe("sakura");
});
it("the default theme carries no data-theme attribute", () => {
const { result } = renderHook(() => useTheme());
act(() => result.current.setThemeFor("dark", "darcula"));
+2 -2
View File
@@ -22,8 +22,8 @@ export type ThemeMode = Theme | "system";
/** Named themes per mode, in picker order. `default` is the stock palette. */
export const THEMES = {
dark: ["default", "darcula", "apple-dark", "orca-dark"],
light: ["default", "canvas", "apple-light", "orca-light"],
dark: ["default", "darcula", "apple-dark", "orca-dark", "sunset-cloud", "terminal", "night-sea"],
light: ["default", "canvas", "apple-light", "orca-light", "sakura"],
} as const satisfies Record<Theme, readonly string[]>;
const MODE_KEY = "theme";
+2 -2
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,7 +270,7 @@
"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.",
+2 -2
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,7 +268,7 @@
"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.",
+100
View File
@@ -350,6 +350,106 @@
--syn-del: 197 0 15; /* #c5000f */
}
/* Sunset Cloud, Terminal, Night Sea (dark) and Sakura (light) — the colour
* themes of ai-chat-themes (github.com/Aohkne/ai-chat-themes, MIT), which
* gives only a background, a text and an accent colour per theme. Every
* other token is derived from those three and held to the bar above: text
* ≥ 4.5:1 on a card and on its hover lift, the text accent ≥ 4.5:1 on a card
* and on its own /15 chip (Sakura's #e0567f and Sunset's #ff8a6b darkened /
* lightened to get there), white ≥ 4.5:1 on `--accent-solid`, each status
* label ≥ 4.5:1 on its /20 chip. Their fonts (a monospace UI, Georgia) are
* not taken; syntax, diff and `--kept` colours are their mode's defaults.
* `--ansi-blue` (read only by `lib/terminalTheme.ts`) keeps a terminal's
* blue blue where the accent is green or coral. */
.dark[data-theme="sunset-cloud"] {
--surface-0: 32 24 36; /* #201824 */
--surface-1: 48 37 52; /* #302534 */
--surface-2: 54 43 58; /* #362b3a */
--surface-3: 60 49 63; /* #3c313f */
--surface-4: 67 56 69; /* #433845 */
--surface-5: 75 63 76; /* #4b3f4c */
--border: 76 64 76; /* #4c404c */
--border-light: 88 76 87; /* #584c57 */
--accent: 255 159 134; /* #ff9f86 */
--accent-hover: 255 188 170; /* #ffbcaa */
--accent-solid: 173 94 73; /* #ad5e49 */
--accent-solid-hover: 149 81 63; /* #95513f */
--fg-primary: 247 232 226; /* #f7e8e2 */
--fg-secondary: 214 203 203; /* #d6cbcb */
--fg-muted: 176 165 168; /* #b0a5a8 */
--status-success: 88 220 156; /* #58dc9c */
--status-danger: 255 176 173; /* #ffb0ad */
--status-warning: 255 202 22; /* #ffca16 */
/* ANSI blue in the terminal: the accent is not blue here. */
--ansi-blue: 112 184 255; /* #70b8ff */
}
.dark[data-theme="terminal"] {
--surface-0: 8 13 9; /* #080d09 */
--surface-1: 14 23 17; /* #0e1711 */
--surface-2: 20 30 22; /* #141e16 */
--surface-3: 25 36 28; /* #19241c */
--surface-4: 31 44 34; /* #1f2c22 */
--surface-5: 37 52 41; /* #253429 */
--border: 38 54 42; /* #26362a */
--border-light: 49 67 53; /* #314335 */
--accent: 57 232 122; /* #39e87a */
--accent-hover: 116 239 162; /* #74efa2 */
--accent-solid: 32 132 70; /* #208446 */
--accent-solid-hover: 28 114 60; /* #1c723c */
--fg-primary: 185 246 198; /* #b9f6c6 */
--fg-secondary: 147 196 157; /* #93c49d */
--fg-muted: 117 154 125; /* #759a7d */
--status-success: 61 214 140; /* #3dd68c */
--status-danger: 255 149 146; /* #ff9592 */
--status-warning: 255 202 22; /* #ffca16 */
/* ANSI blue in the terminal: the accent is not blue here. */
--ansi-blue: 112 184 255; /* #70b8ff */
}
.dark[data-theme="night-sea"] {
--surface-0: 11 20 33; /* #0b1421 */
--surface-1: 20 31 49; /* #141f31 */
--surface-2: 26 37 55; /* #1a2537 */
--surface-3: 32 43 62; /* #202b3e */
--surface-4: 40 50 69; /* #283245 */
--surface-5: 47 58 76; /* #2f3a4c */
--border: 48 59 77; /* #303b4d */
--border-light: 60 71 89; /* #3c4759 */
--accent: 92 175 255; /* #5cafff */
--accent-hover: 141 199 255; /* #8dc7ff */
--accent-solid: 62 120 176; /* #3e78b0 */
--accent-solid-hover: 53 103 151; /* #356797 */
--fg-primary: 220 230 250; /* #dce6fa */
--fg-secondary: 183 192 210; /* #b7c0d2 */
--fg-muted: 146 155 173; /* #929bad */
--status-success: 61 214 140; /* #3dd68c */
--status-danger: 255 155 153; /* #ff9b99 */
--status-warning: 255 202 22; /* #ffca16 */
}
[data-mode="light"][data-theme="sakura"] {
--surface-0: 242 230 232; /* #f2e6e8 */
--surface-1: 247 235 237; /* #f7ebed */
--surface-2: 251 240 242; /* #fbf0f2 */
--surface-3: 255 249 250; /* #fff9fa */
--surface-4: 255 252 253; /* #fffcfd */
--surface-5: 255 255 255; /* #ffffff */
--border: 224 210 213; /* #e0d2d5 */
--border-light: 237 224 227; /* #ede0e3 */
--accent: 167 64 95; /* #a7405f */
--accent-hover: 137 52 78; /* #89344e */
--accent-solid: 195 75 110; /* #c34b6e */
--accent-solid-hover: 168 65 95; /* #a8415f */
--fg-primary: 75 42 53; /* #4b2a35 */
--fg-secondary: 107 80 89; /* #6b5059 */
--fg-muted: 120 97 105; /* #786169 */
--status-success: 23 105 45; /* #17692d */
--status-danger: 173 28 38; /* #ad1c26 */
--status-warning: 124 82 0; /* #7c5200 */
--shadow-card: 0 1px 2px rgb(75 42 53 / 0.06), 0 2px 6px rgb(75 42 53 / 0.07);
}
@layer base {
body {
font-weight: 500;
@@ -39,6 +39,11 @@ describe("buildTerminalTheme", () => {
expect(t.brightWhite).toBe("#edeef0");
});
it("takes ANSI blue from --ansi-blue when a theme's accent is not blue", () => {
const t = buildTerminalTheme((n) => ({ ...vars, "--ansi-blue": "112 184 255" })[n] ?? "");
expect(t.blue).toBe("#70b8ff");
});
it("makes each bright twin lighter than its base colour", () => {
const t = buildTerminalTheme((n) => vars[n] ?? "");
const sum = (hex: string) =>
+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. */
+4 -2
View File
@@ -40,7 +40,9 @@ function lighten(hex: string | undefined, amount: number): string | undefined {
/**
* Build an xterm theme from a theme's CSS variables. red/green/yellow come from
* `--status-*`, blue from `--accent`, black/white from the surface/fg ramp.
* `--status-*`, blue from `--ansi-blue` when a theme sets one (its accent is
* not blue: Terminal's green, Sunset Cloud's coral) else `--accent`,
* black/white from the surface/fg ramp.
* Magenta and cyan stay the classic ANSI hues: no token keeps that hue in every
* theme (darcula's keyword is orange), and a program printing "magenta" should
* get magenta.
@@ -50,7 +52,7 @@ export function buildTerminalTheme(read: VarReader): ITheme {
const red = v("--status-danger");
const green = v("--status-success");
const yellow = v("--status-warning");
const blue = v("--accent");
const blue = v("--ansi-blue") ?? v("--accent");
const magenta = "#c678dd";
const cyan = "#56b6c2";
return {
+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`…). */
+63 -40
View File
@@ -65,9 +65,11 @@ import {
LayoutGrid,
List,
MoreHorizontal,
Pencil,
Puzzle,
Search,
Sparkles,
Trash2,
X,
} from "lucide-react";
import {
@@ -2564,13 +2566,24 @@ 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}
@@ -2579,26 +2592,54 @@ function FolderBar({
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) + lit(f.id)}
onClick={() => onPick(f.id)}
title={t("folders.dropHint")}
data-testid={`artifact-folder-${f.id}`}
{...dropProps(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")}
@@ -2619,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"
>
@@ -2627,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>
);
}
@@ -7316,7 +7316,7 @@ exports[`screen snapshots > Settings — about 1`] = `
<span
class="text-status-warning/90"
>
UI build v2.8.72 (rebuild client to match)
UI build v2.8.76 (rebuild client to match)
</span>
</p>
</div>
+1 -1
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
+3 -2
View File
@@ -84,8 +84,9 @@ Internal build — all rights reserved.
- **Updates.** Release-based update detection with an in-UI **Update all**
(marketplaces → every plugin → AgentHub last → one session reload).
- **Appearance.** Settings → Appearance picks Dark / Light / System (follows the
OS live) and a theme per mode — dark: Default, Darcula, Apple Dark, Orca Dark;
light: Default, Apple Light, Canvas, Orca Light. Each mode
OS live) and a theme per mode — dark: Default, Darcula, Apple Dark, Orca Dark,
Sunset Cloud, Terminal, Night Sea; light: Default, Apple Light, Canvas, Orca
Light, Sakura (the four last from ai-chat-themes, colours only). Each mode
remembers its own theme, so the sidebar's quick toggle swaps between the two.
It also picks the workspace terminals' font: JetBrains Mono (default), the
rounded Maple Mono, or the browser's own fixed-width
+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.72",
"version": "2.8.76",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agenthub",
"version": "2.8.72",
"version": "2.8.76",
"hasInstallScript": true,
"license": "UNLICENSED",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "agenthub",
"version": "2.8.72",
"version": "2.8.76",
"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),
+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. */
+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 })