fix(updates): report a plugin build installed beside the running one, never "up to date" on a failed check

A `claude plugin update` run from a terminal (or with only the Claude desktop
app open) unpacks the new build and starts nothing: no SessionStart fires, so
the dashboard kept running the old cache dir across reboots, and with the
forge unreachable the sidebar read "Up to date" (seen on a machine stuck at
1.8.10).

- pluginStatus asks supersededBy for its own root: a recorded sibling install
  sets `installed_not_running: {root, version}` and `update_available`, with
  no network needed
- the sidebar reads "<version> installed, not running"; the update modal keys
  on it, so Update now is offered even with no release tag or sha
- plugin-update-helper hands over to the recorded install first, the newest
  sibling dir only as a fallback
- a plugin install whose release lookup failed reads "Could not check for
  updates" instead of "Up to date"
- docs/PLUGINS.md: the case and the manual hand-over command
This commit is contained in:
2026-10-01 10:09:12 +07:00
parent 969b06253c
commit 058c574138
14 changed files with 208 additions and 27 deletions
@@ -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(<UpdateNotifier />);
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,