Compare commits

..

4 Commits

Author SHA1 Message Date
nntrivi2001 38ea78b489 chore: v2.8.33 — an HTML file opens as the page (Files panel, Chat preview, File Explorer) 2026-10-28 16:46:21 +07:00
nntrivi2001 7b5abc6ad9 feat(files): an HTML file opens as the page — Files panel, Chat preview, File Explorer
Clicking an .html/.htm file now renders it instead of showing its source or
downloading it:
- Changes/Files panel: a whole HTML file opens on Preview (Raw = the editor).
- Chat file preview: a workspace page brings its relative files along.
- File Explorer: double-click / Enter opens a viewer; Download stays on its
  header and right-click menu.

The page runs in the chat artifact's sandbox (iframe srcdoc, scripts, never
allow-same-origin). Its relative stylesheets (and their url()s), scripts,
images, media and icons are read by the dashboard through the existing
download routes and inlined as <style>/data: URLs (lib/htmlInline.ts: never
above the folder's root, ≤ 60 files / 30 MB) — the login cookie is
SameSite=Strict, so a frame could not fetch them itself, and no new route
serves files without it.
2026-10-28 16:46:07 +07:00
nntrivi2001 ca55ff8ec5 chore: v2.8.32 — a Claude plan is the one Anthropic reports now (Max 5x upgraded from Pro) 2026-10-28 15:11:22 +07:00
nntrivi2001 0841d3e5c8 fix(plan): a Claude plan is the one Anthropic reports now, not the sign-in's
An account signed in on Pro and upgraded to Max 5x kept showing "Pro": the
plan was read only from .credentials.json / .claude.json, whose
subscriptionType and rate-limit tiers are written at sign-in. fetchOwner
already asks GET /api/oauth/profile for a token's owner; it now also reads
organization_type / rate_limit_tier / seat_tier into a plan
(planFromProfile), remembered per account uuid (rememberLivePlan). The live
row, saved profiles (identityOf) and pool accounts (poolStatus, whose quota
refresh now asks a login's owner too) show that plan when one was seen
(shownPlan), else planLabel of the files as before.
2026-10-28 15:09:30 +07:00
26 changed files with 809 additions and 48 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
# Claude Code Working Guide # Claude Code Working Guide
_Last synced: v2.8.31 (2026-10-28) — spend quotas per user; the branch switcher lists and tracks remote-only branches._ 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.33 (2026-10-28) — an HTML file opens as the page (Files panel, Chat preview, File Explorer)._ 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 ## Project mission
- Maintain a reliable local-first dashboard for Claude Code session monitoring. - Maintain a reliable local-first dashboard for Claude Code session monitoring.
@@ -65,7 +65,7 @@ A **workspace** — called a **lane** in the API, CLI, database and code, which
- **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. - **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.
- **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. - **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.
- **Opening a session never resumes it — only SENDING a message to it does.** The sidebar and a workspace home ATTACH pane 0 to a session's live tmux pane (`/workspace?lane=<id>&run=<runId>`, `attachRequest` on `LaneConsolePane`) or, for an ended one, open it in its workspace like Claude Desktop (`/workspace?lane=<id>&session=<id>`, `client/src/components/lanes/SessionReplay.tsx`: its conversation read-only plus a prompt box; the console is only hidden, CSS). A resume re-reads the whole transcript into context, so it happens only on an explicit action: a message SENT from that replay (`resumeSessionId` + `prompt`, first free slot), the transcript page's **Resume**/**Fork** (`SessionResumeButton.tsx`, behind a warning), or the ticked sessions of the **Resume interrupted sessions?** dialog (`InterruptedSessionsPrompt.tsx`, `GET /api/run/interrupted` — runs whose pane died under them; opening it resumes nothing). The analysis page `/sessions/<id>` is reached from the session view's **Analyst** button (beside Files/Changes in the right panel). Do not add a path that resumes on a click. A resume is refused while a live pane holds the session (`409 ESESSIONLIVE` with that pane's `run_id`) — open that pane instead; a fork makes a new session and is allowed. - **Opening a session never resumes it — only SENDING a message to it does.** The sidebar and a workspace home ATTACH pane 0 to a session's live tmux pane (`/workspace?lane=<id>&run=<runId>`, `attachRequest` on `LaneConsolePane`) or, for an ended one, open it in its workspace like Claude Desktop (`/workspace?lane=<id>&session=<id>`, `client/src/components/lanes/SessionReplay.tsx`: its conversation read-only plus a prompt box; the console is only hidden, CSS). A resume re-reads the whole transcript into context, so it happens only on an explicit action: a message SENT from that replay (`resumeSessionId` + `prompt`, first free slot), the transcript page's **Resume**/**Fork** (`SessionResumeButton.tsx`, behind a warning), or the ticked sessions of the **Resume interrupted sessions?** dialog (`InterruptedSessionsPrompt.tsx`, `GET /api/run/interrupted` — runs whose pane died under them; opening it resumes nothing). The analysis page `/sessions/<id>` is reached from the session view's **Analyst** button (beside Files/Changes in the right panel). Do not add a path that resumes on a click. A resume is refused while a live pane holds the session (`409 ESESSIONLIVE` with that pane's `run_id`) — open that pane instead; a fork makes a new session and is allowed.
- **A chat artifact runs in its own sandbox, never on the dashboard's origin.** The artifact panel (`client/src/components/run/ChatFilePreview.tsx`, `client/src/lib/artifactView.ts`) runs model-written HTML/Mermaid/React in an `<iframe srcdoc sandbox="allow-scripts …">` and an SVG in `sandbox=""` — never add `allow-same-origin` (the script would read the dashboard's cookies and call its API as the user), never render one with `dangerouslySetInnerHTML`, and keep its CDN libraries pinned to exact versions. Versions are rebuilt from Write/Edit inputs (kept up to 256 KB by `server/lib/transcript-message.js`); no copy is stored, and Save to Artifacts uses the Hub's own `pinFile`/upload routes. - **A chat artifact runs in its own sandbox, never on the dashboard's origin.** The artifact panel (`client/src/components/run/ChatFilePreview.tsx`, `client/src/lib/artifactView.ts`) runs model-written HTML/Mermaid/React in an `<iframe srcdoc sandbox="allow-scripts …">` and an SVG in `sandbox=""` — never add `allow-same-origin` (the script would read the dashboard's cookies and call its API as the user), never render one with `dangerouslySetInnerHTML`, and keep its CDN libraries pinned to exact versions. An HTML FILE opened from the Changes panel, a Chat file preview or the File Explorer runs in the same sandbox (`client/src/components/HtmlFrame.tsx`); its relative CSS/scripts/images are fetched by the DASHBOARD through the existing download routes and inlined (`client/src/lib/htmlInline.ts`) — never add a route that serves a folder to the frame without the login cookie. Versions are rebuilt from Write/Edit inputs (kept up to 256 KB by `server/lib/transcript-message.js`); no copy is stored, and Save to Artifacts uses the Hub's own `pinFile`/upload routes.
- **A `cwd` can back at most one lane per tool, not one lane overall.** Uniqueness is the composite `idx_lanes_cwd_tool` (`cwd`, `tool`), not `cwd` alone — Repo/Adopt mode (`POST /api/lanes/ensure`) lets the same directory carry a `claude` lane and a `codex` lane side by side, shown as two separate cards. `resolveLaneByCwd(cwd, tool)` takes an optional `tool` filter for exactly this; hook ingestion (`touchLaneFromHook`) pins it to the hook's own tool — `claude`, `codex` for a hook posted with `tool: "codex"`, or `antigravity` for one posted with `tool: "antigravity"` (the Codex and Antigravity hook rules above) — so a hook event never touches the other tool's lane sharing the path, and stage detection (`resolveLaneForWork` + `detect`) runs for `claude` only. Worktree mode is unaffected — it already allocates a unique directory per lane. - **A `cwd` can back at most one lane per tool, not one lane overall.** Uniqueness is the composite `idx_lanes_cwd_tool` (`cwd`, `tool`), not `cwd` alone — Repo/Adopt mode (`POST /api/lanes/ensure`) lets the same directory carry a `claude` lane and a `codex` lane side by side, shown as two separate cards. `resolveLaneByCwd(cwd, tool)` takes an optional `tool` filter for exactly this; hook ingestion (`touchLaneFromHook`) pins it to the hook's own tool — `claude`, `codex` for a hook posted with `tool: "codex"`, or `antigravity` for one posted with `tool: "antigravity"` (the Codex and Antigravity hook rules above) — so a hook event never touches the other tool's lane sharing the path, and stage detection (`resolveLaneForWork` + `detect`) runs for `claude` only. Worktree mode is unaffected — it already allocates a unique directory per lane.
See `docs/LANES.md` for full guide: stage reporting, custom templates, lane actions, and the five node states. See `docs/LANES.md` for full guide: stage reporting, custom templates, lane actions, and the five node states.
+98
View File
@@ -0,0 +1,98 @@
/**
* @file HtmlFrame.tsx
* @description An HTML file rendered as a page, the one way every file view
* shows one (a Chat file preview, the Changes panel's Preview, the File
* Explorer's viewer): its relative CSS, scripts and images pulled in through
* the caller's guarded read (`lib/htmlInline.ts`), then run in the chat
* artifact's sandbox — `<iframe srcdoc>` with scripts but never the
* dashboard's origin (no `allow-same-origin`). Given no `html`, it reads the
* file itself through the same `fetchFile`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { FileText, Loader2 } from "lucide-react";
import { artifactDocument, frameSandbox } from "../lib/artifactView";
import { blobText, inlineHtml, type FetchFile } from "../lib/htmlInline";
/** A page read by `fetchFile` is shown up to this size. */
const HTML_MAX = 10 * 1024 * 1024;
export function HtmlFrame({
path,
html,
fetchFile,
title,
className = "",
testId = "html-frame",
}: {
/** The file's path relative to the place `fetchFile` reads from. */
path: string;
/** The page's text; read through `fetchFile` when absent. */
html?: string;
/** Reads the page's relative files; none = the page as it is. */
fetchFile?: FetchFile | null;
title: string;
className?: string;
testId?: string;
}) {
const [doc, setDoc] = useState<string | null>(null);
const [failed, setFailed] = useState(false);
useEffect(() => {
let live = true;
// The page shown stays until its successor is ready (a version step, a
// re-read): no flash of the spinner between two renders of one file.
setFailed(false);
(async () => {
let text = html;
if (text === undefined) {
if (!fetchFile) throw new Error("nothing to read the page with");
const b = await fetchFile(path);
if (b.size > HTML_MAX) throw new Error("too big");
text = await blobText(b);
}
const page = text;
return fetchFile ? inlineHtml(page, path, fetchFile).catch(() => page) : page;
})().then(
(d) => live && setDoc(d),
() => live && setFailed(true)
);
return () => {
live = false;
};
}, [html, path, fetchFile]);
if (failed) {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-2 p-6 text-center text-sm text-fg-muted">
<FileText className="h-8 w-8" />
<p className="break-all font-mono text-xs">{path}</p>
</div>
);
}
if (doc === null) {
return (
<div className="flex flex-1 items-center justify-center">
<Loader2 className="h-5 w-5 animate-spin text-fg-muted" />
</div>
);
}
return (
<iframe
data-testid={testId}
title={title}
srcDoc={artifactDocument("html", doc, false) ?? ""}
sandbox={frameSandbox("html")}
referrerPolicy="no-referrer"
className={`min-h-0 w-full flex-1 border-0 bg-white ${className}`}
/>
);
}
/** A `FetchFile` over a download URL builder (a workspace's or a home's). */
export function fetchVia(url: (relPath: string) => string): FetchFile {
return async (rel) => {
const r = await fetch(url(rel), { credentials: "same-origin" });
if (!r.ok) throw new Error(String(r.status));
return r.blob();
};
}
+82 -2
View File
@@ -17,7 +17,8 @@
* (the clipboard outlives the dialog); upload files or whole folders (picker * (the clipboard outlives the dialog); upload files or whole folders (picker
* or dropped from the desktop), streamed with progress, ≤ 500 MB a batch; * or dropped from the desktop), streamed with progress, ≤ 500 MB a batch;
* drag items onto a folder to move them (Ctrl: copy); download a file as * drag items onto a folder to move them (Ctrl: copy); download a file as
* itself, anything else as one zip; a taken name asks Replace / Keep both / * itself, anything else as one zip — except an HTML file, which opens as the
* page (`HtmlViewer`, Download on its header and right-click menu); a taken name asks Replace / Keep both /
* Skip. Deleting forever asks first when it empties the Trash or touches * Skip. Deleting forever asks first when it empties the Trash or touches
* another user's. An admin's Trash opens on one folder per user (with its * another user's. An admin's Trash opens on one folder per user (with its
* item count); each folder is THAT user's Trash, to restore from or purge * item count); each folder is THAT user's Trash, to restore from or purge
@@ -90,6 +91,7 @@ import {
} from "../../lib/api"; } from "../../lib/api";
import type { Lane } from "../../lib/types"; import type { Lane } from "../../lib/types";
import { useDialog } from "../../hooks/useDialog"; import { useDialog } from "../../hooks/useDialog";
import { HtmlFrame, fetchVia } from "../HtmlFrame";
import { toast } from "../../lib/toast"; import { toast } from "../../lib/toast";
import { confirmDialog } from "../../lib/confirm"; import { confirmDialog } from "../../lib/confirm";
import { formatBytes } from "../../lib/format"; import { formatBytes } from "../../lib/format";
@@ -166,6 +168,7 @@ const join = (dir: string, name: string) => (dir ? `${dir}/${name}` : name);
const errText = (e: unknown) => (e instanceof Error ? e.message : String(e)); const errText = (e: unknown) => (e instanceof Error ? e.message : String(e));
const errCode = (e: unknown) => (e as { code?: string })?.code; const errCode = (e: unknown) => (e as { code?: string })?.code;
const isFolder = (e: FsEntry) => e.type === "dir" || e.type === "linkdir"; const isFolder = (e: FsEntry) => e.type === "dir" || e.type === "linkdir";
const isHtml = (e: FsEntry) => !isFolder(e) && /\.html?$/i.test(e.name);
const ext = (name: string) => name.slice(name.lastIndexOf(".") + 1).toLowerCase(); const ext = (name: string) => name.slice(name.lastIndexOf(".") + 1).toLowerCase();
const IMAGE = new Set(["png", "jpg", "jpeg", "gif", "webp", "bmp", "ico"]); const IMAGE = new Set(["png", "jpg", "jpeg", "gif", "webp", "bmp", "ico"]);
const CODE = new Set([ const CODE = new Set([
@@ -498,8 +501,11 @@ export function FileBrowser({
}; };
// ── Actions ───────────────────────────────────────────────────────────── // ── Actions ─────────────────────────────────────────────────────────────
// An HTML page opens as itself (`HtmlViewer`); any other file downloads.
const [viewing, setViewing] = useState<FsEntry | null>(null);
const openEntry = (e: FsEntry) => { const openEntry = (e: FsEntry) => {
if (isFolder(e)) go(e.path); if (isFolder(e)) go(e.path);
else if (isHtml(e)) setViewing(e);
else download([e]); else download([e]);
}; };
const goUp = () => { const goUp = () => {
@@ -970,6 +976,14 @@ export function FileBrowser({
onClick={() => setMenu(null)} onClick={() => setMenu(null)}
data-testid="file-explorer" data-testid="file-explorer"
> >
{viewing && (
<HtmlViewer
entry={viewing}
place={target}
onDownload={() => download([viewing])}
onClose={() => setViewing(null)}
/>
)}
{cloneParent && ( {cloneParent && (
<CloneHereDialog <CloneHereDialog
open={cloning} open={cloning}
@@ -1656,13 +1670,18 @@ export function FileBrowser({
{menuItem ? ( {menuItem ? (
<> <>
<MenuItem onSelect={close(() => openEntry(menuItem))}> <MenuItem onSelect={close(() => openEntry(menuItem))}>
{isFolder(menuItem) ? t("fx.open") : t("fx.download")} {isFolder(menuItem) || isHtml(menuItem) ? t("fx.open") : t("fx.download")}
</MenuItem> </MenuItem>
{(isFolder(menuItem) || menuTargets.length > 1) && ( {(isFolder(menuItem) || menuTargets.length > 1) && (
<MenuItem onSelect={close(() => download(menuTargets))}> <MenuItem onSelect={close(() => download(menuTargets))}>
{t("fx.downloadZip")} {t("fx.downloadZip")}
</MenuItem> </MenuItem>
)} )}
{isHtml(menuItem) && menuTargets.length === 1 && (
<MenuItem onSelect={close(() => download([menuItem]))}>
{t("fx.download")}
</MenuItem>
)}
<MenuSeparator /> <MenuSeparator />
{!ro && ( {!ro && (
<MenuItem onSelect={close(() => putClip(menuTargets, true))}> <MenuItem onSelect={close(() => putClip(menuTargets, true))}>
@@ -1916,6 +1935,67 @@ export function FileExplorer({
); );
} }
/** An HTML file shown as the page, over the explorer: its relative CSS,
* scripts and images read through the same download route (`HtmlFrame`).
* Keys and clicks stop here — through the portal they would otherwise reach
* the explorer's own shortcuts (Delete would trash the selection). */
function HtmlViewer({
entry,
place,
onDownload,
onClose,
}: {
entry: FsEntry;
place: FsPlace;
onDownload: () => void;
onClose: () => void;
}) {
const { t } = useTranslation(["lanes"]);
const panelRef = useRef<HTMLDivElement>(null);
useDialog(true, panelRef, onClose);
const placeKey = JSON.stringify(place);
const fetchFile = useMemo(
() => fetchVia((p) => api.files.downloadUrl(place, p)),
// eslint-disable-next-line react-hooks/exhaustive-deps
[placeKey]
);
return createPortal(
<div
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 p-3"
onClick={(e) => {
e.stopPropagation();
onClose();
}}
onKeyDown={(e) => e.stopPropagation()}
role="presentation"
>
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-label={entry.name}
data-testid="fx-html-viewer"
className="flex h-[92vh] w-[96vw] max-w-7xl flex-col overflow-hidden rounded-xl border border-border bg-surface-1 shadow-2xl shadow-black/50"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
<span className="min-w-0 flex-1 truncate font-mono text-[13px] text-fg-primary">
{entry.path}
</span>
<HeadButton label={t("fx.download")} onClick={onDownload}>
<Download className="h-4 w-4" />
</HeadButton>
<HeadButton label={t("fx.close")} onClick={onClose}>
<X className="h-4 w-4" />
</HeadButton>
</div>
<HtmlFrame path={entry.path} fetchFile={fetchFile} title={entry.name} />
</div>
</div>,
document.body
);
}
function HeadButton({ function HeadButton({
label, label,
onClick, onClick,
@@ -13,7 +13,9 @@
* side, each block with the diff's Undo / Keep while nothing * side, each block with the diff's Undo / Keep while nothing
* is unsaved): Changes folds the unchanged lines away, Whole file * is unsaved): Changes folds the unchanged lines away, Whole file
* shows them; any other file opens on its read-only diff; a * shows them; any other file opens on its read-only diff; a
* whole markdown file can also be read rendered (Preview), and an * whole markdown file can also be read rendered (Preview), an HTML
* file opens as the page itself (`HtmlFrame`, its relative CSS,
* scripts and images read like downloads), and an
* image file is shown as the picture (`GET …/git/raw`) * image file is shown as the picture (`GET …/git/raw`)
* history — recent commits; opening one shows that commit's diff * history — recent commits; opening one shows that commit's diff
* browse — quick-open by path, or the repo's tracked file tree (the * browse — quick-open by path, or the repo's tracked file tree (the
@@ -77,7 +79,16 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { Suspense, lazy, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import {
Suspense,
lazy,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
ChevronDown, ChevronDown,
@@ -127,6 +138,7 @@ import { LaneConflictView } from "./LaneConflictView";
import { FileExplorer } from "./FileExplorer"; import { FileExplorer } from "./FileExplorer";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu"; import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview"; import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
import { HtmlFrame, fetchVia } from "../HtmlFrame";
import { LaneGitHeader, useLaneGitFacts } from "./LaneGitHeader"; import { LaneGitHeader, useLaneGitFacts } from "./LaneGitHeader";
import { changeTree, treeFiles, type ChangeTreeDir } from "../../lib/changeTree"; import { changeTree, treeFiles, type ChangeTreeDir } from "../../lib/changeTree";
import { dragProjectPath } from "../../lib/promptAttach"; import { dragProjectPath } from "../../lib/promptAttach";
@@ -185,6 +197,9 @@ function Delta({
); );
} }
/** An HTML page, which the viewer can show rendered (`HtmlFrame`). */
const isHtml = (p: string | null | undefined) => !!p && /\.html?$/i.test(p);
/** What the viewer shows as a picture (`readImageAt`'s list on the server). */ /** What the viewer shows as a picture (`readImageAt`'s list on the server). */
const IMAGE_FILE = /\.(png|jpe?g|gif|webp|avif|bmp|ico|svg)$/i; const IMAGE_FILE = /\.(png|jpe?g|gif|webp|avif|bmp|ico|svg)$/i;
// CodeMirror is loaded only once a file is edited. // CodeMirror is loaded only once a file is edited.
@@ -874,10 +889,11 @@ export function LaneChangesPanel({
const [layout, setLayout] = useState<DiffLayout>(wide ? "split" : "unified"); const [layout, setLayout] = useState<DiffLayout>(wide ? "split" : "unified");
useEffect(() => setLayout(wide ? "split" : "unified"), [wide]); useEffect(() => setLayout(wide ? "split" : "unified"), [wide]);
const [whole, setWhole] = useState(false); const [whole, setWhole] = useState(false);
// A whole markdown file shown rendered instead of as source; per file, each // A whole markdown or HTML file shown rendered instead of as source; per
// one opening as Settings → Appearance says (`lib/markdownView.ts`). // file, markdown opening as Settings → Appearance says
// (`lib/markdownView.ts`), an HTML page always as the page.
const [mdPreview, setMdPreview] = useState(readMarkdownPreview); const [mdPreview, setMdPreview] = useState(readMarkdownPreview);
useEffect(() => setMdPreview(readMarkdownPreview()), [openFile]); useEffect(() => setMdPreview(isHtml(openFile) || readMarkdownPreview()), [openFile]);
const [data, setData] = useState<LaneChanges | null>(null); const [data, setData] = useState<LaneChanges | null>(null);
// A folder that is not a repo (an empty new workspace, a parent of clones) // A folder that is not a repo (an empty new workspace, a parent of clones)
// has no changes, review or history — only its files, which Files reads // has no changes, review or history — only its files, which Files reads
@@ -2042,8 +2058,16 @@ export function LaneChangesPanel({
}); });
const dirty = draft !== null && draft !== text?.lines.join("\n"); const dirty = draft !== null && draft !== text?.lines.join("\n");
// Rendered only where the toggle is offered: a whole markdown file. // Rendered only where the toggle is offered: a whole markdown or HTML file.
const mdShown = mdPreview && whole && isMarkdown(openFile); const mdShown = mdPreview && whole && (isMarkdown(openFile) || isHtml(openFile));
// An HTML page's relative CSS/scripts/images, read like a download — only
// from the working tree: a commit's page would get today's files.
const htmlFetch = useMemo(
() =>
range === "commit" ? null : fetchVia((p) => api.lanes.gitDownloadUrl(target, p)),
// eslint-disable-next-line react-hooks/exhaustive-deps
[range, JSON.stringify(target)]
);
dirtyRef.current = dirty; dirtyRef.current = dirty;
useUnsavedGuard(dirty); useUnsavedGuard(dirty);
const dirtyChangeRef = useRef(onDirtyChange); const dirtyChangeRef = useRef(onDirtyChange);
@@ -2969,7 +2993,7 @@ export function LaneChangesPanel({
{t("changes.annotate")} {t("changes.annotate")}
</button> </button>
)} )}
{whole && text && !dirty && isMarkdown(openFile) && ( {whole && text && !dirty && (isMarkdown(openFile) || isHtml(openFile)) && (
<button <button
type="button" type="button"
data-testid="lane-file-preview" data-testid="lane-file-preview"
@@ -3113,7 +3137,17 @@ export function LaneChangesPanel({
</Suspense> </Suspense>
) )
) : whole ? ( ) : whole ? (
text && mdShown ? ( text && mdShown && isHtml(openFile) ? (
<HtmlFrame
key={openFile}
path={text.path}
// A cut-off page is read whole through the download.
html={text.truncated && htmlFetch ? undefined : text.lines.join("\n")}
fetchFile={htmlFetch}
title={text.path}
className="rounded border border-border-light"
/>
) : text && mdShown ? (
<div className="min-h-0 flex-1 overflow-auto rounded border border-border-light bg-surface-0 p-4"> <div className="min-h-0 flex-1 overflow-auto rounded border border-border-light bg-surface-0 p-4">
<MarkdownPreview text={text.lines.join("\n")} /> <MarkdownPreview text={text.lines.join("\n")} />
</div> </div>
@@ -9,7 +9,8 @@
* purge there, sending the item's store) and * purge there, sending the item's store) and
* shows a Trash that could not be read as an error, never as empty; nests a * shows a Trash that could not be read as an error, never as empty; nests a
* user's workspaces under their home in the sidebar (under a fold-only row * user's workspaces under their home in the sidebar (under a fold-only row
* when the viewer may not open that home). * when the viewer may not open that home); opens an HTML file as the page
* instead of downloading it.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
@@ -136,6 +137,38 @@ describe("FileExplorer", () => {
expect(api.files.list).toHaveBeenLastCalledWith(1, "src"); expect(api.files.list).toHaveBeenLastCalledWith(1, "src");
}); });
it("opens an HTML file as the page, its stylesheet read through the download", async () => {
vi.mocked(api.files.list).mockResolvedValue({
...root,
entries: [
...root.entries,
{ name: "deck.html", path: "deck.html", type: "file", size: 9, mtime: 0 },
],
});
const body: Record<string, string> = {
"/dl/1/deck.html": '<link rel="stylesheet" href="s.css"><p>deck</p>',
"/dl/1/s.css": "p{color:red}",
};
const fetchSpy = vi
.spyOn(globalThis, "fetch")
.mockImplementation(async (u) => new Response(body[String(u)] ?? "", { status: 200 }));
const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
open();
await screen.findByText("deck.html");
fireEvent.doubleClick(row("deck.html"));
const viewer = await screen.findByTestId("fx-html-viewer");
await waitFor(() =>
expect(within(viewer).getByTestId("html-frame").getAttribute("srcdoc")).toContain(
"<style>p{color:red}</style>"
)
);
expect(click).not.toHaveBeenCalled();
fireEvent.click(within(viewer).getByRole("button", { name: "Close" }));
expect(screen.queryByTestId("fx-html-viewer")).toBeNull();
fetchSpy.mockRestore();
click.mockRestore();
});
it("makes a folder inline", async () => { it("makes a folder inline", async () => {
vi.mocked(api.files.mkdir).mockResolvedValue({ path: "docs" }); vi.mocked(api.files.mkdir).mockResolvedValue({ path: "docs" });
const user = userEvent.setup(); const user = userEvent.setup();
@@ -544,6 +544,34 @@ describe("LaneChangesPanel", () => {
expect(screen.queryByTestId("lane-file-editor")).toBeNull(); expect(screen.queryByTestId("lane-file-editor")).toBeNull();
}); });
it("opens a whole HTML file as the page, its source after Raw", async () => {
vi.mocked(api.lanes.gitTree).mockResolvedValue({
available: true,
path: "",
entries: [{ name: "deck.html", path: "deck.html", dir: false, status: "??" }],
truncated: false,
});
vi.mocked(api.lanes.gitFile).mockResolvedValue({
available: true,
path: "deck.html",
rev: "work",
binary: false,
truncated: false,
lines: ["<p>deck</p>"],
sha: "c".repeat(40),
});
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} full />);
const sidebar = await screen.findByTestId("lane-changes-sidebar");
await user.click(await within(sidebar).findByText("deck.html"));
const frame = await screen.findByTestId("html-frame");
expect(frame.getAttribute("srcdoc")).toContain("<p>deck</p>");
expect(frame.getAttribute("sandbox")).not.toContain("allow-same-origin");
await user.click(screen.getByTestId("lane-file-preview"));
expect(await screen.findByTestId("lane-file-editor")).toHaveValue("<p>deck</p>");
expect(screen.queryByTestId("html-frame")).toBeNull();
});
it("shows an image file as the picture, with no edit control", async () => { it("shows an image file as the picture, with no edit control", async () => {
vi.mocked(api.lanes.gitTree).mockResolvedValue({ vi.mocked(api.lanes.gitTree).mockResolvedValue({
available: true, available: true,
+49 -9
View File
@@ -39,6 +39,7 @@ import { useDialog } from "../../hooks/useDialog";
import { dragSkill } from "../../lib/promptAttach"; import { dragSkill } from "../../lib/promptAttach";
import { readMarkdownPreview } from "../../lib/markdownView"; import { readMarkdownPreview } from "../../lib/markdownView";
import { MarkdownPreview } from "../MarkdownPreview"; import { MarkdownPreview } from "../MarkdownPreview";
import { HtmlFrame, fetchVia } from "../HtmlFrame";
import { CodeBlock } from "../conversation/CodeBlock"; import { CodeBlock } from "../conversation/CodeBlock";
import { useTheme } from "../../hooks/useTheme"; import { useTheme } from "../../hooks/useTheme";
import { toast } from "../../lib/toast"; import { toast } from "../../lib/toast";
@@ -85,6 +86,18 @@ export const extOf = (p: string) => {
export const displayName = (p: string) => export const displayName = (p: string) =>
DROP_RE.test(p) ? baseName(p).replace(/^[0-9a-f]{16}-?/, "") || baseName(p) : baseName(p); DROP_RE.test(p) ? baseName(p).replace(/^[0-9a-f]{16}-?/, "") || baseName(p) : baseName(p);
/** A file inside the pane's folder: the git target that reads it and its path there. */
function workspaceFile(path: string, scope: PreviewScope) {
const file = slashed(path);
const base = slashed(scope.cwd).replace(/\/+$/, "");
if (scope.laneId === null || !base || !file.startsWith(`${base}/`)) return null;
const target =
base === slashed(scope.laneCwd).replace(/\/+$/, "")
? scope.laneId
: gitTarget(scope.laneId, scope.sessionId);
return { target, rel: file.slice(base.length + 1) };
}
type Content = type Content =
| { kind: "loading" } | { kind: "loading" }
| { kind: "image"; src: string } | { kind: "image"; src: string }
@@ -121,8 +134,8 @@ function useContent(path: string, scope: PreviewScope): Content {
}; };
} }
const file = slashed(path); const file = slashed(path);
const base = slashed(scope.cwd).replace(/\/+$/, ""); const inside = workspaceFile(path, scope);
if (scope.laneId === null || !base || !file.startsWith(`${base}/`)) { if (!inside) {
// Claude Code's own files (memory, a skill): the config pages' guarded read. // Claude Code's own files (memory, a skill): the config pages' guarded read.
if (/\/\.claude\//.test(file) && !IMAGE_RE.test(file)) { if (/\/\.claude\//.test(file) && !IMAGE_RE.test(file)) {
show( show(
@@ -147,11 +160,7 @@ function useContent(path: string, scope: PreviewScope): Content {
live = false; live = false;
}; };
} }
const rel = file.slice(base.length + 1); const { target, rel } = inside;
const target =
base === slashed(scope.laneCwd).replace(/\/+$/, "")
? scope.laneId
: gitTarget(scope.laneId, scope.sessionId);
if (IMAGE_RE.test(path)) { if (IMAGE_RE.test(path)) {
setContent({ kind: "image", src: api.lanes.gitRawUrl(target, rel) }); setContent({ kind: "image", src: api.lanes.gitRawUrl(target, rel) });
return; return;
@@ -235,9 +244,28 @@ function defaultView(path: string): View {
return kind === "markdown" && !readMarkdownPreview() ? "code" : "preview"; return kind === "markdown" && !readMarkdownPreview() ? "code" : "preview";
} }
function Body({ path, content, view }: { path: string; content: Content; view: View }) { function Body({
path,
content,
view,
scope,
}: {
path: string;
content: Content;
view: View;
scope: PreviewScope;
}) {
const { t } = useTranslation("run"); const { t } = useTranslation("run");
const kind = artifactKind(path); const kind = artifactKind(path);
// A page in the workspace brings its relative CSS, scripts and images along.
const inside = kind === "html" ? workspaceFile(path, scope) : null;
const insideTarget = inside ? JSON.stringify(inside.target) : null;
const page = useMemo(() => {
if (!inside) return null;
const { target, rel } = inside;
return { rel, fetchFile: fetchVia((p) => api.lanes.gitDownloadUrl(target, p)) };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [insideTarget, inside?.rel]);
// An image the route refused (gone, server mode) shows like no preview. // An image the route refused (gone, server mode) shows like no preview.
const [broken, setBroken] = useState<string | null>(null); const [broken, setBroken] = useState<string | null>(null);
const shown: Content = const shown: Content =
@@ -290,6 +318,18 @@ function Body({ path, content, view }: { path: string; content: Content; view: V
); );
} }
if (kind === "csv") return <CsvTable text={shown.text} tab={/\.tsv$/i.test(path)} />; if (kind === "csv") return <CsvTable text={shown.text} tab={/\.tsv$/i.test(path)} />;
if (page && !shown.truncated) {
return (
<HtmlFrame
key={path}
path={page.rel}
html={shown.text}
fetchFile={page.fetchFile}
title={baseName(path)}
testId="artifact-frame"
/>
);
}
return <ArtifactFrame kind={kind} text={shown.text} title={baseName(path)} />; return <ArtifactFrame kind={kind} text={shown.text} title={baseName(path)} />;
} }
@@ -503,7 +543,7 @@ export function FilePreview({
</button> </button>
</div> </div>
</header> </header>
<Body path={path} content={content} view={view} /> <Body path={path} content={content} view={view} scope={scope} />
</section> </section>
); );
// Full screen: over the whole page, where a pane's edges cannot clip it. // Full screen: over the whole page, where a pane's edges cannot clip it.
@@ -242,7 +242,9 @@ describe("the artifact view", () => {
((await screen.findByTestId("artifact-frame")) as HTMLIFrameElement).getAttribute("srcdoc") ((await screen.findByTestId("artifact-frame")) as HTMLIFrameElement).getAttribute("srcdoc")
).toContain("two"); ).toContain("two");
fireEvent.click(versions.querySelector("button")!); fireEvent.click(versions.querySelector("button")!);
expect(screen.getByTestId("artifact-frame").getAttribute("srcdoc")).toContain("<p>one</p>"); await vi.waitFor(() =>
expect(screen.getByTestId("artifact-frame").getAttribute("srcdoc")).toContain("<p>one</p>")
);
cleanup(); cleanup();
}); });
@@ -0,0 +1,53 @@
/**
* @file htmlInline.test.ts
* @description An HTML preview's relative CSS, scripts and images are inlined
* from the place's own files, never from above its root or off a URL.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, expect, it, vi } from "vitest";
import { inlineHtml, resolveRef } from "../htmlInline";
const files: Record<string, string> = {
"site/css/app.css": "body{background:url(../img/bg.png)}",
"site/js/app.js": "window.ok=1",
"site/img/logo.png": "PNG",
"site/img/bg.png": "BG",
};
const fetchFile = vi.fn(async (p: string) => {
if (!(p in files)) throw new Error("404");
return new Blob([files[p]!]);
});
describe("resolveRef", () => {
it("resolves against the page's folder, from the root on a leading slash", () => {
expect(resolveRef("site", "css/a.css?v=2#x")).toBe("site/css/a.css");
expect(resolveRef("site/sub", "../img/a%20b.png")).toBe("site/img/a b.png");
expect(resolveRef("site", "/top.css")).toBe("top.css");
});
it("skips URLs, anchors and anything above the root", () => {
for (const r of ["https://x/a.js", "//cdn/a.js", "data:x", "#top", "", "../../etc/passwd"])
expect(resolveRef("site", r)).toBeNull();
});
});
describe("inlineHtml", () => {
it("inlines stylesheets (with their url()s), scripts and images", async () => {
const html = `<!doctype html><html><head><link rel="stylesheet" href="css/app.css">
<script src="js/app.js"></script><script src="https://cdn.example/x.js"></script></head>
<body><img src="img/logo.png" srcset="img/logo@2x.png 2x"><img src="missing.png"></body></html>`;
const out = await inlineHtml(html, "site/index.html", fetchFile);
expect(out.startsWith("<!doctype html>")).toBe(true);
expect(out).toContain(`<style>body{background:url("data:image/png;base64,${btoa("BG")}")}</style>`);
expect(out).toContain(`src="data:text/javascript;base64,${btoa("window.ok=1")}"`);
expect(out).toContain(`src="data:image/png;base64,${btoa("PNG")}"`);
expect(out).not.toContain("srcset");
expect(out).toContain('src="https://cdn.example/x.js"');
expect(out).toContain('src="missing.png"');
expect(fetchFile).not.toHaveBeenCalledWith(expect.stringContaining("cdn"));
});
it("returns the page untouched when nothing is local", async () => {
const html = "<p>hi <a href='https://x'>x</a></p>";
expect(await inlineHtml(html, "a.html", fetchFile)).toBe(html);
});
});
+230
View File
@@ -0,0 +1,230 @@
/**
* @file htmlInline.ts
* @description An HTML file made whole for its sandboxed preview. The page
* runs in an `<iframe srcdoc>` with no same-origin access, so it can fetch
* nothing from the dashboard — and the dashboard's login cookie is
* SameSite=Strict, so even a URL it could reach would answer 401. Instead the
* DASHBOARD reads the page's relative stylesheets, scripts, images, media and
* the `url(...)`s inside its CSS through a read route that already guards
* them (the caller's `fetchFile`: a workspace's or a home's download), and
* writes them into the page as `<style>` and `data:` URLs before it renders.
* Only paths inside the page's own place are followed (`..` above its root is
* skipped), and a page can pull at most `MAX_ASSETS` files / `MAX_BYTES` in
* all, so a big site never stalls the viewer. A reference that cannot be read
* is left as it was. `fetch()`/XHR from the page's own script, ES module
* imports and links to other pages still do not work.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/** Reads one file by its path relative to the place's root; throws when it cannot. */
export type FetchFile = (relPath: string) => Promise<Blob>;
const MAX_ASSETS = 60;
const MAX_BYTES = 30 * 1024 * 1024;
const MIME: Record<string, string> = {
css: "text/css",
js: "text/javascript",
mjs: "text/javascript",
json: "application/json",
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
webp: "image/webp",
avif: "image/avif",
bmp: "image/bmp",
ico: "image/x-icon",
svg: "image/svg+xml",
woff: "font/woff",
woff2: "font/woff2",
ttf: "font/ttf",
otf: "font/otf",
mp4: "video/mp4",
webm: "video/webm",
mp3: "audio/mpeg",
wav: "audio/wav",
ogg: "audio/ogg",
};
const mimeOf = (p: string) =>
MIME[(/\.([A-Za-z0-9]+)$/.exec(p)?.[1] || "").toLowerCase()] ?? "application/octet-stream";
/** The folder part of a relative path ("" at the root). */
export const dirOf = (p: string) => (p.includes("/") ? p.slice(0, p.lastIndexOf("/")) : "");
/**
* A page's reference as a path from the place's root, or null when it is not
* a local file (a URL, `//host`, `data:`, an anchor) or climbs above the root.
* A leading `/` counts from the root, as a site served from it would.
*/
export function resolveRef(dir: string, ref: string): string | null {
const r = ref.trim();
if (!r || r.startsWith("#") || r.startsWith("//") || /^[a-z][a-z0-9+.-]*:/i.test(r)) return null;
let p = r.replace(/[?#].*$/, "");
try {
p = decodeURIComponent(p);
} catch {
// A stray % is a literal one.
}
const out: string[] = [];
for (const seg of (p.startsWith("/") ? p : `${dir}/${p}`).split("/")) {
if (!seg || seg === ".") continue;
if (seg === "..") {
if (!out.length) return null;
out.pop();
} else out.push(seg);
}
return out.length ? out.join("/") : null;
}
/** A Blob's text / bytes, through FileReader where Blob lacks the methods (jsdom). */
export function blobText(b: Blob): Promise<string> {
if (typeof b.text === "function") return b.text();
return new Promise((ok, fail) => {
const r = new FileReader();
r.onload = () => ok(String(r.result));
r.onerror = () => fail(r.error);
r.readAsText(b);
});
}
function blobBytes(b: Blob): Promise<ArrayBuffer> {
if (typeof b.arrayBuffer === "function") return b.arrayBuffer();
return new Promise((ok, fail) => {
const r = new FileReader();
r.onload = () => ok(r.result as ArrayBuffer);
r.onerror = () => fail(r.error);
r.readAsArrayBuffer(b);
});
}
function base64(buf: ArrayBuffer): string {
const bytes = new Uint8Array(buf);
let s = "";
for (let i = 0; i < bytes.length; i += 0x8000) {
s += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
}
return btoa(s);
}
const URL_RE = /url\(\s*(['"]?)([^'")]+?)\1\s*\)/gi;
/** Inline the relative resources of `html` (the file at `htmlPath`). */
export async function inlineHtml(
html: string,
htmlPath: string,
fetchFile: FetchFile
): Promise<string> {
const doc = new DOMParser().parseFromString(html, "text/html");
const cache = new Map<string, Promise<Blob | null>>();
let count = 0;
let bytes = 0;
let changed = false;
const read = (rel: string): Promise<Blob | null> => {
let p = cache.get(rel);
if (!p) {
if (count >= MAX_ASSETS) return Promise.resolve(null);
count++;
p = fetchFile(rel)
.then((b) => {
if (bytes + b.size > MAX_BYTES) return null;
bytes += b.size;
return b;
})
.catch(() => null);
cache.set(rel, p);
}
return p;
};
const dataUrl = async (rel: string): Promise<string | null> => {
const b = await read(rel);
return b ? `data:${mimeOf(rel)};base64,${base64(await blobBytes(b))}` : null;
};
/** CSS with its own relative `url(...)`s made data URLs (from the CSS's folder). */
const inlineCss = async (css: string, dir: string): Promise<string> => {
const refs = new Map<string, string | null>();
for (const m of css.matchAll(URL_RE)) {
const rel = resolveRef(dir, m[2]!);
if (rel && !refs.has(rel)) refs.set(rel, null);
}
await Promise.all([...refs.keys()].map(async (rel) => refs.set(rel, await dataUrl(rel))));
return css.replace(URL_RE, (all, _q, ref: string) => {
const rel = resolveRef(dir, ref);
const url = rel ? refs.get(rel) : null;
return url ? `url("${url}")` : all;
});
};
const dir = dirOf(htmlPath);
const jobs: Promise<void>[] = [];
for (const link of doc.querySelectorAll<HTMLLinkElement>("link[rel~='stylesheet' i][href]")) {
const rel = resolveRef(dir, link.getAttribute("href")!);
if (!rel) continue;
jobs.push(
(async () => {
const b = await read(rel);
if (!b) return;
const style = doc.createElement("style");
const media = link.getAttribute("media");
if (media) style.setAttribute("media", media);
style.textContent = await inlineCss(await blobText(b), dirOf(rel));
link.replaceWith(style);
changed = true;
})()
);
}
for (const style of doc.querySelectorAll("style")) {
const css = style.textContent || "";
if (!/url\(/i.test(css)) continue;
jobs.push(
inlineCss(css, dir).then((out) => {
if (out !== css) {
style.textContent = out;
changed = true;
}
})
);
}
for (const el of doc.querySelectorAll<HTMLElement>("[style*='url(' i]")) {
const css = el.getAttribute("style")!;
jobs.push(
inlineCss(css, dir).then((out) => {
if (out !== css) {
el.setAttribute("style", out);
changed = true;
}
})
);
}
const attrs: [string, string][] = [
["script[src]", "src"],
["img[src]", "src"],
["source[src]", "src"],
["video[src]", "src"],
["audio[src]", "src"],
["video[poster]", "poster"],
["input[type='image' i][src]", "src"],
["link[rel~='icon' i][href]", "href"],
];
for (const [sel, attr] of attrs) {
for (const el of doc.querySelectorAll(sel)) {
const rel = resolveRef(dir, el.getAttribute(attr)!);
if (!rel) continue;
jobs.push(
dataUrl(rel).then((url) => {
if (!url) return;
el.setAttribute(attr, url);
// A srcset beside it would win over the inlined src and still miss.
if (el.hasAttribute("srcset")) el.removeAttribute("srcset");
changed = true;
})
);
}
}
await Promise.all(jobs);
if (!changed) return html;
const doctype = /^\s*(?:<!--[\s\S]*?-->\s*)*<!doctype/i.test(html) ? "<!doctype html>\n" : "";
return doctype + doc.documentElement.outerHTML;
}
@@ -7316,7 +7316,7 @@ exports[`screen snapshots > Settings — about 1`] = `
<span <span
class="text-status-warning/90" class="text-status-warning/90"
> >
UI build v2.8.31 (rebuild client to match) UI build v2.8.33 (rebuild client to match)
</span> </span>
</p> </p>
</div> </div>
+1 -1
View File
@@ -2588,7 +2588,7 @@ curl "http://localhost:4820/api/pricing/cost?tz_offset=-420&from=2026-09-01&to=2
GET /api/pricing/quota[?force=1] 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 read from the rate-limit tier and seat ("Max 5x", "Team Standard", "Team Premium", else `subscriptionType` title-cased; `subscriptionType` alone says `team` for both seats and keeps `pro` after an upgrade), 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?, 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.
#### Analytics #### Analytics
```http ```http
+12 -2
View File
@@ -677,8 +677,18 @@ reads the workspace's git surface in five modes:
line-comment buttons — Review keeps them for every file. In a read-only side-by-side diff, every row is at line-comment buttons — Review keeps them for every file. In a read-only side-by-side diff, every row is at
least one line tall (`MIN_ROW`), so an empty filler row keeps the two least one line tall (`MIN_ROW`), so an empty filler row keeps the two
columns aligned. An image file (png, jpg, gif, webp, avif, bmp, ico, svg) is columns aligned. An image file (png, jpg, gif, webp, avif, bmp, ico, svg) is
shown as the picture (`/git/raw`), and a conflicted file opens in the shown as the picture (`/git/raw`), an HTML file (`.html`/`.htm`) opens
conflict view. **Cancel** reverts unsaved edits. **Save** (or as the page itself — **Raw** shows its source in the editor, **Preview**
brings the page back — and a conflicted file opens in the
conflict view. The page (here, in a Chat file preview, and on a
double-click in the File Explorer, whose right-click menu keeps
**Download**) runs in the chat artifact's sandbox (`HtmlFrame.tsx`:
`<iframe srcdoc>`, scripts but never `allow-same-origin`); its relative
stylesheets (and their `url()`s), scripts, images, media and icons are
read by the dashboard through the same download route and written into
it as `<style>`/`data:` URLs (`client/src/lib/htmlInline.ts`: never above
the folder's root, at most 60 files / 30 MB a page). The page's own
`fetch()`, ES module imports and links to other pages do not resolve. **Cancel** reverts unsaved edits. **Save** (or
Ctrl/Cmd+S) is `PUT /api/lanes/:id/git/file`, which writes only over the Ctrl/Cmd+S) is `PUT /api/lanes/:id/git/file`, which writes only over the
exact version that was opened — a file the agent changed meanwhile answers exact version that was opened — a file the agent changed meanwhile answers
`409 ESTALE` and the draft stays for a retry — and keeps the file's CRLF `409 ESTALE` and the draft stays for a retry — and keeps the file's CRLF
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "agenthub", "name": "agenthub",
"version": "2.8.31", "version": "2.8.33",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "agenthub", "name": "agenthub",
"version": "2.8.31", "version": "2.8.33",
"hasInstallScript": true, "hasInstallScript": true,
"license": "UNLICENSED", "license": "UNLICENSED",
"dependencies": { "dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "agenthub", "name": "agenthub",
"version": "2.8.31", "version": "2.8.33",
"bin": { "bin": {
"agenthub": "bin/agenthub.js" "agenthub": "bin/agenthub.js"
}, },
+36 -2
View File
@@ -448,8 +448,14 @@ describe("cc-profiles — identity and token disagree", () => {
it("planLabel reads the plan from the rate-limit tier and seat, not subscriptionType alone", () => { it("planLabel reads the plan from the rate-limit tier and seat, not subscriptionType alone", () => {
const { planLabel } = profiles; const { planLabel } = profiles;
// An upgrade keeps `subscriptionType: "pro"` from sign-in; the tier is the truth. // An upgrade keeps `subscriptionType: "pro"` from sign-in; the tier is the truth.
assert.strictEqual(planLabel({ subscriptionType: "pro", rateLimitTier: "default_claude_max_5x" }, null), "Max 5x"); assert.strictEqual(
assert.strictEqual(planLabel({ subscriptionType: "max", rateLimitTier: "default_claude_max_20x" }, null), "Max 20x"); planLabel({ subscriptionType: "pro", rateLimitTier: "default_claude_max_5x" }, null),
"Max 5x"
);
assert.strictEqual(
planLabel({ subscriptionType: "max", rateLimitTier: "default_claude_max_20x" }, null),
"Max 20x"
);
assert.strictEqual(planLabel({ subscriptionType: "pro" }, null), "Pro"); assert.strictEqual(planLabel({ subscriptionType: "pro" }, null), "Pro");
const team = { subscriptionType: "team", rateLimitTier: "default_raven" }; const team = { subscriptionType: "team", rateLimitTier: "default_raven" };
assert.strictEqual(planLabel(team, { seatTier: "team_standard" }), "Team Standard"); assert.strictEqual(planLabel(team, { seatTier: "team_standard" }), "Team Standard");
@@ -462,3 +468,31 @@ it("planLabel reads the plan from the rate-limit tier and seat, not subscription
assert.strictEqual(planLabel(team, null), "Team"); assert.strictEqual(planLabel(team, null), "Team");
assert.strictEqual(planLabel(null, null), null); assert.strictEqual(planLabel(null, null), null);
}); });
it("the profile endpoint's live plan wins over files written at sign-in", () => {
const { planFromProfile, rememberLivePlan, shownPlan } = profiles;
assert.strictEqual(
planFromProfile({ organization_type: "claude_max", rate_limit_tier: "default_claude_max_5x" }),
"Max 5x"
);
assert.strictEqual(
planFromProfile({
organization_type: "claude_team",
rate_limit_tier: "default_raven",
seat_tier: "team_standard",
}),
"Team Standard"
);
assert.strictEqual(planFromProfile({ organization_type: "claude_pro" }), "Pro");
assert.strictEqual(planFromProfile(null), null);
// Signed in on Pro, upgraded to Max 5x since: the files still say Pro.
const oauth = { subscriptionType: "pro", rateLimitTier: "default_claude_pro" };
const account = { accountUuid: "acct-upgraded" };
assert.strictEqual(shownPlan(oauth, account), "Pro");
rememberLivePlan("acct-upgraded", "Max 5x");
assert.strictEqual(shownPlan(oauth, account), "Max 5x");
rememberLivePlan("acct-upgraded", null); // an unknown answer keeps the last one
assert.strictEqual(shownPlan(oauth, account), "Max 5x");
assert.strictEqual(shownPlan(oauth, { accountUuid: "other" }), "Pro");
});
+3
View File
@@ -26,6 +26,7 @@ const pty = require("../lib/pty-run");
const TOKEN = (c) => `sk-ant-oat01-${c.repeat(40)}`; const TOKEN = (c) => `sk-ant-oat01-${c.repeat(40)}`;
const realFetchUsage = ccUsage.fetchUsage; const realFetchUsage = ccUsage.fetchUsage;
const realFetchOwner = ccUsage.fetchOwner;
function run(id, account, { status = "completed", session = null, started, ended = null } = {}) { function run(id, account, { status = "completed", session = null, started, ended = null } = {}) {
db.prepare( db.prepare(
@@ -61,10 +62,12 @@ before(() => {
ccUsage.fetchUsage = async () => { ccUsage.fetchUsage = async () => {
throw Object.assign(new Error("offline"), { code: "EUNAUTHORIZED" }); throw Object.assign(new Error("offline"), { code: "EUNAUTHORIZED" });
}; };
ccUsage.fetchOwner = async () => null;
}); });
after(() => { after(() => {
ccUsage.fetchUsage = realFetchUsage; ccUsage.fetchUsage = realFetchUsage;
ccUsage.fetchOwner = realFetchOwner;
tmux.__setExecImpl(null); tmux.__setExecImpl(null);
fs.rmSync(TMP, { recursive: true, force: true }); fs.rmSync(TMP, { recursive: true, force: true });
}); });
@@ -30,6 +30,7 @@ const claudePool = require("../lib/claude-pool");
const pins = require("../lib/account-pins"); const pins = require("../lib/account-pins");
require("../lib/cc-usage").fetchUsage = async () => ({}); require("../lib/cc-usage").fetchUsage = async () => ({});
require("../lib/cc-usage").fetchOwner = async () => null;
const KEY = (c) => `key_${c.repeat(40)}`; const KEY = (c) => `key_${c.repeat(40)}`;
const TOKEN = (c) => `sk-ant-oat01-${c.repeat(40)}`; const TOKEN = (c) => `sk-ant-oat01-${c.repeat(40)}`;
+1
View File
@@ -30,6 +30,7 @@ const claudePool = require("../lib/claude-pool");
const pins = require("../lib/account-pins"); const pins = require("../lib/account-pins");
require("../lib/cc-usage").fetchUsage = async () => ({}); require("../lib/cc-usage").fetchUsage = async () => ({});
require("../lib/cc-usage").fetchOwner = async () => null;
const BOSS_PASS = "admin-pass-1"; const BOSS_PASS = "admin-pass-1";
const KEY = (c) => `key_${c.repeat(40)}`; const KEY = (c) => `key_${c.repeat(40)}`;
+59
View File
@@ -170,6 +170,65 @@ test("a profile holding another account's token shows EWRONGACCOUNT, not that ac
} }
}); });
test("the live plan is the profile endpoint's, not the one written at sign-in", async () => {
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { quotas, __resetCache } = require("../lib/quota");
const root = fs.mkdtempSync(path.join(os.tmpdir(), "quota-plan-"));
const home = path.join(root, ".claude");
fs.mkdirSync(home, { recursive: true });
// Signed in on Pro; upgraded to Max 5x since — the files never learned.
fs.writeFileSync(
path.join(home, ".credentials.json"),
JSON.stringify({
claudeAiOauth: {
accessToken: "live-upgraded",
expiresAt: Date.now() + 3600_000,
subscriptionType: "pro",
rateLimitTier: "default_claude_pro",
},
})
);
fs.writeFileSync(
`${home}.json`,
JSON.stringify({ oauthAccount: { accountUuid: "uuid-up", emailAddress: "up@x.y" } })
);
const prev = { home: process.env.CLAUDE_HOME, fetch: global.fetch };
process.env.CLAUDE_HOME = home;
global.fetch = async (url) => ({
status: 200,
ok: true,
json: async () =>
url.endsWith("/profile")
? {
account: { uuid: "uuid-up", email: "up@x.y" },
organization: {
organization_type: "claude_max",
rate_limit_tier: "default_claude_max_5x",
},
}
: { five_hour: { utilization: 10, resets_at: null } },
});
try {
__resetCache();
const live = (await quotas()).tools.find((t) => t.id === "claude" && t.active);
assert.strictEqual(live.plan, "Max 5x");
// Remembered: a later read from the files alone says the same.
const { shownPlan } = require("../lib/cc-profiles");
assert.strictEqual(
shownPlan({ subscriptionType: "pro" }, { accountUuid: "uuid-up" }),
"Max 5x"
);
} finally {
if (prev.home === undefined) delete process.env.CLAUDE_HOME;
else process.env.CLAUDE_HOME = prev.home;
global.fetch = prev.fetch;
__resetCache();
fs.rmSync(root, { recursive: true, force: true });
}
});
test("the live row is the account its token belongs to, not the one .claude.json names", async () => { test("the live row is the account its token belongs to, not the one .claude.json names", async () => {
const fs = require("node:fs"); const fs = require("node:fs");
const os = require("node:os"); const os = require("node:os");
@@ -29,6 +29,7 @@ const cursorPool = require("../lib/cursor-pool");
// The quota read would go to the network; the pool's list must not. // The quota read would go to the network; the pool's list must not.
require("../lib/cc-usage").fetchUsage = async () => ({}); require("../lib/cc-usage").fetchUsage = async () => ({});
require("../lib/cc-usage").fetchOwner = async () => null;
let server; let server;
let BASE; let BASE;
@@ -26,6 +26,7 @@ const auth = require("../lib/auth");
// The quota read would go to the network. // The quota read would go to the network.
require("../lib/cc-usage").fetchUsage = async () => ({}); require("../lib/cc-usage").fetchUsage = async () => ({});
require("../lib/cc-usage").fetchOwner = async () => null;
let server; let server;
let BASE; let BASE;
+39 -2
View File
@@ -220,6 +220,40 @@ function planLabel(oauth, account) {
return sub ? cap(sub) : null; return sub ? cap(sub) : null;
} }
/**
* The plan Anthropic's profile endpoint reports for an account RIGHT NOW,
* keyed by account uuid (cc-usage.js `fetchOwner` fills it). The stored
* `subscriptionType` and rate-limit tiers are written at sign-in, so an
* account upgraded since (Pro → Max 5x) keeps reading as its old plan from
* the files alone; the live answer wins wherever one was seen.
*/
const livePlans = new Map();
/**
* `planLabel` of the profile endpoint's `organization` (`organization_type`
* `claude_max`, `rate_limit_tier` `default_claude_max_5x`, `seat_tier`), or null.
*/
function planFromProfile(org) {
if (!org || typeof org !== "object") return null;
const type = String(org.organization_type || "");
return planLabel(
{ subscriptionType: type.replace(/^claude_/, ""), rateLimitTier: org.rate_limit_tier || "" },
{ seatTier: org.seat_tier || "", organizationType: type }
);
}
/** Remember an account's live plan (null/unknown leaves the last one). */
function rememberLivePlan(accountUuid, plan) {
if (!accountUuid || !plan) return;
if (livePlans.size > 100) livePlans.clear(); // a few accounts per machine
livePlans.set(accountUuid, plan);
}
/** The plan to show: the live one when seen, else what the files say. */
function shownPlan(oauth, account) {
return livePlans.get(account?.accountUuid) || planLabel(oauth, account);
}
/** Token-free description of an account, safe to send to the browser. */ /** Token-free description of an account, safe to send to the browser. */
function identityOf({ oauth, account }) { function identityOf({ oauth, account }) {
return { return {
@@ -229,8 +263,8 @@ function identityOf({ oauth, account }) {
organization: (account && account.organizationName) || null, organization: (account && account.organizationName) || null,
seatTier: (account && account.seatTier) || null, seatTier: (account && account.seatTier) || null,
subscriptionType: (oauth && oauth.subscriptionType) || null, subscriptionType: (oauth && oauth.subscriptionType) || null,
/** The readable plan (`planLabel`) — what the UI shows. */ /** The readable plan (`shownPlan`: live when seen, else `planLabel`) — what the UI shows. */
plan: planLabel(oauth, account), plan: shownPlan(oauth, account),
/** When the stored access token expires; a switch still works after that /** When the stored access token expires; a switch still works after that
* (the refresh token is stored too), it just refreshes on first use. */ * (the refresh token is stored too), it just refreshes on first use. */
expiresAt: (oauth && oauth.expiresAt) || null, expiresAt: (oauth && oauth.expiresAt) || null,
@@ -588,6 +622,9 @@ module.exports = {
liveCredentialsFor, liveCredentialsFor,
readActiveAuth, readActiveAuth,
planLabel, planLabel,
planFromProfile,
rememberLivePlan,
shownPlan,
// exported for tests // exported for tests
profilesDir, profilesDir,
configPaths, configPaths,
+9 -3
View File
@@ -165,7 +165,8 @@ async function fetchUsage(accessToken) {
const owners = new Map(); const owners = new Map();
/** /**
* The account a token belongs to — `{uuid, email}` — or null when the profile * The account a token belongs to — `{uuid, email, plan}` (`plan` as the
* profile endpoint reports it now, see cc-profiles.js `livePlans`) — or null when the profile
* endpoint does not say. A saved profile's label and its token come from two * endpoint does not say. A saved profile's label and its token come from two
* different files (`oauthAccount` and the credentials store), and a running * different files (`oauthAccount` and the credentials store), and a running
* Claude Code session can leave them naming different accounts, so the token's * Claude Code session can leave them naming different accounts, so the token's
@@ -179,9 +180,14 @@ async function fetchOwner(accessToken) {
signal: AbortSignal.timeout(TIMEOUT_MS), signal: AbortSignal.timeout(TIMEOUT_MS),
}); });
if (!res.ok) return null; if (!res.ok) return null;
const account = (await res.json())?.account; const body = await res.json();
const account = body?.account;
if (!account?.uuid) return null; if (!account?.uuid) return null;
const owner = { uuid: account.uuid, email: account.email || null }; // The plan as it is NOW (an upgrade since sign-in included): the stored
// files keep the plan the account had when it signed in.
const plan = profiles.planFromProfile(body.organization);
profiles.rememberLivePlan(account.uuid, plan);
const owner = { uuid: account.uuid, email: account.email || null, plan };
if (owners.size > 50) owners.clear(); // ponytail: crude cap; a few accounts per machine if (owners.size > 50) owners.clear(); // ponytail: crude cap; a few accounts per machine
owners.set(accessToken, owner); owners.set(accessToken, owner);
return owner; return owner;
+8 -3
View File
@@ -379,7 +379,7 @@ const inFlight = new Set();
/** Read every account's quota whose reading is older than QUOTA_TTL_MS. */ /** Read every account's quota whose reading is older than QUOTA_TTL_MS. */
async function refreshQuotas({ force = false } = {}) { async function refreshQuotas({ force = false } = {}) {
const { fetchUsage } = require("./cc-usage"); const { fetchUsage, fetchOwner } = require("./cc-usage");
await Promise.all( await Promise.all(
allAccounts().map(async (a) => { allAccounts().map(async (a) => {
const cached = quotaCache.get(a.name); const cached = quotaCache.get(a.name);
@@ -388,7 +388,12 @@ async function refreshQuotas({ force = false } = {}) {
} }
inFlight.add(a.name); inFlight.add(a.name);
try { try {
const u = await fetchUsage(bearerOf(a)); // A login's owner too: the profile endpoint's answer is its plan as it
// is now (cc-profiles.js livePlans), the stored one is the sign-in's.
const [u] = await Promise.all([
fetchUsage(bearerOf(a)),
a.kind === "login" ? fetchOwner(bearerOf(a)) : null,
]);
quotaCache.set(a.name, { at: Date.now(), ...u }); quotaCache.set(a.name, { at: Date.now(), ...u });
} catch (e) { } catch (e) {
// Keep the last good windows: a hiccup must not unblock a full account. // Keep the last good windows: a hiccup must not unblock a full account.
@@ -436,7 +441,7 @@ function poolStatus() {
kind: a.kind, kind: a.kind,
email: a.kind === "login" ? (a.account?.emailAddress ?? null) : null, email: a.kind === "login" ? (a.account?.emailAddress ?? null) : null,
// "Max 5x", "Team Premium"…; a setup-token says nothing of its plan. // "Max 5x", "Team Premium"…; a setup-token says nothing of its plan.
plan: a.kind === "login" ? require("./cc-profiles").planLabel(a.oauth, a.account) : null, plan: a.kind === "login" ? require("./cc-profiles").shownPlan(a.oauth, a.account) : null,
refreshError: a.kind === "login" ? (a.refreshError ?? null) : null, refreshError: a.kind === "login" ? (a.refreshError ?? null) : null,
addedAt: a.addedAt, addedAt: a.addedAt,
expiresAt: expiresAt(a), expiresAt: expiresAt(a),
+12 -7
View File
@@ -36,7 +36,7 @@
* `AGENTHUB_AGY_USAGE=0` never runs it. * `AGENTHUB_AGY_USAGE=0` never runs it.
* *
* Each tool also carries its signed-in `account` (an email), its `plan` (the * Each tool also carries its signed-in `account` (an email), its `plan` (the
* subscription's name: Claude's `planLabel` (tier + seat, not the coarse `subscriptionType`), * subscription's name: Claude's plan as the profile endpoint reports it now, else `planLabel` of the stored files (tier + seat, not the coarse `subscriptionType`),
* Codex's `chatgpt_plan_type` claim, Cursor's `membershipType`) and * Codex's `chatgpt_plan_type` claim, Cursor's `membershipType`) and
* `expiresAt`, when its stored access token runs out — Claude's * `expiresAt`, when its stored access token runs out — Claude's
* `claudeAiOauth.expiresAt`, Codex's and Cursor's access-token `exp` (claims * `claudeAiOauth.expiresAt`, Codex's and Cursor's access-token `exp` (claims
@@ -60,7 +60,7 @@ const { execFile } = require("node:child_process");
const { promisify } = require("node:util"); const { promisify } = require("node:util");
const aiTools = require("./ai-tools"); const aiTools = require("./ai-tools");
const { getCodexHome } = require("./codex-home"); const { getCodexHome } = require("./codex-home");
const { readActiveAuth, readProfile, listProfiles, planLabel } = require("./cc-profiles"); const { readActiveAuth, readProfile, listProfiles, shownPlan } = require("./cc-profiles");
const { fetchUsage, fetchOwner } = require("./cc-usage"); const { fetchUsage, fetchOwner } = require("./cc-usage");
const { allRolloutFiles } = require("./codex-ingest"); const { allRolloutFiles } = require("./codex-ingest");
const { getCursorDir } = require("./cursor-usage"); const { getCursorDir } = require("./cursor-usage");
@@ -122,7 +122,8 @@ async function usageFor(token, uuid) {
code: "EWRONGACCOUNT", code: "EWRONGACCOUNT",
}); });
} }
return claudeWindows(usage); // The plan as the profile endpoint says it now (null: it did not answer).
return { windows: claudeWindows(usage), plan: owner?.plan || null };
} }
/** A member's Claude login: `<home>/.claude/.credentials.json` + `.claude.json`. */ /** A member's Claude login: `<home>/.claude/.credentials.json` + `.claude.json`. */
@@ -142,7 +143,7 @@ async function liveClaude(readAuth = readActiveAuth) {
if (!oauth || !oauth.accessToken) return null; // API-key user or not logged in if (!oauth || !oauth.accessToken) return null; // API-key user or not logged in
who = { who = {
account: account?.emailAddress || account?.displayName || null, account: account?.emailAddress || account?.displayName || null,
plan: planLabel(oauth, account), plan: shownPlan(oauth, account),
expiresAt: iso(oauth.expiresAt), expiresAt: iso(oauth.expiresAt),
}; };
if (!fresh(oauth.expiresAt)) return { ...who, windows: [], error: "ELIVEEXPIRED" }; if (!fresh(oauth.expiresAt)) return { ...who, windows: [], error: "ELIVEEXPIRED" };
@@ -154,6 +155,8 @@ async function liveClaude(readAuth = readActiveAuth) {
return { return {
...who, ...who,
account: owner?.email || who.account, account: owner?.email || who.account,
// Live: the files keep the plan of the sign-in, not of an upgrade since.
plan: owner?.plan || who.plan,
ownerUuid: owner?.uuid || account?.accountUuid || null, ownerUuid: owner?.uuid || account?.accountUuid || null,
windows: claudeWindows(usage), windows: claudeWindows(usage),
}; };
@@ -183,9 +186,9 @@ async function profileClaude(item) {
} }
if (creds?.accessToken && fresh(creds.expiresAt)) { if (creds?.accessToken && fresh(creds.expiresAt)) {
try { try {
const windows = await usageFor(creds.accessToken, item.accountUuid); const { windows, plan } = await usageFor(creds.accessToken, item.accountUuid);
lastGood.set(item.name, { windows, at: Date.now() }); lastGood.set(item.name, { windows, at: Date.now() });
return { ...who, windows }; return { ...who, plan: plan || who.plan, windows };
} catch (e) { } catch (e) {
return { ...who, windows: [], error: e.code || "EUPSTREAM" }; return { ...who, windows: [], error: e.code || "EUPSTREAM" };
} }
@@ -289,7 +292,9 @@ function jwtClaims(token) {
/** Codex's ChatGPT login from `auth.json`: email, plan + access-token expiry. */ /** Codex's ChatGPT login from `auth.json`: email, plan + access-token expiry. */
function codexAccount(codexHome = getCodexHome()) { function codexAccount(codexHome = getCodexHome()) {
try { try {
const { tokens } = JSON.parse(readFileSafe(path.join(codexHome, "auth.json"), "utf8", { max: 1 << 20 })); const { tokens } = JSON.parse(
readFileSafe(path.join(codexHome, "auth.json"), "utf8", { max: 1 << 20 })
);
const { exp } = jwtClaims(tokens.access_token); const { exp } = jwtClaims(tokens.access_token);
const id = jwtClaims(tokens.id_token); const id = jwtClaims(tokens.id_token);
return { return {