diff --git a/client/src/components/Sidebar.tsx b/client/src/components/Sidebar.tsx index 75882d06..b3ac6024 100644 --- a/client/src/components/Sidebar.tsx +++ b/client/src/components/Sidebar.tsx @@ -368,15 +368,25 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) { ? t("nav:checkingForUpdates") : checkError ? t("nav:checkFailed") - : updateAvailable - ? updateStatus?.latest_release - ? t("nav:releaseAvailable", { version: updateStatus.latest_release }) - : t("nav:updateAvailable") - : updateStatus?.dev_build - ? t("nav:devBuild", { count: updateStatus.commits_ahead_of_release ?? 0 }) - : updateStatus - ? t("nav:upToDate") - : t("nav:checkForUpdates"); + : updateStatus?.installed_not_running + ? t("nav:installedNotRunning", { + version: updateStatus.installed_not_running.version ?? "?", + }) + : updateAvailable + ? updateStatus?.latest_release + ? t("nav:releaseAvailable", { version: updateStatus.latest_release }) + : t("nav:updateAvailable") + : updateStatus?.dev_build + ? t("nav:devBuild", { count: updateStatus.commits_ahead_of_release ?? 0 }) + : // A plugin install has no commit count to fall back on: a release + // that could not be read is "unknown", never "up to date". + updateStatus?.install_kind === "plugin" && + updateStatus.release_check && + updateStatus.release_check !== "ok" + ? t("nav:releaseUnchecked") + : updateStatus + ? t("nav:upToDate") + : t("nav:checkForUpdates"); const currentLanguage = normalizeLanguage(i18n.resolvedLanguage ?? i18n.language); const currentIndex = SUPPORTED_LANGUAGES.indexOf(currentLanguage); const nextLanguage = SUPPORTED_LANGUAGES[(currentIndex + 1) % SUPPORTED_LANGUAGES.length]; diff --git a/client/src/components/UpdateNotifier.tsx b/client/src/components/UpdateNotifier.tsx index f85327aa..33974003 100644 --- a/client/src/components/UpdateNotifier.tsx +++ b/client/src/components/UpdateNotifier.tsx @@ -185,7 +185,13 @@ export function UpdateNotifier() { // also swallow v1.6.0 — and so the prompt survives the tag pointing at a // commit the user already has. Falls back to the upstream SHA for the // commit-count path (no release readable). - const dismissKey = status?.latest_release || status?.remote_sha || null; + // A build installed from a terminal while this server kept running has no + // tag or sha when the forge is unreachable — key it by its directory. + const installed = status?.installed_not_running ?? null; + const dismissKey = + status?.latest_release || + status?.remote_sha || + (installed ? `installed:${installed.root}` : null); const show = Boolean(status?.update_available && dismissKey && dismissedSha !== dismissKey); const dismiss = useCallback(() => { @@ -320,12 +326,16 @@ export function UpdateNotifier() { id="update-notifier-title" className="text-sm font-semibold text-fg-primary truncate" > - {release ? t("releaseTitle", { version: release }) : t("title")} + {installed + ? t("installedTitle", { version: installed.version ?? "?" }) + : release + ? t("releaseTitle", { version: release }) + : t("title")}

{/* Never print a commit count on a shallow clone — the number is a graft artefact, not a distance. */} - {release && !status.shallow_clone && status.local_version + {(release || installed) && !status.shallow_clone && status.local_version ? t("releaseSubtitle", { current: status.local_version }) : t("commitsBehind", { count: behind, ref: refLabel })}

diff --git a/client/src/components/__tests__/Sidebar.test.tsx b/client/src/components/__tests__/Sidebar.test.tsx index 2f4fd51f..a77cf7de 100644 --- a/client/src/components/__tests__/Sidebar.test.tsx +++ b/client/src/components/__tests__/Sidebar.test.tsx @@ -5,10 +5,11 @@ */ import { describe, it, expect } from "vitest"; -import { render, screen, waitFor } from "@testing-library/react"; +import { act, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { MemoryRouter } from "react-router-dom"; import { Sidebar } from "../Sidebar"; +import { eventBus } from "../../lib/eventBus"; function renderSidebar(wsConnected: boolean, collapsed = false) { return render( @@ -103,4 +104,44 @@ describe("Sidebar", () => { expect(screen.getByText("VI")).toBeInTheDocument(); }); }); + + describe("update status line", () => { + const plugin = { + git_repo: false, + install_kind: "plugin", + situation: "plugin_managed", + local_version: "1.8.10", + }; + const publish = (data: object) => + act(() => eventBus.publish({ type: "update_status", data } as never)); + + it("does not claim up to date when a plugin install could not read the release", () => { + renderSidebar(true); + publish({ ...plugin, update_available: false, release_check: "unreachable" }); + expect(screen.queryByText("Up to date")).toBeNull(); + expect(screen.getByText("Could not check for updates")).toBeInTheDocument(); + }); + + it("names a build installed beside the running one", () => { + renderSidebar(true); + publish({ + ...plugin, + update_available: true, + release_check: "unreachable", + installed_not_running: { root: "/x", version: "1.8.12" }, + }); + expect(screen.getByText("1.8.12 installed, not running")).toBeInTheDocument(); + }); + + it("still says up to date when the release was read and matches", () => { + renderSidebar(true); + publish({ + ...plugin, + update_available: false, + release_check: "ok", + latest_release: "v1.8.10", + }); + expect(screen.getByText("Up to date")).toBeInTheDocument(); + }); + }); }); diff --git a/client/src/components/__tests__/UpdateNotifier.test.tsx b/client/src/components/__tests__/UpdateNotifier.test.tsx index b3793719..3dac8876 100644 --- a/client/src/components/__tests__/UpdateNotifier.test.tsx +++ b/client/src/components/__tests__/UpdateNotifier.test.tsx @@ -137,6 +137,33 @@ describe("UpdateNotifier — Update now", () => { expect(screen.queryByText(i18n.t("updates:notGit"))).not.toBeInTheDocument(); }); + it("offers Update now for a build installed from a terminal, even with no release read", async () => { + // `claude plugin update` started nothing and the forge was unreachable: + // no release tag and no sha to key the modal on, yet a newer build sits + // on disk. That used to read as "up to date" with no way to switch. + statusMock.mockResolvedValue({ + ...BASE_STATUS, + git_repo: false, + situation: "plugin_managed", + install_kind: "plugin", + plugin_marketplace: "sg", + plugin_name: "agenthub", + remote_sha: null, + commits_behind: 0, + local_version: "1.8.10", + release_check: "unreachable", + latest_release: null, + installed_not_running: { root: "/cache/sg/agenthub/new", version: "1.8.12" }, + }); + render(); + + await screen.findByText(i18n.t("updates:installedTitle", { version: "1.8.12" })); + expect( + screen.getByText(i18n.t("updates:releaseSubtitle", { current: "1.8.10" })) + ).toBeInTheDocument(); + expect(screen.getByText(i18n.t("updates:updateNow"))).toBeInTheDocument(); + }); + it("surfaces a previous plugin chain's failure so a dead update isn't silent", async () => { statusMock.mockResolvedValue({ ...BASE_STATUS, diff --git a/client/src/i18n/locales/en/nav.json b/client/src/i18n/locales/en/nav.json index 9b533636..7f9d462b 100644 --- a/client/src/i18n/locales/en/nav.json +++ b/client/src/i18n/locales/en/nav.json @@ -68,5 +68,7 @@ "workspace": "Workspace", "wsEndpoint": "WebSocket", "releaseAvailable": "{{version}} available", - "devBuild": "Dev build · {{count}} ahead" + "devBuild": "Dev build · {{count}} ahead", + "installedNotRunning": "{{version}} installed, not running", + "releaseUnchecked": "Could not check for updates" } diff --git a/client/src/i18n/locales/en/updates.json b/client/src/i18n/locales/en/updates.json index 031abdd3..e87ba6cf 100644 --- a/client/src/i18n/locales/en/updates.json +++ b/client/src/i18n/locales/en/updates.json @@ -26,5 +26,6 @@ "pluginAutoNote": "This dashboard is installed as a Claude Code plugin. \"Update now\" pulls the new plugin version, reinstalls the runtime, and restarts the server for you — it takes a few minutes. Newly added skills and slash commands only appear in an already-open Claude Code session after you run /reload-plugins there.", "pluginUpdating": "Pulling the new plugin version and reinstalling the runtime — this takes a few minutes, the page reloads itself when the new server is up...", "lastPluginUpdateFailed": "The last plugin update failed at step \"{{step}}\": {{error}} — run /agenthub-update in Claude Code to recover.", - "restartingShort": "Restarting…" + "restartingShort": "Restarting…", + "installedTitle": "Version {{version}} is installed but not running" } diff --git a/client/src/i18n/locales/vi/nav.json b/client/src/i18n/locales/vi/nav.json index dbf3046b..ed487baf 100644 --- a/client/src/i18n/locales/vi/nav.json +++ b/client/src/i18n/locales/vi/nav.json @@ -68,5 +68,7 @@ "workspace": "Không gian làm việc", "wsEndpoint": "WebSocket", "releaseAvailable": "Có {{version}}", - "devBuild": "Bản dev · trước {{count}} commit" + "devBuild": "Bản dev · trước {{count}} commit", + "installedNotRunning": "Đã cài {{version}}, chưa chạy", + "releaseUnchecked": "Không kiểm tra được bản mới" } diff --git a/client/src/i18n/locales/vi/updates.json b/client/src/i18n/locales/vi/updates.json index 0fa9194f..ea130491 100644 --- a/client/src/i18n/locales/vi/updates.json +++ b/client/src/i18n/locales/vi/updates.json @@ -26,5 +26,6 @@ "pluginAutoNote": "Dashboard này cài dạng plugin của Claude Code. Bấm \"Cập nhật ngay\" sẽ tự tải bản plugin mới, cài lại runtime và khởi động lại server — mất vài phút. Riêng skill và slash command mới thì session Claude Code đang mở chỉ thấy sau khi bạn chạy /reload-plugins ở đó.", "pluginUpdating": "Đang tải bản plugin mới và cài lại runtime — mất vài phút, trang sẽ tự reload khi server mới lên...", "lastPluginUpdateFailed": "Lần cập nhật plugin trước thất bại ở bước \"{{step}}\": {{error}} — chạy /agenthub-update trong Claude Code để khắc phục.", - "restartingShort": "Đang khởi động lại…" + "restartingShort": "Đang khởi động lại…", + "installedTitle": "Đã cài bản {{version}} nhưng chưa chạy" } diff --git a/client/src/lib/types.ts b/client/src/lib/types.ts index cfb45c1e..3c187978 100644 --- a/client/src/lib/types.ts +++ b/client/src/lib/types.ts @@ -1352,6 +1352,10 @@ export interface UpdateStatusPayload { * or `"http_"`. Anything but `"ok"` means detection fell back to * the commit-count comparison. */ release_check?: string; + /** Plugin installs only: a newer build Claude Code has installed beside the + * running one (`claude plugin update` from a terminal starts nothing), or + * null. Sets `update_available`; "Update now" hands the server over to it. */ + installed_not_running?: { root: string; version: string | null } | null; /** Tag of the newest published release, e.g. `"v1.5.0"`. null when * `release_check !== "ok"`. */ latest_release?: string | null; diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index 7ca1a989..8e18be8a 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -185,6 +185,34 @@ key, which holds without parsing a cache path or trusting a name. An entry pointing at a directory that no longer exists is ignored — a phantom must never wedge a bootstrap shut. +### A build installed while the server kept running + +The same record answers the opposite case. `claude plugin update` run from a +terminal (or with only the Claude desktop app open) starts **nothing**: no +SessionStart fires, so no bootstrap hands the server over, and the dashboard +keeps running the old directory — across reboots too, since +`agenthub.service` still names it and it still exists. `agenthub open` only +opens the browser. + +So `getUpdatesStatus` (`server/lib/update-check.js`) asks `supersededBy` for +its own root: when a sibling build is the installed one, the status carries +`installed_not_running: {root, version}` and `update_available: true` — with +no network needed, so an unreachable forge can no longer hide it. The sidebar +reads **`` installed, not running**, and the update modal's **Update +now** runs the usual chain; `scripts/plugin-update-helper.js` hands over to the +recorded install first (`supersededBy(oldRoot)`), falling back to the newest +sibling directory only when there is no record. + +Separately, a plugin install whose release lookup failed (`release_check` not +`ok`) now reads **Could not check for updates** in the sidebar instead of +**Up to date** — a plugin install has no commit count to fall back on. + +To hand over by hand without the dashboard: + +```bash +node "$(python3 -c 'import json,os;d=json.load(open(os.path.expanduser("~/.claude/plugins/installed_plugins.json")));print([e["installPath"] for k,v in d["plugins"].items() if k.startswith("agenthub@") for e in v][0])')/scripts/plugin-bootstrap.js" --force +``` + ### Pruning the plugin cache Claude Code unpacks every update beside the last one and does not reliably diff --git a/scripts/plugin-update-helper.js b/scripts/plugin-update-helper.js index 2caa2b89..87015881 100644 --- a/scripts/plugin-update-helper.js +++ b/scripts/plugin-update-helper.js @@ -158,7 +158,10 @@ function main() { return; } - const newRoot = findNewRoot(oldRoot); + // Claude Code's own install record first: newest-mtime is only a guess, and a + // build installed from a terminal while this server kept running is exactly + // the one to hand over to, whether or not this chain pulled anything. + const newRoot = require("./plugin-bootstrap").supersededBy(oldRoot) || findNewRoot(oldRoot); if (!newRoot) { // Already newest: the marketplace had nothing to pull. Not a failure — the // old server is still running and untouched, so say so plainly. diff --git a/server/__tests__/update-check-plugin-nogit.test.js b/server/__tests__/update-check-plugin-nogit.test.js index fbb402fe..bdbd0add 100644 --- a/server/__tests__/update-check-plugin-nogit.test.js +++ b/server/__tests__/update-check-plugin-nogit.test.js @@ -384,3 +384,39 @@ describe("fetchReleaseFromUrl", () => { } }); }); + +describe("a newer build installed beside the running one", () => { + /** Record `installed` as Claude Code's install of agenthub@sg. */ + function recordInstall(home, installed) { + fs.writeFileSync( + path.join(home, "plugins", "installed_plugins.json"), + JSON.stringify({ plugins: { "agenthub@sg": [{ scope: "user", installPath: installed }] } }) + ); + } + + it("reports an update even when the release check fails", async () => { + // `claude plugin update` from a terminal unpacks the new build and starts + // nothing: the server keeps running the old directory, and with the forge + // unreachable it used to answer "up to date" forever. + const { root, home } = makePluginInstall("installed-newer", { version: "1.8.10" }); + const newer = path.join(path.dirname(root), "aaaaaaaaaaaa"); + fs.mkdirSync(newer, { recursive: true }); + fs.writeFileSync(path.join(newer, "package.json"), JSON.stringify({ version: "1.8.12" })); + recordInstall(home, newer); + + const s = await getUpdatesStatus(root, { + latestRelease: { ok: false, reason: "unreachable" }, + }); + assert.equal(s.update_available, true); + assert.deepEqual(s.installed_not_running, { root: newer, version: "1.8.12" }); + assert.match(s.message, /1\.8\.12 is installed but not running/); + }); + + it("says nothing extra when the running build is the installed one", async () => { + const { root, home } = makePluginInstall("installed-same", { version: "1.8.12" }); + recordInstall(home, root); + const s = await getUpdatesStatus(root, { latestRelease: release("v1.8.12") }); + assert.equal(s.update_available, false); + assert.equal(s.installed_not_running, null); + }); +}); diff --git a/server/lib/update-check.js b/server/lib/update-check.js index c9509683..f0b69094 100644 --- a/server/lib/update-check.js +++ b/server/lib/update-check.js @@ -415,6 +415,18 @@ async function pluginStatus(root, pluginInstall, options = {}) { const comparable = release.ok && Boolean(localVersion); const releaseIsNewer = comparable && compareVersions(release.tag, localVersion) > 0; + // `claude plugin update` run from a terminal unpacks the new build into a + // sibling cache dir and starts NOTHING — only a SessionStart or this + // dashboard's own update chain hands the server over. Without a Claude Code + // session this server ran the old build forever, and with the forge + // unreachable it said "up to date". The local record needs no network, and + // "Update now" already re-bootstraps from the newest sibling. + const installedRoot = require("../../scripts/plugin-bootstrap").supersededBy(root); + const installedNotRunning = installedRoot + ? { root: installedRoot, version: readLocalVersion(installedRoot) } + : null; + const updateAvailable = releaseIsNewer || Boolean(installedNotRunning); + const manualParts = [ `claude plugin marketplace update ${pluginInstall.marketplace}`, `claude plugin update ${pluginInstall.plugin}@${pluginInstall.marketplace} -y`, @@ -425,7 +437,8 @@ async function pluginStatus(root, pluginInstall, options = {}) { // `install_kind`, not on this, or they will report "check manually" for an // install the dashboard can in fact check AND update. git_repo: false, - update_available: releaseIsNewer, + update_available: updateAvailable, + installed_not_running: installedNotRunning, repo_root: root, situation: "plugin_managed", situation_note: @@ -438,13 +451,16 @@ async function pluginStatus(root, pluginInstall, options = {}) { // gets the same sentence. Left null, `agenthub update-check` printed the // literal "null" as its headline on every plugin-cache install — the one // install kind that cannot fall back to a commit count. - message: releaseIsNewer - ? `Release ${release.tag} is available (this install reports ${localVersion || "no version"}).` - : comparable - ? `Running the newest release (${release.tag}).` - : release.ok - ? `This install reports no version, so it cannot be compared with release ${release.tag}.` - : `Could not read the latest release (${release.reason}); version comparison unavailable.`, + message: installedNotRunning + ? `Version ${installedNotRunning.version || "(unknown)"} is installed but not running ` + + `(this server runs ${localVersion || "no version"}); "Update now" switches the dashboard to it.` + : releaseIsNewer + ? `Release ${release.tag} is available (this install reports ${localVersion || "no version"}).` + : comparable + ? `Running the newest release (${release.tag}).` + : release.ok + ? `This install reports no version, so it cannot be compared with release ${release.tag}.` + : `Could not read the latest release (${release.reason}); version comparison unavailable.`, install_kind: "plugin", plugin_marketplace: pluginInstall.marketplace, diff --git a/server/openapi.js b/server/openapi.js index b246d3d3..8cf43419 100644 --- a/server/openapi.js +++ b/server/openapi.js @@ -2419,7 +2419,7 @@ function createOpenApiSpec() { type: "object", additionalProperties: true, description: - 'Reads the newest published release from the forge API (derived from the origin remote) and compares its tag against this install\'s package.json version; falls back to a commit count against the canonical default branch when no release can be read, with the reason in release_check. Includes git_repo, update_available, install_kind ("plugin" | "checkout"), plugin_marketplace, local_version, shallow_clone, release_check, latest_release, latest_release_name, latest_release_url, latest_release_published_at, latest_release_notes, release_sha, commits_behind_release, commits_ahead_of_release, dev_build, commits_behind, remote_ref, local_sha, remote_sha, situation, manual_command, and optional error/message fields. dev_build is true when this install is strictly ahead of the newest release; commits_* fields are null on a shallow clone, where distances are meaningless.', + 'Reads the newest published release from the forge API (derived from the origin remote) and compares its tag against this install\'s package.json version; falls back to a commit count against the canonical default branch when no release can be read, with the reason in release_check. Includes git_repo, update_available, install_kind ("plugin" | "checkout"), plugin_marketplace, local_version, shallow_clone, release_check, latest_release, latest_release_name, latest_release_url, latest_release_published_at, latest_release_notes, release_sha, commits_behind_release, commits_ahead_of_release, dev_build, commits_behind, remote_ref, local_sha, remote_sha, situation, manual_command, and optional error/message fields. installed_not_running ({root, version} or null, plugin installs only) names a newer build Claude Code installed beside the running one and sets update_available without any network; dev_build is true when this install is strictly ahead of the newest release; commits_* fields are null on a shallow clone, where distances are meaningless.', }, }, },