magus-cli 7.2.0 → 7.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -34,6 +34,7 @@ import {
34
34
  type PluginInfo,
35
35
  compareVersions,
36
36
  isBehind,
37
+ isRepairablyBehind,
37
38
  } from "./plugin-manager.js";
38
39
  import {
39
40
  type ToolFacts,
@@ -54,6 +55,23 @@ export type PluginUpdateAction =
54
55
  | "repair"
55
56
  /** Already on the right version. */
56
57
  | "current"
58
+ /**
59
+ * BROKEN: the right version is installed, but Claude Code's loader will
60
+ * resolve an OLDER one — so the session runs stale code while every version
61
+ * magus records looks correct.
62
+ *
63
+ * A REINSTALL DOES NOT FIX IT (measured). `claude plugin install` writes a
64
+ * row for this project, and the row that wins belongs to another one — often
65
+ * a worktree that no longer exists, whose registry entry nothing prunes.
66
+ *
67
+ * It IS applied, by `repairStaleLoad` (services/claude-settings.ts), which
68
+ * moves those rows forward onto the installed version rather than reinstalling
69
+ * anything. `target` is therefore the version installed here — the one whose
70
+ * cache directory exists — and not what the catalog offers. `scopes` stays
71
+ * empty because this is not a per-scope install: one registry rewrite covers
72
+ * every row, whichever scope or project each belongs to.
73
+ */
74
+ | "stale-load"
57
75
  /** The catalog check failed; we cannot claim either way. */
58
76
  | "unknown";
59
77
 
@@ -78,6 +96,8 @@ export type PluginReason =
78
96
  | "content-stale"
79
97
  /** update: a scope is provably behind. */
80
98
  | "behind"
99
+ /** stale-load: the loader resolves an older version than the one installed. */
100
+ | "older-version-loads"
81
101
  /** current|update: an exact pin differs from what the marketplace offers. */
82
102
  | "pin-undeliverable"
83
103
  /** current: every installed scope is at (or ahead of) the target. */
@@ -89,6 +109,16 @@ export interface PluginUpdateItem {
89
109
  pinned: string;
90
110
  /** Highest version installed in any scope, or null when absent. */
91
111
  installed: string | null;
112
+ /**
113
+ * The version Claude Code's loader will ACTUALLY load in this project, or
114
+ * null when it will load nothing (and when the registry was unreadable).
115
+ *
116
+ * A separate column from `installed` because they answer different questions
117
+ * and routinely disagree — see services/plugin-resolution.ts. `installed` is
118
+ * what the settings file and this project's registry rows record; `loaded` is
119
+ * what runs.
120
+ */
121
+ loaded: string | null;
92
122
  /** Version the marketplace catalog offers, or null when unknown. */
93
123
  available: string | null;
94
124
  /** Version this item would move to. Null for `current`/`unknown`. */
@@ -142,6 +172,40 @@ function highestInstalled(info: PluginInfo | undefined): string | null {
142
172
  );
143
173
  }
144
174
 
175
+ /**
176
+ * The highest installed version THE PIN PERMITS — the ceiling a stale-load
177
+ * repair may aim at. Null when no installed version qualifies.
178
+ *
179
+ * Under "latest" that is simply {@link highestInstalled}: nothing caps it, and
180
+ * the newest version on this machine is the one to converge on.
181
+ *
182
+ * Under an EXACT pin it is the highest installed version at or below the pin.
183
+ * `installed` reaches across every scope and `scopesNeeding` never downgrades, so
184
+ * a scope sitting above an exact pin is a normal, permanent state — not something
185
+ * to plan toward. Comparing the stale-load branch against the uncapped
186
+ * `installed` made a plugin loading exactly its pinned version read as BROKEN,
187
+ * and the repair then moved the pinned rows off the pin.
188
+ *
189
+ * A version that cannot be ORDERED against the pin (a commit sha, the literal
190
+ * "unknown") is excluded rather than assumed to qualify: "cannot tell" must never
191
+ * license a registry rewrite, which is the same rule {@link isRepairablyBehind}
192
+ * applies one step later.
193
+ */
194
+ function highestPermittedInstalled(
195
+ info: PluginInfo | undefined,
196
+ pin: string,
197
+ ): string | null {
198
+ if (pin === "latest") return highestInstalled(info);
199
+ const permitted = scopeVersions(info)
200
+ .map(([, v]) => v)
201
+ // pin >= v, i.e. v is at or below the pin. Null (unorderable) fails this.
202
+ .filter((v) => (compareVersions(pin, v) ?? -1) >= 0);
203
+ if (permitted.length === 0) return null;
204
+ return permitted.reduce((a, b) =>
205
+ (compareVersions(a, b) ?? -1) >= 0 ? a : b,
206
+ );
207
+ }
208
+
145
209
  /**
146
210
  * Scopes that are strictly BEHIND `target`.
147
211
  *
@@ -277,6 +341,7 @@ export function classifyPlugin(
277
341
  pluginId,
278
342
  pinned: pin,
279
343
  installed,
344
+ loaded: info?.loadedVersion ?? null,
280
345
  available,
281
346
  beforeVersions: Object.fromEntries(scopeVersions(info)) as Partial<
282
347
  Record<PluginScope, string>
@@ -375,6 +440,112 @@ export function classifyPlugin(
375
440
  };
376
441
  }
377
442
 
443
+ // Installed and listed — but the loader will resolve an OLDER version than
444
+ // what is installed here, so this session runs stale code.
445
+ //
446
+ // Decided BEFORE every branch below, and that ordering is the whole fix.
447
+ // Those branches compare the REGISTERED version against the catalog, and the
448
+ // registered version is precisely the one that is not loading: a plugin at
449
+ // the newest version in every scope classified `current` while Claude Code
450
+ // loaded a two-versions-old copy. The report was green about code that was
451
+ // not running — which is worse than no report, because magus is the tool
452
+ // relied on to stay current.
453
+ //
454
+ // `loaded` is the loader's own answer (services/plugin-resolution.ts), not
455
+ // magus's registry resolution. Null means we could not establish it, and
456
+ // "cannot tell" never becomes a finding here — the plugin falls through to
457
+ // the ordinary comparisons below.
458
+ //
459
+ // `target` is what is INSTALLED here, never `available`. The repair points
460
+ // registry rows at a cache directory, and only the installed version's cache
461
+ // is on disk — aiming at the catalog version would name a directory that does
462
+ // not exist, which `repairStaleLoad` refuses. When the install is also behind
463
+ // the catalog, the next run plans the ordinary `update` on top, so the two
464
+ // steps converge instead of competing.
465
+ //
466
+ // `scopes` stays empty: one registry rewrite covers every row, so there is no
467
+ // per-scope work to enumerate. `pluginNeedsWork` says so directly.
468
+ //
469
+ // `isRepairablyBehind`, NOT `isBehind` — the one predicate `findStaleLoads`
470
+ // asks too, so this header's count and the startup popup's count are the same
471
+ // question. `isBehind` calls two differing commit shas "behind", which is the
472
+ // right answer to ITS question and the wrong one to this: moving rows forward
473
+ // between two unordered shas is a guess, not a repair. That difference alone
474
+ // made the popup claim 8 broken plugins where this header said 6.
475
+ //
476
+ // INSTALLED against LOADED — never `available` against loaded. This state is
477
+ // "what is installed HERE is not what runs here", and the catalog is not a
478
+ // party to it. Asking the catalog question here was wrong in both directions,
479
+ // measured against this module:
480
+ //
481
+ // - installed 7.1.0, loaded 7.1.0, catalog 7.2.0 — an ordinary pending
482
+ // release, nothing stale at all — classified `stale-load` with an empty
483
+ // `scopes`, so no installer ran, `repairStaleLoad` found no row behind
484
+ // 7.1.0, wrote nothing, and its post-write assertion passed because the
485
+ // loader already resolved 7.1.0. The engine counted that as a success and
486
+ // 7.2.0 was never installed. Every run classified identically while
487
+ // `planIsBehind` stayed true, so `magus update --check` failed forever over
488
+ // something `magus update` could not clear — the un-greenable gate the note
489
+ // on `planIsBehind` argues must not exist. It fired on any machine with a
490
+ // pending release, which is the normal state for this marketplace.
491
+ // - a marketplace publishing NO version (278 of the 292 official plugins)
492
+ // leaves `available` null, so `isRepairablyBehind` answered false and a
493
+ // genuine stale load classified `current` — green about a session running
494
+ // old code, the exact defect this state exists to end.
495
+ //
496
+ // The two formulas agree whenever `available` equals `installed`, which is
497
+ // every machine with nothing pending — which is why it held up by hand and why
498
+ // the parity fixture in stale-load-repair.test.ts had to be changed to be able
499
+ // to fail at all.
500
+ //
501
+ // BOTH AT ONCE — stale-loaded AND behind the catalog — is deliberately NOT
502
+ // folded together, and neither fact is dropped. This branch wins (what runs is
503
+ // the more consequential fact, and a reinstall is measured not to clear it),
504
+ // `target` stays an installed version because only an installed version's cache
505
+ // directory exists, and the pending catalog version is named in the note rather
506
+ // than left for the user to infer from a column. `magus update` then converges
507
+ // over consecutive runs: repair the load, then install the update once the load
508
+ // is no longer stale. It is not collapsed into one run because the two are
509
+ // different mechanisms — a registry rewrite and an installer — and the apply
510
+ // engine runs one action per item; interleaving them silently would make a
511
+ // failed half look like a whole success.
512
+ //
513
+ // CAPPED AT THE PIN, which is why the comparison is against `staleTarget` and
514
+ // not `installed`. `installed` is the highest version in ANY scope, and
515
+ // `scopesNeeding` deliberately never downgrades, so a scope legitimately sits
516
+ // ABOVE an exact pin. Measured: pin 0.3.0, project scope at 0.3.0 and loading,
517
+ // user scope at 0.5.0 — `installed` is 0.5.0, so an uncapped comparison called
518
+ // a plugin loading exactly what its pin asked for a broken load, and the repair
519
+ // then succeeded (this repo's row is writable, and 0.5.0's cache exists because
520
+ // user scope installed it), moving a pinned plugin off its pin and reporting
521
+ // success. An exact pin is a request, not a starting point: this branch may
522
+ // never plan past it, for the same reason the pin branch below refuses to plan
523
+ // toward something the marketplace cannot deliver.
524
+ //
525
+ // A pin naming a version installed NOWHERE at or below it leaves `staleTarget`
526
+ // null and the branch silent. That is the honest answer rather than a quiet
527
+ // promotion: the only versions on disk are above the pin, so there is no repair
528
+ // this may perform, and the pin branch below reports the pin state instead.
529
+ const staleTarget = highestPermittedInstalled(info, pin);
530
+ if (isRepairablyBehind(staleTarget, base.loaded)) {
531
+ // Only ever under "latest". Under an exact pin the catalog is not what this
532
+ // plugin is judged against, so naming a newer published version as something
533
+ // "available once the load is repaired" would advertise a move the pin
534
+ // forbids.
535
+ const pending =
536
+ pin === "latest" && isRepairablyBehind(available, installed);
537
+ return {
538
+ ...base,
539
+ target: staleTarget,
540
+ action: "stale-load",
541
+ reason: "older-version-loads",
542
+ scopes: [],
543
+ note: pending
544
+ ? `loads v${base.loaded}, installed v${staleTarget}; v${available} is available once the load is repaired`
545
+ : `loads v${base.loaded}, installed v${staleTarget}`,
546
+ };
547
+ }
548
+
378
549
  // An exact pin is planned against what can ACTUALLY be delivered.
379
550
  //
380
551
  // This is dependency management, not a package manager: there is no
@@ -563,6 +734,12 @@ export interface PluginStateCounts {
563
734
  update: number;
564
735
  repair: number;
565
736
  current: number;
737
+ /**
738
+ * Its own count, never folded into `current`. The installed version being
739
+ * right is exactly what makes this state dangerous: counted as current, the
740
+ * header would report a clean bill of health for a session running old code.
741
+ */
742
+ "stale-load": number;
566
743
  unknown: number;
567
744
  }
568
745
 
@@ -575,6 +752,7 @@ export function countPluginStates(
575
752
  update: 0,
576
753
  repair: 0,
577
754
  current: 0,
755
+ "stale-load": 0,
578
756
  unknown: 0,
579
757
  };
580
758
  for (const item of items) counts[item.action]++;
@@ -762,6 +940,14 @@ export function planHasWork(plan: UpdatePlan): boolean {
762
940
  * binary had no version probe and "upgrade" meant "re-run the installer"; it
763
941
  * now means the owning installer says the tool is behind, which is drift.
764
942
  *
943
+ * A `stale-load` plugin DOES count. It was excluded while it was report-only, on
944
+ * the un-greenable-gate argument above: nothing magus ran could clear it, so a
945
+ * gate that failed on it would have failed forever. `repairStaleLoad` clears it
946
+ * now, and `magus update` applies that, so the gate closes — and leaving it out
947
+ * would mean `--check` printing "nothing behind" over a plugin whose session
948
+ * loads a version nobody installed, which is the deception the state exists to
949
+ * end.
950
+ *
765
951
  * NEITHER an `unknown` plugin NOR an `unknown` tool counts. "We could not check
766
952
  * this" is not evidence of drift, and most unknowns are structural and
767
953
  * permanent rather than transient: a plugin whose marketplace publishes no
@@ -784,7 +970,8 @@ export function planIsBehind(plan: UpdatePlan): boolean {
784
970
  (p) =>
785
971
  p.action === "install" ||
786
972
  p.action === "update" ||
787
- p.action === "repair",
973
+ p.action === "repair" ||
974
+ p.action === "stale-load",
788
975
  ) ||
789
976
  plan.bins.some((b) => b.action === "install" || b.action === "upgrade") ||
790
977
  plan.skills.some((s) => s.action === "install")
@@ -1,5 +1,6 @@
1
1
  import semver from "semver";
2
2
  import pkg from "../../package.json";
3
+ import { MAGUS_NPM_PACKAGE } from "../data/npm-package.js";
3
4
 
4
5
  // Cache the result for the session
5
6
  let cachedResult: VersionCheckResult | null = null;
@@ -29,15 +30,20 @@ async function fetchLatestVersion(): Promise<string | null> {
29
30
  const controller = new AbortController();
30
31
  const timeout = setTimeout(() => controller.abort(), 3000); // 3s timeout
31
32
 
32
- const response = await fetch("https://registry.npmjs.org/magus/latest", {
33
- signal: controller.signal,
34
- headers: {
35
- Accept: "application/json",
36
- // Always revalidate — a CDN/HTTP-cached response serves a stale
37
- // "latest" right after a release (the 4.25-vs-4.26 flicker).
38
- "Cache-Control": "no-cache",
33
+ // The npm package, not the command: bare `magus` on npm is an unrelated
34
+ // package whose latest is 0.0.2, which hid every release from this check.
35
+ const response = await fetch(
36
+ `https://registry.npmjs.org/${MAGUS_NPM_PACKAGE}/latest`,
37
+ {
38
+ signal: controller.signal,
39
+ headers: {
40
+ Accept: "application/json",
41
+ // Always revalidate — a CDN/HTTP-cached response serves a stale
42
+ // "latest" right after a release (the 4.25-vs-4.26 flicker).
43
+ "Cache-Control": "no-cache",
44
+ },
39
45
  },
40
- });
46
+ );
41
47
 
42
48
  clearTimeout(timeout);
43
49
 
@@ -117,11 +123,15 @@ export async function checkForUpdates(
117
123
  }
118
124
 
119
125
  /**
120
- * Format update message for display
126
+ * Format update message for display.
127
+ *
128
+ * Points at `magus upgrade`, which picks bun or npm from the active binary. A
129
+ * hard-coded `npm i -g …` would give every bun user a second, npm-owned copy —
130
+ * the duplicate state `magus upgrade` itself warns about.
121
131
  */
122
132
  export function formatUpdateMessage(result: VersionCheckResult): string {
123
133
  if (!result.updateAvailable) return "";
124
134
 
125
135
  const typeLabel = result.updateType ? ` (${result.updateType})` : "";
126
- return `Update available: v${result.currentVersion} → v${result.latestVersion}${typeLabel}. Run: npm i -g magus-cli`;
136
+ return `Update available: v${result.currentVersion} → v${result.latestVersion}${typeLabel}. Run: magus upgrade`;
127
137
  }
package/src/ui/App.tsx CHANGED
@@ -1,10 +1,13 @@
1
1
  import fs from "node:fs";
2
2
  import { useRenderer } from "@opentui/react";
3
3
  import React, { useEffect, useState } from "react";
4
+ import { activePluginPins } from "../services/active-closure.js";
4
5
  import { isClaudeAvailable } from "../services/claude-cli.js";
5
6
  import {
7
+ findStaleLoads,
6
8
  migrateMarketplaceRename,
7
9
  recoverMarketplaceSettings,
10
+ repairAllStaleLoads,
8
11
  } from "../services/claude-settings.js";
9
12
  import { checkGitignore } from "../services/gitignore-check.js";
10
13
  import {
@@ -28,6 +31,10 @@ import { ModalContainer } from "./components/modals/index.js";
28
31
  import { useGitignoreModal } from "./hooks/useGitignoreModal.js";
29
32
  import { useKeyboardHandler } from "./hooks/useKeyboardHandler.js";
30
33
  import { useProfileStartupModal } from "./hooks/useProfileStartupModal.js";
34
+ import {
35
+ staleLoadRepairSummary,
36
+ useStaleLoadModal,
37
+ } from "./hooks/useStaleLoadModal.js";
31
38
  import {
32
39
  AliasScreen,
33
40
  CliToolsScreen,
@@ -391,6 +398,10 @@ function AppContent({ onExit }: AppContentProps) {
391
398
  null,
392
399
  );
393
400
  const gitignoreModal = useGitignoreModal();
401
+ const [brokenPluginCount, setBrokenPluginCount] = useState<number | null>(
402
+ null,
403
+ );
404
+ const staleLoadModal = useStaleLoadModal();
394
405
  // The profile modal cannot be dismissed, so it needs the app's own exit: its
395
406
  // only non-action is to leave.
396
407
  const profileModal = useProfileStartupModal(onExit);
@@ -436,6 +447,58 @@ function AppContent({ onExit }: AppContentProps) {
436
447
  setGitignoreData(null);
437
448
  }, [gitignoreData, gitignoreModal, state.modal, state.projectPath]);
438
449
 
450
+ // Show the broken-plugin modal — same "wait for the modal slot" rule as the
451
+ // gitignore one above, so two startup findings queue instead of one stomping
452
+ // the other.
453
+ useEffect(() => {
454
+ if (brokenPluginCount === null) return;
455
+ if (state.modal) return;
456
+ staleLoadModal.show({
457
+ brokenCount: brokenPluginCount,
458
+ // Re-surveys before repairing, like the gitignore fixer: the registry may
459
+ // have moved between the startup scan and the user pressing Enter, and
460
+ // repairing from the stale count would act on rows that are no longer
461
+ // there. `repairAllStaleLoads` is the same `repairStaleLoad` the Plugins
462
+ // screen's `a` key drives through the apply engine.
463
+ //
464
+ // The pins are re-read with it, from the same helper the survey used: a
465
+ // repair that capped at a different ceiling than the offer was counted
466
+ // against would move rows the dialog never mentioned.
467
+ onFix: async () =>
468
+ staleLoadRepairSummary(
469
+ await repairAllStaleLoads(
470
+ state.projectPath,
471
+ undefined,
472
+ await activePluginPins(state.projectPath),
473
+ ),
474
+ ),
475
+ });
476
+ setBrokenPluginCount(null);
477
+ }, [brokenPluginCount, staleLoadModal, state.modal, state.projectPath]);
478
+
479
+ // Does this project load an older plugin than it installed? Detection only;
480
+ // nothing is written until the user chooses Fix.
481
+ //
482
+ // Its own effect for the same reason the profile check below has one: the
483
+ // marketplace block returns early on `--no-refresh`, which is about network
484
+ // calls, and this question is answered from the local registry alone. Sharing
485
+ // that effect let the flag silently disable the check — observed, while this
486
+ // was still inside it.
487
+ useEffect(() => {
488
+ (async () => {
489
+ try {
490
+ const broken = await findStaleLoads(
491
+ process.cwd(),
492
+ undefined,
493
+ await activePluginPins(process.cwd()),
494
+ );
495
+ if (broken.length > 0) setBrokenPluginCount(broken.length);
496
+ } catch {
497
+ // non-fatal: the Plugins tab still shows the same rows
498
+ }
499
+ })();
500
+ }, []);
501
+
439
502
  // Show the profile modal on the same terms as the gitignore one: only once
440
503
  // the modal slot is free, so two startup checks that both found something do
441
504
  // not stomp on each other. Gitignore is asked first simply because its
@@ -0,0 +1,157 @@
1
+ import { useCallback } from "react";
2
+ import { useApp, useNavigation } from "../state/AppContext.js";
3
+ import type { ModalState } from "../state/types.js";
4
+
5
+ /**
6
+ * Startup broken-plugin modal: magus launches, finds that Claude Code will load
7
+ * an older version of a plugin than the one installed here, and offers to fix it.
8
+ *
9
+ * Same shape as the gitignore modal beside it — a startup repair magus can make
10
+ * without being asked to, surfaced as a choice rather than done silently. The
11
+ * difference is the DEFAULT: fixing is the first option and `defaultIndex` says
12
+ * so explicitly, because a broken plugin means every session in this directory
13
+ * runs code nobody installed, and there is no reason to prefer leaving it.
14
+ *
15
+ * The copy names the CAUSE, not magus's internal state. An earlier version read
16
+ * "Claude Code loads an older version here than the one installed", which is a
17
+ * report on two stores disagreeing and leaves the reader with nothing to do. It
18
+ * is a defect in Claude Code's plugin loader, the user did nothing to cause it,
19
+ * and the sentence they need is what is wrong and how far it reaches.
20
+ *
21
+ * Keep it plain and keep it accurate. A draft said "your sessions run code you
22
+ * did not install", which is both overstated and wrong: nothing happens to the
23
+ * user's code, an old cached plugin version is used instead of the current one.
24
+ * Say that, in those words.
25
+ *
26
+ * The reach is not this directory. Relevance is decided by repository root, so
27
+ * every worktree of this repo — and the main checkout — resolve identically and
28
+ * are affected together. Saying "in this directory" understated it.
29
+ *
30
+ * NO UPSTREAM LINK YET, deliberately. anthropics/claude-code#45997 is the
31
+ * nearest issue and is CLOSED, and its title states the `[0]`-index diagnosis,
32
+ * which measurement disproved: the loader filters by repository root first and
33
+ * takes the first RELEVANT row. Linking a closed issue with a wrong cause sends
34
+ * the reader somewhere that contradicts this dialog. Add a link here when an
35
+ * accurate issue exists.
36
+ *
37
+ * Pure builder is exported separately for tests.
38
+ */
39
+
40
+ export interface BuildStaleLoadModalArgs {
41
+ brokenCount: number;
42
+ onFix: () => void;
43
+ onOpenTab: () => void;
44
+ onDismiss: () => void;
45
+ }
46
+
47
+ export function buildStaleLoadModal(args: BuildStaleLoadModalArgs): ModalState {
48
+ const { brokenCount, onFix, onOpenTab, onDismiss } = args;
49
+ const plural = brokenCount === 1 ? "plugin" : "plugins";
50
+ return {
51
+ type: "select",
52
+ title: "Claude Code is loading old plugin versions",
53
+ message: `It picks up a stale cached version — the one recorded by another directory in this repo, including worktrees you deleted. ${brokenCount} ${plural} ${brokenCount === 1 ? "is" : "are"} on an older version than you installed, in every worktree and the main checkout.\n\nFix updates this repo's install records to the versions you installed. installed_plugins.json is backed up first. Nothing is deleted or downgraded.`,
54
+ options: [
55
+ { label: `Fix it (${brokenCount} ${plural})`, value: "fix" },
56
+ { label: "Open the Plugins tab to review", value: "open-tab" },
57
+ { label: "Not now", value: "dismiss" },
58
+ ],
59
+ // FIX, stated rather than inherited from being first — a reordering must not
60
+ // silently make "leave it broken" the thing Enter does.
61
+ defaultIndex: 0,
62
+ onSelect: (value: string) => {
63
+ if (value === "fix") onFix();
64
+ else if (value === "open-tab") onOpenTab();
65
+ else onDismiss();
66
+ },
67
+ onCancel: onDismiss,
68
+ };
69
+ }
70
+
71
+ export function useStaleLoadModal() {
72
+ const { dispatch } = useApp();
73
+ const { navigateToScreen } = useNavigation();
74
+
75
+ const show = useCallback(
76
+ (args: {
77
+ brokenCount: number;
78
+ /** Runs the repair. Resolves to what to tell the user afterwards. */
79
+ onFix: () => Promise<string> | string;
80
+ }) => {
81
+ if (args.brokenCount === 0) return;
82
+ const dismiss = () => dispatch({ type: "HIDE_MODAL" });
83
+ const modal = buildStaleLoadModal({
84
+ brokenCount: args.brokenCount,
85
+ onFix: () => {
86
+ dispatch({
87
+ type: "SHOW_MODAL",
88
+ modal: { type: "loading", message: "Repairing plugins…" },
89
+ });
90
+ // The outcome is REPORTED, never assumed. `repairStaleLoad` re-asks
91
+ // the loader after writing and can legitimately answer "still not
92
+ // loading the target"; swallowing that would leave the user believing
93
+ // a broken plugin was fixed.
94
+ void Promise.resolve(args.onFix())
95
+ .then((summary) => {
96
+ dispatch({
97
+ type: "SHOW_MODAL",
98
+ modal: {
99
+ type: "message",
100
+ title: "Plugin repair",
101
+ message: summary,
102
+ variant: "info",
103
+ onDismiss: dismiss,
104
+ },
105
+ });
106
+ })
107
+ .catch((err) => {
108
+ dispatch({
109
+ type: "SHOW_MODAL",
110
+ modal: {
111
+ type: "message",
112
+ title: "Plugin repair failed",
113
+ message: err instanceof Error ? err.message : String(err),
114
+ variant: "error",
115
+ onDismiss: dismiss,
116
+ },
117
+ });
118
+ });
119
+ },
120
+ onOpenTab: () => {
121
+ dispatch({ type: "HIDE_MODAL" });
122
+ navigateToScreen("plugins");
123
+ },
124
+ onDismiss: dismiss,
125
+ });
126
+ dispatch({ type: "SHOW_MODAL", modal });
127
+ },
128
+ [dispatch, navigateToScreen],
129
+ );
130
+
131
+ return { show };
132
+ }
133
+
134
+ /**
135
+ * One line per repair attempt, for the modal that follows the fix.
136
+ *
137
+ * Exported and pure so the wording is tested rather than inspected by eye. Each
138
+ * line says what the loader resolves NOW, because that — not the row count — is
139
+ * the thing the user came here to change.
140
+ */
141
+ export function staleLoadRepairSummary(
142
+ results: ReadonlyArray<{
143
+ pluginId: string;
144
+ target: string;
145
+ ok: boolean;
146
+ error?: string;
147
+ }>,
148
+ ): string {
149
+ if (results.length === 0) return "Nothing to repair.";
150
+ return results
151
+ .map((r) =>
152
+ r.ok
153
+ ? `✓ ${r.pluginId} now loads v${r.target}`
154
+ : `✗ ${r.pluginId} — ${r.error ?? "repair did not take effect"}`,
155
+ )
156
+ .join("\n");
157
+ }
@@ -278,6 +278,9 @@ const ACTION_LABEL: Record<PluginUpdateAction, string> = {
278
278
  update: "UPDATE AVAILABLE",
279
279
  repair: "NEEDS REINSTALL",
280
280
  current: "UP TO DATE",
281
+ // The DETAIL panel's label, not the row's — this is where the mechanism is
282
+ // allowed to be named, and the note beside it spells out both versions.
283
+ "stale-load": "BROKEN — OLDER VERSION LOADS",
281
284
  unknown: "UNKNOWN",
282
285
  };
283
286
 
@@ -463,6 +466,32 @@ function pluginDetail(
463
466
  </text>
464
467
  </box>
465
468
  ) : null}
469
+ {/*
470
+ * THE panel where the mechanism is spelled out in full. The row above
471
+ * says only "BROKEN — press a to fix", on purpose; a reader who wants to
472
+ * know why comes here, so this names both versions, says which registry
473
+ * row wins, and says what the fix does. A reinstall is measured NOT to
474
+ * fix it — it writes another row for this project while the winner
475
+ * belongs to a different one — so the fix rewrites those rows instead.
476
+ */}
477
+ {status?.action === "stale-load" ? (
478
+ <box flexDirection="column">
479
+ <text fg={theme.colors.danger}>
480
+ ✗ Broken: Claude Code loads v{status.loaded ?? "?"} in this
481
+ directory, not the v{status.installed ?? "?"} that is installed.
482
+ </text>
483
+ <text fg={theme.colors.muted}>
484
+ An install row belonging to another directory in this repository —
485
+ often a worktree that no longer exists — is resolved first. `magus
486
+ doctor` names which one.
487
+ </text>
488
+ <text fg={theme.colors.muted}>
489
+ Press a to fix: magus moves those rows forward to v
490
+ {status.target ?? status.installed ?? "?"}, backing up
491
+ installed_plugins.json first. Nothing is deleted or downgraded.
492
+ </text>
493
+ </box>
494
+ ) : null}
466
495
 
467
496
  {/* Description */}
468
497
  <box marginTop={1} marginBottom={1}>
@@ -41,6 +41,20 @@ export function pluginStatusText(
41
41
  };
42
42
  case "repair":
43
43
  return { suffix: ` stale — reinstall${withNote}`, tone: "warning" };
44
+ case "stale-load":
45
+ // NO VERSIONS, and the note is deliberately not shown. This row used to
46
+ // print the classifier's note verbatim — "≠ loads v0.3.0, installed
47
+ // v0.5.0" — which is internal vocabulary: it tells a reader neither what
48
+ // is wrong nor what to do, because "loads" versus "installed" is a
49
+ // distinction only this codebase draws. The row's job is the verdict and
50
+ // the key; the mechanism, including which project's registry row wins,
51
+ // belongs in the detail panel, which is where someone curious looks.
52
+ //
53
+ // Red, not the amber it carried while this was report-only. Amber is
54
+ // this file's tone for "cannot tell" and for routine work; a plugin
55
+ // whose session runs a version nobody installed is broken, and there is
56
+ // now a key that fixes it.
57
+ return { suffix: " BROKEN — press a to fix", tone: "danger" };
44
58
  case "install":
45
59
  // `absent` only ever comes from a profile pin (see `pinForRow`), so it
46
60
  // may say so. An undeclared row that is merely enabled must not.