Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 890cd08f57 | |||
| aabc95768c | |||
| b34b201bd1 | |||
| c356cf96a1 | |||
| 2a31537b80 | |||
| 0cd853e330 | |||
| 59533b997b |
@@ -1,6 +1,6 @@
|
|||||||
# Claude Code Working Guide
|
# 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
|
## Project mission
|
||||||
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
|
- 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 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.
|
- **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.
|
- **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 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 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.
|
- **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.
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @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 { createPortal } from "react-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link, useLocation } from "react-router-dom";
|
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 type { BudgetStatus, CostResult, ToolQuota } from "../lib/types";
|
||||||
import { fmt, fmtCostFull, formatDateTime, formatModelName, timeAgo } from "../lib/format";
|
import { fmt, fmtCostFull, formatDateTime, formatModelName, timeAgo } from "../lib/format";
|
||||||
import { AiToolIcon } from "./AiToolIcon";
|
import { AiToolIcon } from "./AiToolIcon";
|
||||||
|
import {
|
||||||
|
HIDDEN_PROFILES_EVENT,
|
||||||
|
isHiddenProfilesKey,
|
||||||
|
readHiddenProfiles,
|
||||||
|
standIn,
|
||||||
|
} from "../lib/hiddenProfilesStorage";
|
||||||
import { useLiveRefresh } from "../hooks/useLiveRefresh";
|
import { useLiveRefresh } from "../hooks/useLiveRefresh";
|
||||||
import { useSpin } from "../hooks/useSpin";
|
import { useSpin } from "../hooks/useSpin";
|
||||||
import { sessionChangeMatcher } from "../lib/sessionChange";
|
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. */
|
/** The right-end quota chips; nothing at all until a tool reports one. */
|
||||||
function QuotaChips() {
|
function QuotaChips() {
|
||||||
const { t } = useTranslation("nav");
|
const { t } = useTranslation("nav");
|
||||||
const [tools, setTools] = useState<ToolQuota[]>([]);
|
const [fetched, setTools] = useState<ToolQuota[]>([]);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
// Leaving the chips closes the panel only after a beat, and entering the
|
// Leaving the chips closes the panel only after a beat, and entering the
|
||||||
// panel cancels that: the pointer crosses the gap to read it.
|
// panel cancels that: the pointer crosses the gap to read it.
|
||||||
@@ -303,6 +343,8 @@ function QuotaChips() {
|
|||||||
);
|
);
|
||||||
const [refreshing, refresh] = useSpin(() => load(true));
|
const [refreshing, refresh] = useSpin(() => load(true));
|
||||||
useVisiblePoll(load, QUOTA_POLL_MS, [load]);
|
useVisiblePoll(load, QUOTA_POLL_MS, [load]);
|
||||||
|
const hidden = useHiddenProfiles();
|
||||||
|
const tools = useMemo(() => withoutHidden(fetched, hidden), [fetched, hidden]);
|
||||||
if (!tools.length) return null;
|
if (!tools.length) return null;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ vi.mock("../../lib/api", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import { api } from "../../lib/api";
|
import { api } from "../../lib/api";
|
||||||
import { StatusBar } from "../StatusBar";
|
import { StatusBar, withoutHidden } from "../StatusBar";
|
||||||
import { LIVE_RUNS_EVENT } from "../WorkspaceTree";
|
import { LIVE_RUNS_EVENT } from "../WorkspaceTree";
|
||||||
|
|
||||||
const at = (url: string, ui: React.JSX.Element) =>
|
const at = (url: string, ui: React.JSX.Element) =>
|
||||||
@@ -307,3 +307,35 @@ describe("StatusBar", () => {
|
|||||||
expect(api.pricing.quota).toHaveBeenLastCalledWith(true);
|
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",
|
canvas: "Canvas",
|
||||||
"orca-dark": "Orca Dark",
|
"orca-dark": "Orca Dark",
|
||||||
"orca-light": "Orca Light",
|
"orca-light": "Orca Light",
|
||||||
|
"sunset-cloud": "Sunset Cloud",
|
||||||
|
terminal: "Terminal",
|
||||||
|
"night-sea": "Night Sea",
|
||||||
|
sakura: "Sakura",
|
||||||
};
|
};
|
||||||
|
|
||||||
const MODES: { mode: ThemeMode; Icon: typeof Moon }[] = [
|
const MODES: { mode: ThemeMode; Icon: typeof Moon }[] = [
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import { toast } from "../../lib/toast";
|
|||||||
import {
|
import {
|
||||||
profileKey,
|
profileKey,
|
||||||
readHiddenProfiles,
|
readHiddenProfiles,
|
||||||
|
standIn,
|
||||||
toggleHidden,
|
toggleHidden,
|
||||||
writeHiddenProfiles,
|
writeHiddenProfiles,
|
||||||
} from "../../lib/hiddenProfilesStorage";
|
} from "../../lib/hiddenProfilesStorage";
|
||||||
@@ -526,6 +527,17 @@ function ClaudeProfiles({ pooled, local }: { pooled: boolean; local: boolean })
|
|||||||
writeHiddenProfiles(next);
|
writeHiddenProfiles(next);
|
||||||
};
|
};
|
||||||
const activeHidden = !!active && !reveal && isHidden(active);
|
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 () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -622,7 +634,9 @@ function ClaudeProfiles({ pooled, local }: { pooled: boolean; local: boolean })
|
|||||||
<span>
|
<span>
|
||||||
{t("profiles.mismatch", {
|
{t("profiles.mismatch", {
|
||||||
config: mismatchEmail,
|
config: mismatchEmail,
|
||||||
owner: activeHidden ? "?" : active?.email || active?.accountUuid || "?",
|
owner: activeHidden
|
||||||
|
? decoy?.email || "?"
|
||||||
|
: active?.email || active?.accountUuid || "?",
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
@@ -639,7 +653,16 @@ function ClaudeProfiles({ pooled, local }: { pooled: boolean; local: boolean })
|
|||||||
{t("profiles.activeLabel")}
|
{t("profiles.activeLabel")}
|
||||||
</div>
|
</div>
|
||||||
{active && activeHidden ? (
|
{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 ? (
|
) : active ? (
|
||||||
<>
|
<>
|
||||||
<div className="text-sm text-fg-primary">
|
<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>
|
<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="pt-1.5 space-y-1.5">
|
||||||
<div className="text-[11px] text-status-warning">{t("profiles.unsavedActive")}</div>
|
<div className="text-[11px] text-status-warning">{t("profiles.unsavedActive")}</div>
|
||||||
<button
|
<button
|
||||||
@@ -673,71 +696,75 @@ function ClaudeProfiles({ pooled, local }: { pooled: boolean; local: boolean })
|
|||||||
{t("profiles.empty")}
|
{t("profiles.empty")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{items?.filter((p) => reveal || !isHidden(p)).map((p) => (
|
{items
|
||||||
<div
|
?.filter((p) => reveal || !isHidden(p))
|
||||||
key={p.name}
|
.map((p) => (
|
||||||
data-testid={`profile-row-${p.name}`}
|
<div
|
||||||
onContextMenu={(e) => ctx.open(e, p)}
|
key={p.name}
|
||||||
onClickCapture={(e) => toggleRow(e, p)}
|
data-testid={`profile-row-${p.name}`}
|
||||||
className={`${rowCls}${isHidden(p) ? " opacity-50" : ""}`}
|
onContextMenu={(e) => ctx.open(e, p)}
|
||||||
>
|
onClickCapture={(e) => toggleRow(e, p)}
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
className={`${rowCls}${isHidden(p) ? " opacity-50" : ""}`}
|
||||||
<LabelEdit
|
>
|
||||||
label={p.label}
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
busy={busy !== null}
|
<LabelEdit
|
||||||
editing={renaming === p.name}
|
label={p.label}
|
||||||
setEditing={(on) => setRenaming(on ? p.name : null)}
|
busy={busy !== null}
|
||||||
onRename={(next) =>
|
editing={renaming === p.name}
|
||||||
void run(
|
setEditing={(on) => setRenaming(on ? p.name : null)}
|
||||||
`rename:${p.name}`,
|
onRename={(next) =>
|
||||||
() => api.ccConfig.renameProfile(p.name, next),
|
void run(
|
||||||
t("profiles.renameSuccess", { name: next })
|
`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 />}
|
{p.savedAt && (
|
||||||
<span className="text-xs text-fg-secondary truncate flex-1 min-w-0">
|
<div className="text-[10px] text-fg-muted mt-1">
|
||||||
{p.email || p.displayName || p.accountUuid}
|
{t("profiles.savedAt", { time: new Date(p.savedAt).toLocaleString() })}
|
||||||
</span>
|
</div>
|
||||||
{!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>
|
|
||||||
)}
|
)}
|
||||||
</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 && (
|
{ctx.menu && (
|
||||||
<ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="profile-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>
|
<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)}>
|
<MenuItem disabled={busy !== null} onSelect={pick(switchTo)}>
|
||||||
{t("profiles.switchButton")}
|
{t("profiles.switchButton")}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
|||||||
@@ -53,6 +53,10 @@ describe("AppearanceSettings", () => {
|
|||||||
"Apple Light",
|
"Apple Light",
|
||||||
"Canvas",
|
"Canvas",
|
||||||
"Orca Light",
|
"Orca Light",
|
||||||
|
"Sunset Cloud",
|
||||||
|
"Terminal",
|
||||||
|
"Night Sea",
|
||||||
|
"Sakura",
|
||||||
]) {
|
]) {
|
||||||
expect(screen.getByText(name)).toBeInTheDocument();
|
expect(screen.getByText(name)).toBeInTheDocument();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -160,6 +160,19 @@ describe("ToolProfilesSettings — Claude", () => {
|
|||||||
expect(screen.getByTestId("profile-row-spare")).toBeInTheDocument();
|
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 () => {
|
it("does not hide anything in server mode", async () => {
|
||||||
perms.local = false;
|
perms.local = false;
|
||||||
localStorage.setItem("agenthub.cc.hiddenProfiles", JSON.stringify(["u2"]));
|
localStorage.setItem("agenthub.cc.hiddenProfiles", JSON.stringify(["u2"]));
|
||||||
|
|||||||
@@ -103,6 +103,19 @@ describe("useTheme", () => {
|
|||||||
expect(document.documentElement.dataset.theme).toBe("orca-light");
|
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", () => {
|
it("the default theme carries no data-theme attribute", () => {
|
||||||
const { result } = renderHook(() => useTheme());
|
const { result } = renderHook(() => useTheme());
|
||||||
act(() => result.current.setThemeFor("dark", "darcula"));
|
act(() => result.current.setThemeFor("dark", "darcula"));
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ export type ThemeMode = Theme | "system";
|
|||||||
|
|
||||||
/** Named themes per mode, in picker order. `default` is the stock palette. */
|
/** Named themes per mode, in picker order. `default` is the stock palette. */
|
||||||
export const THEMES = {
|
export const THEMES = {
|
||||||
dark: ["default", "darcula", "apple-dark", "orca-dark"],
|
dark: ["default", "darcula", "apple-dark", "orca-dark", "sunset-cloud", "terminal", "night-sea"],
|
||||||
light: ["default", "canvas", "apple-light", "orca-light"],
|
light: ["default", "canvas", "apple-light", "orca-light", "sakura"],
|
||||||
} as const satisfies Record<Theme, readonly string[]>;
|
} as const satisfies Record<Theme, readonly string[]>;
|
||||||
|
|
||||||
const MODE_KEY = "theme";
|
const MODE_KEY = "theme";
|
||||||
|
|||||||
@@ -262,7 +262,7 @@
|
|||||||
"folders.delete": "Delete folder",
|
"folders.delete": "Delete folder",
|
||||||
"folders.deleteTitle": "Delete the folder “{{name}}”?",
|
"folders.deleteTitle": "Delete the folder “{{name}}”?",
|
||||||
"folders.deleteBody": "Only the folder goes; its artifacts stay where they are.",
|
"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.move": "Move to folder…",
|
||||||
"folders.moveTitle": "Move “{{title}}” to a folder",
|
"folders.moveTitle": "Move “{{title}}” to a folder",
|
||||||
"folders.private": "Your folders are yours alone: nobody else sees them.",
|
"folders.private": "Your folders are yours alone: nobody else sees them.",
|
||||||
@@ -270,7 +270,7 @@
|
|||||||
"folders.takeOut": "Take it out of its folder",
|
"folders.takeOut": "Take it out of its folder",
|
||||||
"page.view": "View",
|
"page.view": "View",
|
||||||
"page.viewHint": "Its copy, viewable here without the claude.ai account that published it",
|
"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.page": "Page {{n}}",
|
||||||
"pdf.failed": "This PDF could not be shown. Download it instead.",
|
"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.",
|
"pdf.more": "Showing the first {{shown}} of {{total}} pages — download it for the rest.",
|
||||||
|
|||||||
@@ -260,7 +260,7 @@
|
|||||||
"folders.delete": "Xóa thư mục",
|
"folders.delete": "Xóa thư mục",
|
||||||
"folders.deleteTitle": "Xóa thư mục “{{name}}”?",
|
"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.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.move": "Chuyển vào thư mục…",
|
||||||
"folders.moveTitle": "Chuyển “{{title}}” 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.",
|
"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",
|
"folders.takeOut": "Bỏ khỏi thư mục",
|
||||||
"page.view": "Xem",
|
"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.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.page": "Trang {{n}}",
|
||||||
"pdf.failed": "Không hiển thị được PDF này. Hãy tải về.",
|
"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.",
|
"pdf.more": "Đang hiện {{shown}}/{{total}} trang đầu — tải về để xem hết.",
|
||||||
|
|||||||
@@ -350,6 +350,106 @@
|
|||||||
--syn-del: 197 0 15; /* #c5000f */
|
--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 {
|
@layer base {
|
||||||
body {
|
body {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|||||||
@@ -39,6 +39,11 @@ describe("buildTerminalTheme", () => {
|
|||||||
expect(t.brightWhite).toBe("#edeef0");
|
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", () => {
|
it("makes each bright twin lighter than its base colour", () => {
|
||||||
const t = buildTerminalTheme((n) => vars[n] ?? "");
|
const t = buildTerminalTheme((n) => vars[n] ?? "");
|
||||||
const sum = (hex: string) =>
|
const sum = (hex: string) =>
|
||||||
|
|||||||
@@ -4,12 +4,17 @@
|
|||||||
* AI tools → Profiles, kept in localStorage with the same read/validate/
|
* AI tools → Profiles, kept in localStorage with the same read/validate/
|
||||||
* fallback convention as laneOrderStorage.ts. A viewer preference only: the
|
* fallback convention as laneOrderStorage.ts. A viewer preference only: the
|
||||||
* profile files, the API and the database are untouched, and another browser
|
* 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>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const STORAGE_KEY = "agenthub.cc.hiddenProfiles";
|
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. */
|
/** A profile's stable key: its account (survives a rename), else its file name. */
|
||||||
export function profileKey(p: { accountUuid: string | null; name?: string }): string {
|
export function profileKey(p: { accountUuid: string | null; name?: string }): string {
|
||||||
@@ -34,6 +39,38 @@ export function writeHiddenProfiles(keys: string[]): void {
|
|||||||
} catch {
|
} catch {
|
||||||
/* ignore quota / disabled storage */
|
/* 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. */
|
/** `keys` with `key` added when absent, removed when present. */
|
||||||
|
|||||||
@@ -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
|
* 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
|
* 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
|
* theme (darcula's keyword is orange), and a program printing "magenta" should
|
||||||
* get magenta.
|
* get magenta.
|
||||||
@@ -50,7 +52,7 @@ export function buildTerminalTheme(read: VarReader): ITheme {
|
|||||||
const red = v("--status-danger");
|
const red = v("--status-danger");
|
||||||
const green = v("--status-success");
|
const green = v("--status-success");
|
||||||
const yellow = v("--status-warning");
|
const yellow = v("--status-warning");
|
||||||
const blue = v("--accent");
|
const blue = v("--ansi-blue") ?? v("--accent");
|
||||||
const magenta = "#c678dd";
|
const magenta = "#c678dd";
|
||||||
const cyan = "#56b6c2";
|
const cyan = "#56b6c2";
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1240,6 +1240,8 @@ export interface ToolQuota {
|
|||||||
active?: boolean;
|
active?: boolean;
|
||||||
/** The saved profile's label, when the account has one. */
|
/** The saved profile's label, when the account has one. */
|
||||||
label?: string | null;
|
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. */
|
/** The signed-in account's email, when the tool's login names one. */
|
||||||
account?: string | null;
|
account?: string | null;
|
||||||
/** The subscription's name as the tool reports it (`pro`, `max`, `plus`, `team`…). */
|
/** The subscription's name as the tool reports it (`pro`, `max`, `plus`, `team`…). */
|
||||||
|
|||||||
@@ -65,9 +65,11 @@ import {
|
|||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
List,
|
List,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
|
Pencil,
|
||||||
Puzzle,
|
Puzzle,
|
||||||
Search,
|
Search,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
|
Trash2,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
@@ -2564,13 +2566,24 @@ function FolderBar({
|
|||||||
if (active === f.id) onPick("");
|
if (active === f.id) onPick("");
|
||||||
};
|
};
|
||||||
const chip = (on: boolean) =>
|
const chip = (on: boolean) =>
|
||||||
`inline-flex items-center gap-1 rounded-md px-2.5 py-1 text-xs whitespace-nowrap transition-colors ${
|
`inline-flex h-7 items-center gap-1.5 rounded-full border px-3 text-xs whitespace-nowrap transition-colors ${
|
||||||
on ? "bg-surface-4 text-fg-primary" : "text-fg-muted hover:text-fg-secondary"
|
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 (
|
return (
|
||||||
<div className="flex flex-wrap items-center gap-1" data-testid="artifact-folders">
|
<div className="flex flex-wrap items-center gap-1.5" data-testid="artifact-folders">
|
||||||
<Folder className="mr-1 h-3.5 w-3.5 text-fg-muted" aria-hidden />
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-pressed={!active}
|
aria-pressed={!active}
|
||||||
@@ -2579,26 +2592,54 @@ function FolderBar({
|
|||||||
title={folders.length ? t("folders.dropOutHint") : undefined}
|
title={folders.length ? t("folders.dropOutHint") : undefined}
|
||||||
{...dropProps("")}
|
{...dropProps("")}
|
||||||
>
|
>
|
||||||
|
<Folder className="h-3.5 w-3.5" aria-hidden />
|
||||||
{t("folders.all")}
|
{t("folders.all")}
|
||||||
</button>
|
</button>
|
||||||
{folders.map((f) => (
|
{folders.map((f) => {
|
||||||
<button
|
const on = active === f.id;
|
||||||
key={f.id}
|
return editing === f.id ? null : (
|
||||||
type="button"
|
<span key={f.id} className="inline-flex items-center">
|
||||||
aria-pressed={active === f.id}
|
<button
|
||||||
className={chip(active === f.id) + lit(f.id)}
|
type="button"
|
||||||
onClick={() => onPick(f.id)}
|
aria-pressed={on}
|
||||||
title={t("folders.dropHint")}
|
className={chip(on) + lit(f.id)}
|
||||||
data-testid={`artifact-folder-${f.id}`}
|
onClick={() => onPick(f.id)}
|
||||||
{...dropProps(f.id)}
|
onDoubleClick={() => begin(f.id, f.name)}
|
||||||
>
|
title={t("folders.dropHint")}
|
||||||
{f.name}
|
data-testid={`artifact-folder-${f.id}`}
|
||||||
<span className="text-fg-muted">{counts(f.id)}</span>
|
{...dropProps(f.id)}
|
||||||
</button>
|
>
|
||||||
))}
|
<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 ? (
|
{editing ? (
|
||||||
<input
|
<input
|
||||||
className="input h-7 w-40 text-xs"
|
className="input h-7 w-44 rounded-full px-3 text-xs"
|
||||||
autoFocus
|
autoFocus
|
||||||
maxLength={MAX_FOLDER_NAME}
|
maxLength={MAX_FOLDER_NAME}
|
||||||
aria-label={t(editing === "new" ? "folders.newName" : "folders.rename")}
|
aria-label={t(editing === "new" ? "folders.newName" : "folders.rename")}
|
||||||
@@ -2619,7 +2660,7 @@ function FolderBar({
|
|||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
type="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")}
|
onClick={() => begin("new")}
|
||||||
data-testid="artifact-folder-new"
|
data-testid="artifact-folder-new"
|
||||||
>
|
>
|
||||||
@@ -2627,24 +2668,6 @@ function FolderBar({
|
|||||||
{t("folders.new")}
|
{t("folders.new")}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.72 (rebuild client to match)
|
UI build v2.8.76 (rebuild client to match)
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+1
-1
@@ -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]
|
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
|
#### Analytics
|
||||||
|
|
||||||
```http
|
```http
|
||||||
|
|||||||
+3
-2
@@ -84,8 +84,9 @@ Internal build — all rights reserved.
|
|||||||
- **Updates.** Release-based update detection with an in-UI **Update all**
|
- **Updates.** Release-based update detection with an in-UI **Update all**
|
||||||
(marketplaces → every plugin → AgentHub last → one session reload).
|
(marketplaces → every plugin → AgentHub last → one session reload).
|
||||||
- **Appearance.** Settings → Appearance picks Dark / Light / System (follows the
|
- **Appearance.** Settings → Appearance picks Dark / Light / System (follows the
|
||||||
OS live) and a theme per mode — dark: Default, Darcula, Apple Dark, Orca Dark;
|
OS live) and a theme per mode — dark: Default, Darcula, Apple Dark, Orca Dark,
|
||||||
light: Default, Apple Light, Canvas, Orca Light. Each mode
|
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.
|
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
|
It also picks the workspace terminals' font: JetBrains Mono (default), the
|
||||||
rounded Maple Mono, or the browser's own fixed-width
|
rounded Maple Mono, or the browser's own fixed-width
|
||||||
|
|||||||
@@ -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` |
|
| `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.
|
- **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)
|
## Rules (binding, same spirit as CLAUDE.md)
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.8.72",
|
"version": "2.8.76",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.8.72",
|
"version": "2.8.76",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.8.72",
|
"version": "2.8.76",
|
||||||
"bin": {
|
"bin": {
|
||||||
"agenthub": "bin/agenthub.js"
|
"agenthub": "bin/agenthub.js"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ process.env.CLAUDE_HOME = path.join(ROOT, "claude");
|
|||||||
process.env.DASHBOARD_DATA_DIR = path.join(ROOT, "data");
|
process.env.DASHBOARD_DATA_DIR = path.join(ROOT, "data");
|
||||||
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
|
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
|
||||||
process.env.DASHBOARD_LIVENESS_PROBE = "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 { createApp } = require("../index");
|
||||||
const { db } = require("../db");
|
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 () => {
|
it("takes an older page's copy on first view, once", async () => {
|
||||||
fs.writeFileSync(path.join(ws, "pub", "old.html"), "<p>old</p>");
|
fs.writeFileSync(path.join(ws, "pub", "old.html"), "<p>old</p>");
|
||||||
const id = Number(
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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.label, "Victor");
|
||||||
assert.strictEqual(live.account, "victor@x.y");
|
assert.strictEqual(live.account, "victor@x.y");
|
||||||
assert.strictEqual(live.ownerUuid, undefined);
|
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.strictEqual(live.plan, "Max");
|
||||||
assert.deepStrictEqual(
|
assert.deepStrictEqual(
|
||||||
claude.filter((t) => !t.active).map((t) => t.label),
|
claude.filter((t) => !t.active).map((t) => t.label),
|
||||||
|
|||||||
+122
-21
@@ -494,7 +494,8 @@ function captureFromHook(sessionId, data, peer = null) {
|
|||||||
const file = typeof input.file_path === "string" ? input.file_path : "";
|
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
|
// 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).
|
// 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 (same) {
|
||||||
if (copy) schedulePageSnapshot(same.id, copy);
|
if (copy) schedulePageSnapshot(same.id, copy);
|
||||||
return same;
|
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
|
// 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
|
// copy to `artifacts-export/<artifact id>.(md|pdf)`, which the Stop capture
|
||||||
// attaches to the row instead (`captureOutputsNow`).
|
// 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_FILE_EXT = /\.html?$/i;
|
||||||
const PAGE_FILES_MAX = 50;
|
const PAGE_FILES_MAX = 50;
|
||||||
@@ -530,29 +534,118 @@ const PUBLISHED_RE =
|
|||||||
/** Where Claude is asked to export a typed artifact's copy. */
|
/** Where Claude is asked to export a typed artifact's copy. */
|
||||||
const EXPORT_DIR = "artifacts-export";
|
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
|
* The scratchpad Claude Code gives ONE session —
|
||||||
* path workspace-relative, or null when the main file is not an HTML file
|
* `<tmp>/claude-<uid>/<project key>/<session id>/scratchpad`, `<tmp>` being
|
||||||
* inside the workspace. `cwd` is the session's folder (a relative path in
|
* `/tmp`, `os.tmpdir()` or `CLAUDE_CODE_TMPDIR` — as `{dir, uid}`, when `abs`
|
||||||
* the hook is relative to it); `files` is the Artifact tool's supporting
|
* lies inside it; else null. Only the text is judged here: `scratchpadSafe`
|
||||||
* files map (`{published: source | {from}}`) or list (`[{path}]`).
|
* 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));
|
const lane = require("./lanes").getLane(Number(laneId));
|
||||||
if (!lane || !PAGE_FILE_EXT.test(file)) return null;
|
if (!lane || !PAGE_FILE_EXT.test(file)) return null;
|
||||||
const base = typeof cwd === "string" && path.isAbsolute(cwd) ? cwd : lane.cwd;
|
const base = typeof cwd === "string" && path.isAbsolute(cwd) ? cwd : lane.cwd;
|
||||||
const inLane = (p) => {
|
const under = (root) => (p) => {
|
||||||
const rel = path.relative(lane.cwd, path.resolve(base, p));
|
const rel = path.relative(root, path.resolve(base, p));
|
||||||
return rel && !rel.startsWith("..") && !path.isAbsolute(rel)
|
return rel && !rel.startsWith("..") && !path.isAbsolute(rel)
|
||||||
? rel.split(path.sep).join("/")
|
? rel.split(path.sep).join("/")
|
||||||
: null;
|
: null;
|
||||||
};
|
};
|
||||||
const main = inLane(file);
|
let inRoot = under(lane.cwd);
|
||||||
if (!main) return null;
|
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 extra = [];
|
||||||
const add = (as, src) => {
|
const add = (as, src) => {
|
||||||
if (typeof as !== "string" || !PUBLISHED_RE.test(as) || typeof src !== "string") return;
|
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 (rel && extra.length < PAGE_FILES_MAX) extra.push({ rel, as });
|
||||||
};
|
};
|
||||||
if (Array.isArray(files)) {
|
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);
|
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
|
* 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.
|
* outside the workspace is skipped; the total stays under FILE_MAX_BYTES.
|
||||||
* Returns `{snap, files: [{path, size}]}`, or null when nothing was copied.
|
* 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) {
|
async function snapshotLaneFiles(lane, entries, open) {
|
||||||
const { openDownload } = require("./lane-file-transfer");
|
const read = open || ((rel) => require("./lane-file-transfer").openDownload(lane.cwd, rel));
|
||||||
const snap = newSnapshotDir();
|
const snap = newSnapshotDir();
|
||||||
const files = [];
|
const files = [];
|
||||||
let total = 0;
|
let total = 0;
|
||||||
@@ -587,7 +682,7 @@ async function snapshotLaneFiles(lane, entries) {
|
|||||||
continue;
|
continue;
|
||||||
let file;
|
let file;
|
||||||
try {
|
try {
|
||||||
file = await openDownload(lane.cwd, rel);
|
file = await read(rel);
|
||||||
} catch {
|
} catch {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -633,11 +728,14 @@ function setSnapshot(rowId, shot) {
|
|||||||
async function snapshotPage(rowId, copy) {
|
async function snapshotPage(rowId, copy) {
|
||||||
const lane = require("./lanes").getLane(Number(copy.laneId));
|
const lane = require("./lanes").getLane(Number(copy.laneId));
|
||||||
if (!lane) return null;
|
if (!lane) return null;
|
||||||
|
const sp = copy.scratch;
|
||||||
|
if (sp && !(await scratchpadSafe(sp))) return null;
|
||||||
const mainAs = path.posix.basename(copy.main);
|
const mainAs = path.posix.basename(copy.main);
|
||||||
const shot = await snapshotLaneFiles(lane, [
|
const shot = await snapshotLaneFiles(
|
||||||
{ rel: copy.main, as: mainAs },
|
lane,
|
||||||
...copy.extra.filter((e) => e.as !== mainAs),
|
[{ 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 || shot.files[0].path !== mainAs) {
|
||||||
if (shot) dropSnapshot(shot.snap.id);
|
if (shot) dropSnapshot(shot.snap.id);
|
||||||
return null;
|
return null;
|
||||||
@@ -681,7 +779,10 @@ async function ensurePageCopy(req, id) {
|
|||||||
}
|
}
|
||||||
if (backfillTried.has(row.id)) return view(row, req);
|
if (backfillTried.has(row.id)) return view(row, req);
|
||||||
backfillTried.add(row.id);
|
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;
|
const next = copy ? await schedulePageSnapshot(row.id, copy) : null;
|
||||||
return view(next || row, req);
|
return view(next || row, req);
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-11
@@ -11,6 +11,22 @@ const path = require("path");
|
|||||||
const os = require("os");
|
const os = require("os");
|
||||||
const fs = require("fs");
|
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() {
|
function getClaudeHome() {
|
||||||
return process.env.CLAUDE_HOME || path.join(os.homedir(), ".claude");
|
return process.env.CLAUDE_HOME || path.join(os.homedir(), ".claude");
|
||||||
}
|
}
|
||||||
@@ -143,7 +159,7 @@ function getTranscriptPath(sessionId, cwd) {
|
|||||||
if (!cwd) return null;
|
if (!cwd) return null;
|
||||||
const encoded = encodeCwd(cwd);
|
const encoded = encodeCwd(cwd);
|
||||||
const candidate = path.join(projectsDirFor(cwd), encoded, `${sessionId}.jsonl`);
|
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
|
// Fallback: scan the owner's projects/ subdirectories
|
||||||
return findTranscriptPath(sessionId, cwd);
|
return findTranscriptPath(sessionId, cwd);
|
||||||
}
|
}
|
||||||
@@ -170,14 +186,14 @@ function getTranscriptPath(sessionId, cwd) {
|
|||||||
function resolveAgentTranscriptInDir(subagentsDir, agentId, runId = null) {
|
function resolveAgentTranscriptInDir(subagentsDir, agentId, runId = null) {
|
||||||
if (!subagentsDir) return null;
|
if (!subagentsDir) return null;
|
||||||
const flat = path.join(subagentsDir, `agent-${agentId}.jsonl`);
|
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");
|
const workflowsDir = path.join(subagentsDir, "workflows");
|
||||||
if (!fs.existsSync(workflowsDir)) return null;
|
if (!exists(workflowsDir)) return null;
|
||||||
|
|
||||||
if (runId) {
|
if (runId) {
|
||||||
const nested = path.join(workflowsDir, runId, `agent-${agentId}.jsonl`);
|
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.
|
// 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 })) {
|
for (const d of fs.readdirSync(workflowsDir, { withFileTypes: true })) {
|
||||||
if (!d.isDirectory()) continue;
|
if (!d.isDirectory()) continue;
|
||||||
const cand = path.join(workflowsDir, d.name, `agent-${agentId}.jsonl`);
|
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;
|
if (matches.length > 1) break;
|
||||||
}
|
}
|
||||||
return matches.length === 1 ? matches[0] : null;
|
return matches.length === 1 ? matches[0] : null;
|
||||||
@@ -218,13 +234,13 @@ function getSubagentTranscriptPath(sessionId, cwd, agentId, runId = null) {
|
|||||||
*/
|
*/
|
||||||
function findTranscriptPath(sessionId, cwd = null) {
|
function findTranscriptPath(sessionId, cwd = null) {
|
||||||
for (const projectsDir of sessionProjectsDirs(cwd)) {
|
for (const projectsDir of sessionProjectsDirs(cwd)) {
|
||||||
if (!fs.existsSync(projectsDir)) continue;
|
if (!exists(projectsDir)) continue;
|
||||||
try {
|
try {
|
||||||
const dirs = fs.readdirSync(projectsDir, { withFileTypes: true });
|
const dirs = fs.readdirSync(projectsDir, { withFileTypes: true });
|
||||||
for (const d of dirs) {
|
for (const d of dirs) {
|
||||||
if (!d.isDirectory()) continue;
|
if (!d.isDirectory()) continue;
|
||||||
const candidate = path.join(projectsDir, d.name, `${sessionId}.jsonl`);
|
const candidate = path.join(projectsDir, d.name, `${sessionId}.jsonl`);
|
||||||
if (fs.existsSync(candidate)) return candidate;
|
if (exists(candidate)) return candidate;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Permission or IO error, ignore
|
// Permission or IO error, ignore
|
||||||
@@ -242,7 +258,7 @@ function findTranscriptPath(sessionId, cwd = null) {
|
|||||||
*/
|
*/
|
||||||
function getSnapshotTranscriptPath(sessionId) {
|
function getSnapshotTranscriptPath(sessionId) {
|
||||||
const candidate = path.join(getTranscriptSnapshotDir(), `${sessionId}.jsonl`);
|
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) {
|
function getSnapshotSubagentTranscriptPath(sessionId, agentId, runId = null) {
|
||||||
const subDir = path.join(getTranscriptSnapshotDir(), sessionId, "subagents");
|
const subDir = path.join(getTranscriptSnapshotDir(), sessionId, "subagents");
|
||||||
if (!fs.existsSync(subDir)) return null;
|
if (!exists(subDir)) return null;
|
||||||
const hit = resolveAgentTranscriptInDir(subDir, agentId, runId);
|
const hit = resolveAgentTranscriptInDir(subDir, agentId, runId);
|
||||||
if (hit) return hit;
|
if (hit) return hit;
|
||||||
if (agentId.startsWith("acompact-")) {
|
if (agentId.startsWith("acompact-")) {
|
||||||
@@ -287,13 +303,13 @@ function findSubagentTranscriptPath(sessionId, agentId, runId = null, cwd = null
|
|||||||
}
|
}
|
||||||
|
|
||||||
function findSubagentIn(projectsDir, sessionId, agentId, runId) {
|
function findSubagentIn(projectsDir, sessionId, agentId, runId) {
|
||||||
if (!fs.existsSync(projectsDir)) return null;
|
if (!exists(projectsDir)) return null;
|
||||||
try {
|
try {
|
||||||
const dirs = fs.readdirSync(projectsDir, { withFileTypes: true });
|
const dirs = fs.readdirSync(projectsDir, { withFileTypes: true });
|
||||||
for (const d of dirs) {
|
for (const d of dirs) {
|
||||||
if (!d.isDirectory()) continue;
|
if (!d.isDirectory()) continue;
|
||||||
const subagentsDir = path.join(projectsDir, d.name, sessionId, "subagents");
|
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)
|
// Exact match (flat or nested Workflow-tool layout)
|
||||||
const hit = resolveAgentTranscriptInDir(subagentsDir, agentId, runId);
|
const hit = resolveAgentTranscriptInDir(subagentsDir, agentId, runId);
|
||||||
@@ -358,4 +374,5 @@ module.exports = {
|
|||||||
allProjectsDirs,
|
allProjectsDirs,
|
||||||
sessionProjectsDirs,
|
sessionProjectsDirs,
|
||||||
setClaudeHome,
|
setClaudeHome,
|
||||||
|
exists,
|
||||||
};
|
};
|
||||||
|
|||||||
+14
-4
@@ -210,8 +210,8 @@ async function claudeQuotas(force, home) {
|
|||||||
liveClaude(() => memberClaudeAuth(home))
|
liveClaude(() => memberClaudeAuth(home))
|
||||||
);
|
);
|
||||||
if (!live) return [];
|
if (!live) return [];
|
||||||
const { ownerUuid: _uuid, ...shown } = live;
|
const { ownerUuid, ...shown } = live;
|
||||||
return [{ id: "claude", active: true, label: null, ...shown }];
|
return [{ id: "claude", active: true, label: null, accountUuid: ownerUuid || null, ...shown }];
|
||||||
}
|
}
|
||||||
let items = [];
|
let items = [];
|
||||||
try {
|
try {
|
||||||
@@ -231,12 +231,22 @@ async function claudeQuotas(force, home) {
|
|||||||
id: "claude",
|
id: "claude",
|
||||||
active: false,
|
active: false,
|
||||||
label: it.label,
|
label: it.label,
|
||||||
|
accountUuid: it.accountUuid || null,
|
||||||
...(await cached(`profile:${it.name}`, force, () => profileClaude(it))),
|
...(await cached(`profile:${it.name}`, force, () => profileClaude(it))),
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
if (!live) return others;
|
if (!live) return others;
|
||||||
const { ownerUuid: _uuid, ...shown } = live;
|
const { ownerUuid, ...shown } = live;
|
||||||
return [{ id: "claude", active: true, label: mine?.label || null, ...shown }, ...others];
|
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. */
|
/** The newest `rate_limits` in the last `TAIL_BYTES` of a rollout, or null. */
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const {
|
|||||||
getSnapshotSubagentTranscriptPath,
|
getSnapshotSubagentTranscriptPath,
|
||||||
findTranscriptPath,
|
findTranscriptPath,
|
||||||
findSubagentTranscriptPath,
|
findSubagentTranscriptPath,
|
||||||
|
exists,
|
||||||
} = require("../lib/claude-home");
|
} = require("../lib/claude-home");
|
||||||
const {
|
const {
|
||||||
classifyTranscriptSender,
|
classifyTranscriptSender,
|
||||||
@@ -908,7 +909,7 @@ router.get("/:id/transcripts", async (req, res) => {
|
|||||||
getTranscriptPath(req.params.id, session.cwd) ||
|
getTranscriptPath(req.params.id, session.cwd) ||
|
||||||
findTranscriptPath(req.params.id, session.cwd) ||
|
findTranscriptPath(req.params.id, session.cwd) ||
|
||||||
getSnapshotTranscriptPath(req.params.id);
|
getSnapshotTranscriptPath(req.params.id);
|
||||||
if (mainPath && fs.existsSync(mainPath)) {
|
if (mainPath && exists(mainPath)) {
|
||||||
// Main agent database ID format: <sessionId>-main
|
// Main agent database ID format: <sessionId>-main
|
||||||
const mainDbAgent = dbAgents.find((a) => a.type === "main");
|
const mainDbAgent = dbAgents.find((a) => a.type === "main");
|
||||||
result.push({
|
result.push({
|
||||||
@@ -928,18 +929,18 @@ router.get("/:id/transcripts", async (req, res) => {
|
|||||||
if (encoded) {
|
if (encoded) {
|
||||||
// A member's session (S3) wrote under their own home.
|
// A member's session (S3) wrote under their own home.
|
||||||
const directDir = path.join(projectsDirFor(session.cwd), encoded, req.params.id, "subagents");
|
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
|
// Fallback: scan the owner's project directory when the direct path doesn't exist
|
||||||
if (subagentDirs.length === 0) {
|
if (subagentDirs.length === 0) {
|
||||||
for (const projectsDir of sessionProjectsDirs(session.cwd)) {
|
for (const projectsDir of sessionProjectsDirs(session.cwd)) {
|
||||||
if (fs.existsSync(projectsDir)) {
|
if (exists(projectsDir)) {
|
||||||
try {
|
try {
|
||||||
for (const d of fs.readdirSync(projectsDir, { withFileTypes: true })) {
|
for (const d of fs.readdirSync(projectsDir, { withFileTypes: true })) {
|
||||||
if (!d.isDirectory()) continue;
|
if (!d.isDirectory()) continue;
|
||||||
const candidate = path.join(projectsDir, d.name, req.params.id, "subagents");
|
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 {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
@@ -958,7 +959,7 @@ router.get("/:id/transcripts", async (req, res) => {
|
|||||||
// Try reading meta.json for agent type info
|
// Try reading meta.json for agent type info
|
||||||
let meta = null;
|
let meta = null;
|
||||||
const metaPath = path.join(dir, file.replace(".jsonl", ".meta.json"));
|
const metaPath = path.join(dir, file.replace(".jsonl", ".meta.json"));
|
||||||
if (fs.existsSync(metaPath)) {
|
if (exists(metaPath)) {
|
||||||
try {
|
try {
|
||||||
meta = JSON.parse(
|
meta = JSON.parse(
|
||||||
require("../lib/run-as").readFileSafe(metaPath, "utf8", { max: 1 << 20 })
|
require("../lib/run-as").readFileSafe(metaPath, "utf8", { max: 1 << 20 })
|
||||||
|
|||||||
Reference in New Issue
Block a user