From 9dd97a369cce203456fb6da27a9b3a3f2802ee11 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Fri, 23 Oct 2026 10:44:11 +0700 Subject: [PATCH] =?UTF-8?q?chore:=20v2.8.21=20=E2=80=94=20update=20progres?= =?UTF-8?q?s=20steps,=20clone=20in=20background,=20extra=20SSH=20keys=20in?= =?UTF-8?q?line,=20today's=20spend?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Plugin update reports its running step (plugin-update-progress.json → plugin_update_progress); a hidden update or clone leaves a progress chip. - Clone here can run in the background. - Git identity: extra SSH keys are added inline per host (no More keys tab). - Users: spend today (viewer's local day, ?tz_offset=). - Prettier on the files touched since v2.8.20. --- CLAUDE.md | 4 +- client/src/components/UpdateNotifier.tsx | 64 +- .../__tests__/UpdateNotifier.test.tsx | 52 ++ .../src/components/lanes/CloneHereDialog.tsx | 90 ++- client/src/components/lanes/FileExplorer.tsx | 1 + .../lanes/__tests__/FileExplorer.test.tsx | 61 ++ .../settings/GitIdentitySettings.tsx | 568 +++++++++--------- .../__tests__/GitIdentitySettings.test.tsx | 91 ++- client/src/i18n/locales/en/lanes.json | 1 + client/src/i18n/locales/en/settings.json | 16 +- client/src/i18n/locales/en/updates.json | 7 +- client/src/i18n/locales/en/users.json | 1 + client/src/i18n/locales/vi/lanes.json | 1 + client/src/i18n/locales/vi/settings.json | 16 +- client/src/i18n/locales/vi/updates.json | 7 +- client/src/i18n/locales/vi/users.json | 1 + client/src/lib/api.ts | 5 +- client/src/lib/types.ts | 8 + client/src/pages/Users.tsx | 8 +- .../screens.snapshot.test.tsx.snap | 2 +- docs/API.md | 9 +- docs/SERVER-MODE.md | 4 +- package-lock.json | 4 +- package.json | 2 +- scripts/plugin-update-helper.js | 49 +- server/__tests__/plugin-update.test.js | 58 +- server/__tests__/server-mode-users.test.js | 4 + server/lib/artifacts.js | 7 +- server/lib/plugin-update.js | 39 +- server/lib/update-check.js | 15 +- server/openapi-extra/artifacts.js | 76 ++- server/openapi-extra/auth.js | 5 + server/routes/users.js | 23 +- 33 files changed, 906 insertions(+), 393 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 17d1ba8a..b76355d4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # Claude Code Working Guide -_Last synced: v2.8.20 (2026-10-22) — a Cursor account pool (`CURSOR_API_KEY` via `-e`) and pool groups (a team runs only on its group's accounts); the pool card has a tab per tool._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session. +_Last synced: v2.8.21 (2026-10-23) — Claude Docs docs and session documents (.pptx/.docx/.xlsx/.pdf/.html) land in Artifacts, unowned rows are claimed; a plugin update reports its running step (`plugin_update_progress`); a hidden update or clone leaves a progress chip._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session. ## Project mission - Maintain a reliable local-first dashboard for Claude Code session monitoring. @@ -9,7 +9,7 @@ _Last synced: v2.8.20 (2026-10-22) — a Cursor account pool (`CURSOR_API_KEY` v ## Repo map - `server/`: Express API, hook ingestion, SQLite access, websocket broadcast (includes release-based update detection — `lib/update-check.js` reads the repo's own releases API and `routes/updates.js` exposes status/check/apply, refusing to self-update a `~/.claude/plugins/cache/` install, plus `lib/workflow-ingest.js` which ingests on-disk Workflow-tool run journals — fleets that emit no hooks — and `lib/codex-ingest.js`, which mirrors a `codex` lane's running/idle from the mtime of Codex's own rollout journals (skipping a lane whose hooks are current), `lib/codex-usage.js`, which reads the same journals into `sessions` (`tool = 'codex'`) and `token_usage`, and `lib/codex-hooks.js`, which installs AgentHub's opt-in hooks into Codex's `hooks.json`, and their Antigravity counterparts `lib/antigravity-usage.js` (agy's conversation files → `tool = 'antigravity'` sessions and tokens, and its step log as their conversation view) and `lib/antigravity-hooks.js` (the named hook `agenthub` in `~/.gemini/config/hooks.json`), and `lib/cursor-usage.js` (Cursor's agent transcripts → `tool = 'cursor'` sessions and their conversation view, no tokens), and `lib/omp-usage.js`, `lib/opencode-usage.js` (omp's journals, OpenCode's `opencode.db` → `tool = 'omp'|'opencode'` sessions, tokens and subagent agents)). - `client/`: React + Vite UI. -- `scripts/`: hook installer/handler, import, seed, cleanup utilities. (Update detection lives server-side in `server/lib/update-check.js`, keyed on the forge's **newest release** rather than a commit count. `POST /api/updates/apply` has two modes and picks by install kind, never from the request: a fast-forwardable **checkout** merges the release tag `--ff-only`, rebuilds, and self-restarts via `server/lib/self-restart.js` — through our own unit when this process runs in one (`systemctl restart --no-block`, launchd's KeepAlive; a detached replacement beside a `Restart=always` unit crash-loops it), else the detached `scripts/restart-helper.js`; a **plugin-cache** install is handed to `server/lib/plugin-update.js` + the detached `scripts/plugin-update-helper.js`, which runs `claude plugin marketplace update` → `claude plugin update -y` → `plugin-bootstrap.js --force` from the NEW cache dir. Both write the HTTP response before anything stops the process, and every plugin-chain outcome is recorded to `plugin-update.json` in the runtime dir — outside the cache, so it survives the swap and the next server reports it as `last_plugin_update`. Neither mode can run `/reload-plugins`: it is REPL-scoped to the session that invokes it, so an open Claude Code session still reloads by hand for new skills/commands. Elsewhere users run the printed manual command, surfaced in the UI and by `agenthub update-check`. `scripts/cc-update-all-helper.js` is the same detached pattern for the config panel's **Update all** button — marketplaces → every plugin → AgentHub LAST → one reload — journalling each step to `cc-update-all.json` in the runtime dir.) +- `scripts/`: hook installer/handler, import, seed, cleanup utilities. (Update detection lives server-side in `server/lib/update-check.js`, keyed on the forge's **newest release** rather than a commit count. `POST /api/updates/apply` has two modes and picks by install kind, never from the request: a fast-forwardable **checkout** merges the release tag `--ff-only`, rebuilds, and self-restarts via `server/lib/self-restart.js` — through our own unit when this process runs in one (`systemctl restart --no-block`, launchd's KeepAlive; a detached replacement beside a `Restart=always` unit crash-loops it), else the detached `scripts/restart-helper.js`; a **plugin-cache** install is handed to `server/lib/plugin-update.js` + the detached `scripts/plugin-update-helper.js`, which runs `claude plugin marketplace update` → `claude plugin update -y` → `plugin-bootstrap.js --force` from the NEW cache dir. Both write the HTTP response before anything stops the process, and every plugin-chain outcome is recorded to `plugin-update.json` in the runtime dir — outside the cache, so it survives the swap and the next server reports it as `last_plugin_update`. While it runs, the step it is on is `plugin-update-progress.json` → `plugin_update_progress` (the update modal's step line and the chip it leaves when hidden). Neither mode can run `/reload-plugins`: it is REPL-scoped to the session that invokes it, so an open Claude Code session still reloads by hand for new skills/commands. Elsewhere users run the printed manual command, surfaced in the UI and by `agenthub update-check`. `scripts/cc-update-all-helper.js` is the same detached pattern for the config panel's **Update all** button — marketplaces → every plugin → AgentHub LAST → one reload — journalling each step to `cc-update-all.json` in the runtime dir.) - `skills/`: this repo's skills. Because the repo root **is** the `agenthub` plugin root, this is also the plugin's skills directory — every skill here ships to anyone who installs the plugin, addressed as `/agenthub:` (bare `/` works as an alias). Put a skill here only if it should reach plugin users. The plugin ships no subagents and no pipeline skill, and depends on no other plugin (Superpowers included) — do not add an auto-install of one back into the bootstrap. - `mcp/`, `statusline/`, `monitoring/`: opt-in integrations that only READ the running dashboard over HTTP — the read-only MCP server (`agenthub mcp`, docs/MCP.md), the Claude Code statusline (`agenthub statusline install|uninstall`, docs/STATUSLINE.md) and Prometheus/Grafana files for `/api/metrics` (`docker-compose.monitoring.yml`, docs/MONITORING.md). Neither the MCP server nor the statusline is ever registered by the bootstrap; only the user's own command writes `statusLine`, never over a foreign one without `--force`, always with a backup. The MCP server and the statusline find the dashboard through `server/lib/dashboard-url.js`, the CLI's own discovery. - `.claude-plugin/`: the marketplace plus the root `agenthub` plugin manifest (`"source": "./"` — the whole repo is the plugin). Its hooks are inline in `plugin.json`; its commands live in `plugins/agenthub/commands/`, which is NOT a subdirectory plugin. `scripts/plugin-bootstrap.js` runs from `SessionStart` and owns the writable runtime under `~/.claude/agenthub/runtime/` — it never writes into the plugin cache, which Claude Code replaces on every update. See `docs/PLUGINS.md`. diff --git a/client/src/components/UpdateNotifier.tsx b/client/src/components/UpdateNotifier.tsx index d97febe7..1e18908d 100644 --- a/client/src/components/UpdateNotifier.tsx +++ b/client/src/components/UpdateNotifier.tsx @@ -19,7 +19,9 @@ * background process can do for it. Escape, the backdrop and × close it for * this page load only — also while an update applies: it keeps running in the * background (the page still reloads itself once the new server is up), and a - * failure re-opens the modal with its error; "Skip this version" is the one + * failure re-opens the modal with its error. While hidden, a small chip in the + * corner keeps showing the update — with the plugin chain's current step from + * `plugin_update_progress` — and a click on it shows the modal again; "Skip this version" is the one * persistent, per-version dismiss (never while applying). After a failed update, "Auto fix" hands the * error to an AI agent: it opens (or adopts) a workspace on the install folder * and starts a Claude Code session there with the error as its prompt — the @@ -184,6 +186,8 @@ export function UpdateNotifier() { const [applying, setApplying] = useState(false); const [restarting, setRestarting] = useState(false); const [fixing, setFixing] = useState(false); + /** The running plugin chain's step, from the polls while it runs. */ + const [progress, setProgress] = useState(null); const navigate = useNavigate(); const syncFromPayload = useCallback((s: UpdateStatusPayload) => { @@ -312,6 +316,7 @@ export function UpdateNotifier() { api.updates .status() .then((s) => { + setProgress(s.plugin_update_progress ?? null); const v = s.last_plugin_update; const mine = !!v?.finished_at && Date.parse(v.finished_at) >= since; // A failed step, or nothing to pull, leaves the OLD server up: it @@ -342,6 +347,7 @@ export function UpdateNotifier() { const applyNow = async () => { if (applying || restarting) return; setError(null); + setProgress(null); setApplying(true); try { const result = await api.updates.apply(); @@ -392,6 +398,53 @@ export function UpdateNotifier() { } }; + const stepText = progress?.step + ? t(`pluginStep.${progress.step}`, { + index: progress.index, + total: progress.total, + defaultValue: progress.step, + }) + : null; + + // Hidden while it runs: a chip keeps it in sight and brings the modal back. + if (!show && isAdmin && (applying || restarting) && dismissKey && closedKey === dismissKey) { + return ( + + ); + } + if (!show || !status) return null; const refLabel = status.remote_ref || "origin"; @@ -520,7 +573,14 @@ export function UpdateNotifier() { {/* The plugin chain pulls a new cache directory and reinstalls dependencies before it restarts, so it takes minutes, not seconds — saying "restarting" there would look hung. */} - {pluginManaged ? t("pluginUpdating") : t("restarting")} + + {pluginManaged ? t("pluginUpdating") : t("restarting")} + {stepText ? ( + + {stepText} + + ) : null} + ) : null} diff --git a/client/src/components/__tests__/UpdateNotifier.test.tsx b/client/src/components/__tests__/UpdateNotifier.test.tsx index 2c7a4e64..72a11aba 100644 --- a/client/src/components/__tests__/UpdateNotifier.test.tsx +++ b/client/src/components/__tests__/UpdateNotifier.test.tsx @@ -312,6 +312,58 @@ describe("UpdateNotifier — closing vs skipping", () => { }); }); +describe("UpdateNotifier — hidden while it runs", () => { + it("keeps a chip with the chain's step in sight and re-opens from it", async () => { + const PLUGIN: UpdateStatusPayload = { + ...BASE_STATUS, + situation: "plugin_managed", + install_kind: "plugin", + plugin_marketplace: "sg", + plugin_name: "agenthub", + commits_behind: 0, + local_version: "1.5.0", + release_check: "ok", + latest_release: "v1.6.0", + }; + statusMock.mockResolvedValue(PLUGIN); + applyMock.mockResolvedValue({ ...PLUGIN, applied: true, mode: "plugin" }); + render(); + await userEvent.click(await screen.findByText(i18n.t("updates:updateNow"))); + await screen.findAllByText(i18n.t("updates:pluginUpdating")); + statusMock.mockResolvedValue({ + ...PLUGIN, + plugin_update_progress: { + step: "plugin_update", + index: 2, + total: 3, + started_at: new Date().toISOString(), + }, + }); + const step = i18n.t("updates:pluginStep.plugin_update", { index: 2, total: 3 }); + expect(await screen.findByTestId("update-step", {}, { timeout: 4000 })).toHaveTextContent(step); + + await userEvent.keyboard("{Escape}"); + const chip = await screen.findByTestId("update-chip"); + expect(chip).toHaveTextContent(i18n.t("updates:chipTitle", { version: "v1.6.0" })); + expect(chip).toHaveTextContent(step); + await userEvent.click(chip); + expect( + await screen.findByText(i18n.t("updates:releaseTitle", { version: "v1.6.0" })) + ).toBeInTheDocument(); + expect(screen.queryByTestId("update-chip")).toBeNull(); + }); + + it("shows no chip when nothing is running", async () => { + render(); + await screen.findByText(i18n.t("updates:title")); + await userEvent.keyboard("{Escape}"); + await waitFor(() => + expect(screen.queryByText(i18n.t("updates:title"))).not.toBeInTheDocument() + ); + expect(screen.queryByTestId("update-chip")).toBeNull(); + }); +}); + describe("UpdateNotifier — Auto fix", () => { it("offers no Not now button, and Auto fix only once an update failed", async () => { applyMock.mockRejectedValue(new Error("Command failed: git merge --ff-only v2.8.0")); diff --git a/client/src/components/lanes/CloneHereDialog.tsx b/client/src/components/lanes/CloneHereDialog.tsx index be003b23..550e56d1 100644 --- a/client/src/components/lanes/CloneHereDialog.tsx +++ b/client/src/components/lanes/CloneHereDialog.tsx @@ -7,6 +7,10 @@ * folder's account with that account's own SSH key / HTTPS login — and opens * a workspace on the new folder. A 409 `ECREDENTIALS` opens a login row (sent * once, optionally remembered), `ESSHAUTH` points to Settings → Git. + * A running clone shows `CloneProgress` and never holds the page: closing the + * dialog only hides it behind a small progress chip (click to reopen), the + * request keeps going, and its outcome arrives as a toast — a failure is kept + * for the next open so a login row is still there to fill in. * @author Nguyễn Ngọc Trí Vĩ */ @@ -18,6 +22,8 @@ import { api } from "../../lib/api"; import type { Lane } from "../../lib/types"; import { useDialog } from "../../hooks/useDialog"; import { cloneDirName } from "./AddLaneModal"; +import { CloneProgress, newCloneToken } from "./CloneProgress"; +import { toast } from "../../lib/toast"; type Login = { host: string; username: string; token: string; remember: boolean }; @@ -26,12 +32,15 @@ export function CloneHereDialog({ parent, onClose, onCloned, + onReopen, }: { open: boolean; /** The absolute folder the clone goes into. */ parent: string; onClose: () => void; onCloned: (lane: Lane, path: string) => void; + /** Show the dialog again from the background chip. */ + onReopen: () => void; }) { const { t } = useTranslation(["lanes"]); const panelRef = useRef(null); @@ -42,10 +51,23 @@ export function CloneHereDialog({ const [sshRefused, setSshRefused] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); - useDialog(open, panelRef, busy ? () => {} : onClose); + const [token, setToken] = useState(null); + /** Where the running clone goes (the open folder may change meanwhile). */ + const [target, setTarget] = useState(""); + // Read by the request's own continuation and by the reset below. + const openRef = useRef(open); + openRef.current = open; + const busyRef = useRef(false); + /** A clone failed while hidden: the next open shows that, not a blank form. */ + const keepRef = useRef(false); + useDialog(open, panelRef, onClose); useEffect(() => { - if (!open) return; + if (!open || busyRef.current) return; + if (keepRef.current) { + keepRef.current = false; + return; + } setUrl(""); setName(""); setLogin(null); @@ -54,14 +76,39 @@ export function CloneHereDialog({ setError(null); }, [open, parent]); - if (!open) return null; const folder = name.trim() || cloneDirName(url); - const into = `${parent.replace(/\/+$/, "")}/${folder || "…"}`; + const into = busy ? target : `${parent.replace(/\/+$/, "")}/${folder || "…"}`; + + if (!open) { + if (!busy || !token) return null; + return createPortal( + , + document.body + ); + } const submit = async () => { const u = url.trim(); if (!u || busy) return; + const progressToken = newCloneToken(); + busyRef.current = true; setBusy(true); + setToken(progressToken); + setTarget(into); setError(null); setSshRefused(false); try { @@ -69,6 +116,7 @@ export function CloneHereDialog({ url: u, parent, name: name.trim() || undefined, + progressToken, ...(login?.username.trim() && login.token ? { credentials: { @@ -79,12 +127,16 @@ export function CloneHereDialog({ } : {}), }); + busyRef.current = false; + setBusy(false); + setToken(null); onCloned(result.lane, result.path); } catch (err) { const e = err as { code?: string; auth?: { host?: string }; message?: string }; const host = e.auth?.host || u; + let message: string; if (e.code === "ECREDENTIALS") { - setError(t(login?.token ? "clone.loginRejected" : "clone.loginNeeded", { host })); + message = t(login?.token ? "clone.loginRejected" : "clone.loginNeeded", { host }); setLogin((l) => ({ host, username: l?.username ?? "", @@ -93,18 +145,25 @@ export function CloneHereDialog({ })); } else if (e.code === "ESSHAUTH") { setSshRefused(true); - setError(t("clone.sshRefused", { host })); + message = t("clone.sshRefused", { host }); } else { - setError(err instanceof Error ? err.message : String(err)); + message = err instanceof Error ? err.message : String(err); } + setError(message); + busyRef.current = false; setBusy(false); + setToken(null); + if (!openRef.current) { + keepRef.current = true; + toast.error(message); + } } }; return createPortal(
e.stopPropagation()} @@ -119,7 +178,10 @@ export function CloneHereDialog({ className="w-full max-w-lg rounded-xl border border-border bg-surface-1 p-4 shadow-2xl shadow-black/50" onClick={(e) => e.stopPropagation()} > -

+

{t("fx.cloneRepo")}

@@ -162,7 +224,10 @@ export function CloneHereDialog({

{login && ( -
+
)} + {busy && token && } {error && (

{error}{" "} @@ -204,8 +270,8 @@ export function CloneHereDialog({

)}
-
- {id.writable && ( - )} - {k && id.writable && ( + {k && writable && (