@golba98/codexa 1.0.5 → 1.0.6

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.
package/README.md CHANGED
@@ -577,7 +577,7 @@ npm install -g @golba98/codexa@latest
577
577
 
578
578
  ### Update notice does not appear
579
579
 
580
- Codexa checks for updates on startup and caches the result for 6 hours (stored in `~/.codexa-update-check.json`). The update notice only appears when the npm registry `latest` version is newer than the running version.
580
+ Codexa checks npm for updates on every interactive startup. The local cache at `~/.codexa-update-check.json` is only used as a best-effort fallback if npm is temporarily unavailable. The update notice appears when the npm registry `latest` version is newer than the running version.
581
581
 
582
582
  Published npm versions are immutable. Versions before the fixed update checker may not show update notices even when a newer package exists. If in doubt, update directly:
583
583
 
@@ -588,9 +588,8 @@ npm install -g @golba98/codexa@latest
588
588
  If you expect a notice but don't see one:
589
589
 
590
590
  1. Check the current registry state: `npm view @golba98/codexa dist-tags --json`
591
- 2. Clear the cache to force a fresh check: delete `~/.codexa-update-check.json`, then restart Codexa
592
- 3. Force an explicit check in-app: `/update check`
593
- 4. Update checks are disabled for local dev builds (`codexa-dev` / `cxd`)
591
+ 2. Force an explicit check in-app: `/update check`
592
+ 3. Update checks are disabled for local dev builds (`codexa-dev` / `cxd`)
594
593
 
595
594
  | Symptom | Fix |
596
595
  |---------|-----|
@@ -606,11 +605,8 @@ If you expect a notice but don't see one:
606
605
 
607
606
  See [CHANGELOG.md](CHANGELOG.md) for the full release history.
608
607
 
609
- **Current release: v1.0.4**
608
+ **Current release: v1.0.6**
610
609
 
611
- v1.0.4 is an update-notice reliability fix:
612
- - Older installed versions can detect when npm `latest` is newer
613
- - `/update check` bypasses stale cache and reports a useful status
614
- - Failed registry lookups do not get cached as “up to date”
610
+ v1.0.6 checks npm on every interactive startup and shows an update prompt as soon as Codexa is idle. The prompt offers the correct update command for the package manager that installed Codexa.
615
611
 
616
- > Versions before the fixed update checker may not show update notices. Update directly with `npm install -g @golba98/codexa@latest`.
612
+ > Versions before v1.0.6 can wait for a cached result before discovering a new npm release. Update directly with `npm install -g @golba98/codexa@latest` if needed.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@golba98/codexa",
3
- "version": "1.0.5",
3
+ "version": "1.0.6",
4
4
  "description": "Terminal UI for Codexa — TypeScript + Bun + Ink",
5
5
  "type": "module",
6
6
  "bin": {
package/src/app.tsx CHANGED
@@ -270,9 +270,10 @@ import { AppShell } from "./ui/chrome/AppShell.js";
270
270
  import { TranscriptShell } from "./ui/timeline/TranscriptShell.js";
271
271
  import type { RuntimeAvailability } from "./ui/chrome/RuntimeStatusBar.js";
272
272
  import { checkForUpdates, formatLocalDevUpdateStatus, formatUpdateInstructions, shouldRunStartupUpdateCheck, type UpdateCheckResult } from "./core/version/updateCheck.js";
273
+ import { detectGlobalPackageManager, getUpdateCommand } from "./core/version/packageManager.js";
273
274
  import { isLocalDevChannel } from "./core/version/channel.js";
274
275
  import {
275
- isCacheValid,
276
+ isCacheForRunningVersion,
276
277
  loadUpdateCheckCache,
277
278
  saveUpdateCheckCache,
278
279
  } from "./config/updateCheckCache.js";
@@ -394,9 +395,6 @@ export function App({ launchArgs }: AppProps) {
394
395
  ? projectInstructionsLoad.instructions
395
396
  : null;
396
397
  const initialSettings = useRef(loadSettings());
397
- const skippedUpdateVersionRef = useRef<string | null>(
398
- initialSettings.current.updateCheck.skippedUpdateVersion ?? null,
399
- );
400
398
  const initialProviderWorkspaceConfig = useRef<ProviderWorkspaceConfig>(loadProviderWorkspaceConfig(workspaceRoot));
401
399
  const initialLayeredConfig = useRef<LayeredConfigResult | null>(null);
402
400
  if (initialLayeredConfig.current === null) {
@@ -550,6 +548,9 @@ export function App({ launchArgs }: AppProps) {
550
548
  const [planFlow, setPlanFlow] = useState<PlanFlowState>(createInitialPlanFlowState);
551
549
  const [initialRevisionText, setInitialRevisionText] = useState("");
552
550
  const [updateCheckResult, setUpdateCheckResult] = useState<UpdateCheckResult | null>(null);
551
+ const [startupUpdateDismissed, setStartupUpdateDismissed] = useState(false);
552
+ // Launcher path is fixed for the process lifetime, so detect once.
553
+ const globalPackageManager = useMemo(() => detectGlobalPackageManager(), []);
553
554
  // Transcript mode leaves mouse reporting off so wheel/trackpad input scrolls
554
555
  // the terminal emulator's native scrollback instead of an in-app viewport.
555
556
  const mouseCapture = (mouseOverride ?? (terminalMouseMode === "wheel")) && !isMouseIdle;
@@ -1625,54 +1626,53 @@ export function App({ launchArgs }: AppProps) {
1625
1626
  // eslint-disable-next-line react-hooks/exhaustive-deps
1626
1627
  }, [workspaceRoot]);
1627
1628
 
1628
- // Non-blocking background update check — runs once at startup.
1629
+ // Non-blocking background update check — fetches npm on every interactive startup.
1629
1630
  useEffect(() => {
1630
1631
  const ucSettings = initialSettings.current.updateCheck ?? DEFAULT_UPDATE_CHECK_SETTINGS;
1631
1632
  if (!shouldRunStartupUpdateCheck(process.env, ucSettings.enabled)) return;
1632
1633
 
1633
- const timer = setTimeout(() => {
1634
- void (async () => {
1635
- try {
1636
- const cache = loadUpdateCheckCache();
1637
- if (cache && isCacheValid(cache, ucSettings.intervalHours, APP_VERSION)) {
1638
- if (cache.updateAvailable && cache.latestVersion) {
1639
- setUpdateCheckResult({
1640
- status: "update-available",
1641
- currentVersion: cache.currentVersion,
1642
- latestVersion: cache.latestVersion,
1643
- checkedAt: cache.lastChecked,
1644
- });
1645
- if (cache.latestVersion !== skippedUpdateVersionRef.current) {
1646
- setScreen("update-prompt");
1647
- }
1648
- }
1649
- return;
1650
- }
1651
- const result = await checkForUpdates({ enabled: true });
1652
- if (result.status !== "error") {
1653
- setUpdateCheckResult(result);
1654
- saveUpdateCheckCache({
1655
- lastChecked: result.checkedAt,
1656
- currentVersion: result.currentVersion,
1657
- latestVersion: result.latestVersion,
1658
- updateAvailable: result.status === "update-available",
1659
- });
1660
- if (result.status === "update-available" && result.latestVersion) {
1661
- if (result.latestVersion !== skippedUpdateVersionRef.current) {
1662
- setScreen("update-prompt");
1663
- }
1664
- }
1665
- }
1666
- } catch {
1667
- // Never crash the TUI on a failed update check.
1668
- }
1669
- })();
1670
- }, 2000);
1671
-
1672
- return () => clearTimeout(timer);
1634
+ void (async () => {
1635
+ const cache = loadUpdateCheckCache();
1636
+ try {
1637
+ const result = await checkForUpdates({ enabled: true });
1638
+ if (result.status === "error") {
1639
+ // A previously confirmed update is still useful when npm is briefly
1640
+ // unreachable, but it must never suppress the next fresh startup check.
1641
+ if (cache?.updateAvailable && cache.latestVersion && isCacheForRunningVersion(cache, APP_VERSION)) {
1642
+ setUpdateCheckResult({
1643
+ status: "update-available",
1644
+ currentVersion: cache.currentVersion,
1645
+ latestVersion: cache.latestVersion,
1646
+ checkedAt: cache.lastChecked,
1647
+ source: "cache",
1648
+ });
1649
+ }
1650
+ return;
1651
+ }
1652
+
1653
+ setUpdateCheckResult(result);
1654
+ saveUpdateCheckCache({
1655
+ lastChecked: result.checkedAt,
1656
+ currentVersion: result.currentVersion,
1657
+ latestVersion: result.latestVersion,
1658
+ updateAvailable: result.status === "update-available",
1659
+ });
1660
+ } catch {
1661
+ // Never crash the TUI on a failed update check.
1662
+ }
1663
+ })();
1673
1664
  // eslint-disable-next-line react-hooks/exhaustive-deps
1674
1665
  }, []);
1675
1666
 
1667
+ // A startup update prompt must not interrupt an active run or another panel.
1668
+ // Keep the result until the user returns to the idle main screen instead.
1669
+ useEffect(() => {
1670
+ if (startupUpdateDismissed || busy || screen !== "main") return;
1671
+ if (updateCheckResult?.status === "update-available" && updateCheckResult.latestVersion) {
1672
+ setScreen("update-prompt");
1673
+ }
1674
+ }, [busy, screen, startupUpdateDismissed, updateCheckResult]);
1675
+
1676
1676
  // Track clear epoch to suppress stale command result events
1677
1677
  useEffect(() => {
1678
1678
  clearEpochRef.current = sessionState.clearEpoch;
@@ -2160,32 +2160,10 @@ export function App({ launchArgs }: AppProps) {
2160
2160
  }, [applyWorkspaceDisplayMode, showBusyLoader, terminalMouseMode, terminalTitleMode, workspaceDisplayMode]);
2161
2161
 
2162
2162
  const handleSkipUpdateForSession = useCallback(() => {
2163
+ setStartupUpdateDismissed(true);
2163
2164
  setScreen("main");
2164
2165
  }, []);
2165
2166
 
2166
- const handleSkipUpdateVersion = useCallback((version: string) => {
2167
- initialSettings.current.updateCheck = {
2168
- ...initialSettings.current.updateCheck,
2169
- skippedUpdateVersion: version,
2170
- };
2171
- saveSettings({
2172
- ui: {
2173
- layoutStyle: initialSettings.current.ui.layoutStyle,
2174
- theme: themeSelection.committedTheme,
2175
- workspaceDisplayMode,
2176
- terminalTitleMode,
2177
- showBusyLoader,
2178
- terminalMouseMode,
2179
- customTheme,
2180
- },
2181
- auth: { preference: authPreference },
2182
- header: headerConfig,
2183
- updateCheck: initialSettings.current.updateCheck,
2184
- });
2185
- skippedUpdateVersionRef.current = version;
2186
- setScreen("main");
2187
- }, [authPreference, customTheme, headerConfig, showBusyLoader, terminalMouseMode, terminalTitleMode, themeSelection.committedTheme, workspaceDisplayMode]);
2188
-
2189
2167
  const setApprovalPolicyWithNotice = useCallback((nextValue: RuntimeApprovalPolicy) => {
2190
2168
  const gate = guardConfigMutation("mode", busy);
2191
2169
  if (!gate.allowed) {
@@ -4435,7 +4413,10 @@ export function App({ launchArgs }: AppProps) {
4435
4413
  if (freshResult?.status === "update-available" && freshResult.latestVersion) {
4436
4414
  setScreen("update-prompt");
4437
4415
  } else {
4438
- appendSystemEvent("Update", formatUpdateInstructions(freshResult));
4416
+ appendSystemEvent(
4417
+ "Update",
4418
+ formatUpdateInstructions(freshResult, getUpdateCommand(globalPackageManager).displayCommand),
4419
+ );
4439
4420
  }
4440
4421
  })();
4441
4422
  return;
@@ -4527,6 +4508,7 @@ export function App({ launchArgs }: AppProps) {
4527
4508
  dispatchSession,
4528
4509
  findUserPromptForTurn,
4529
4510
  focusManager,
4511
+ globalPackageManager,
4530
4512
  handleCopy,
4531
4513
  handleClear,
4532
4514
  handleQuit,
@@ -4751,8 +4733,12 @@ export function App({ launchArgs }: AppProps) {
4751
4733
  clearCount={sessionState.clearCount}
4752
4734
  headerConfig={effectiveHeaderConfig}
4753
4735
  updateAvailable={
4754
- updateCheckResult?.status === "update-available" && updateCheckResult.latestVersion
4755
- ? { latestVersion: updateCheckResult.latestVersion, currentVersion: updateCheckResult.currentVersion }
4736
+ screen !== "update-prompt" && updateCheckResult?.status === "update-available" && updateCheckResult.latestVersion
4737
+ ? {
4738
+ latestVersion: updateCheckResult.latestVersion,
4739
+ currentVersion: updateCheckResult.currentVersion,
4740
+ updateCommand: getUpdateCommand(globalPackageManager).displayCommand,
4741
+ }
4756
4742
  : null
4757
4743
  }
4758
4744
  panel={
@@ -5010,9 +4996,9 @@ export function App({ launchArgs }: AppProps) {
5010
4996
  focusId={FOCUS_IDS.updatePrompt}
5011
4997
  currentVersion={updateCheckResult.currentVersion}
5012
4998
  latestVersion={updateCheckResult.latestVersion}
5013
- onSkip={handleSkipUpdateForSession}
5014
- onSkipUntilNextVersion={handleSkipUpdateVersion}
5015
- />
4999
+ packageManager={globalPackageManager}
5000
+ onSkip={handleSkipUpdateForSession}
5001
+ />
5016
5002
  )}
5017
5003
  </>
5018
5004
  }
@@ -391,12 +391,12 @@ function buildHelpMessage(context: CommandContext): string {
391
391
  ` Current reasoning: ${formatReasoningLabel(context.runtime.reasoningLevel)}`,
392
392
  ` Current plan mode: ${context.runtime.planMode ? "Enabled" : "Disabled"}`,
393
393
  " /copy Copy last response to clipboard",
394
- " /update [check] Show update status and instructions for updating Codexa",
394
+ " /update [status] Check for updates and install the latest Codexa (status: cached result only)",
395
395
  " /help Show this help",
396
- "",
397
- "Local development:",
398
- " npm run install:dev-bin Install the codexa-dev command",
399
- " codexa-dev Run this repo without replacing codexa",
396
+ "",
397
+ "Local development:",
398
+ " npm run install:dev-bin Install the codexa-dev command",
399
+ " codexa-dev Run this repo without replacing codexa",
400
400
  "",
401
401
  "Shortcuts:",
402
402
  " Ctrl+B Open backend picker",
@@ -515,11 +515,11 @@ export function handleCommand(text: string, context: CommandContext): CommandRes
515
515
  message: `Unknown reasoning level for ${context.runtime.model}: ${arg}. Valid: ${valid}`,
516
516
  };
517
517
  }
518
- return {
519
- action: "reasoning",
520
- value: normalized,
521
- message: `Reasoning level switched to ${formatReasoningLabel(normalized)} (runtime metadata unavailable; unverified until runtime)`,
522
- };
518
+ return {
519
+ action: "reasoning",
520
+ value: normalized,
521
+ message: `Reasoning level switched to ${formatReasoningLabel(normalized)} (runtime metadata unavailable; unverified until runtime)`,
522
+ };
523
523
  }
524
524
 
525
525
  case "plan": {
@@ -846,7 +846,9 @@ export function handleCommand(text: string, context: CommandContext): CommandRes
846
846
  return { action: "help", message: buildHelpMessage(context) };
847
847
 
848
848
  case "update":
849
- return { action: "update", value: normalizedArg || "status" };
849
+ // Bare /update forces a fresh registry check; /update status is the
850
+ // explicit no-network path that reuses the cached/in-memory result.
851
+ return { action: "update", value: normalizedArg || "check" };
850
852
 
851
853
  default:
852
854
  return {
@@ -0,0 +1,65 @@
1
+ import { readFileSync } from "fs";
2
+ import { dirname, join } from "path";
3
+ import { fileURLToPath } from "url";
4
+ import { APP_VERSION as BUILD_INFO_VERSION } from "./buildInfo.js";
5
+
6
+ // Leaf module: must not import settings.ts or anything under src/core/version
7
+ // (settings.ts re-exports APP_VERSION from here, so that would be a cycle).
8
+
9
+ const CODEXA_PACKAGE_NAME = "@golba98/codexa";
10
+ const SEMVER_RE = /^\d+\.\d+\.\d+(-[\w.]+)?$/;
11
+
12
+ function readPackageVersion(packageJsonPath: string, requireName?: string): string | null {
13
+ try {
14
+ const parsed = JSON.parse(readFileSync(packageJsonPath, "utf8")) as {
15
+ name?: unknown;
16
+ version?: unknown;
17
+ };
18
+ if (requireName !== undefined && parsed.name !== requireName) return null;
19
+ if (typeof parsed.version !== "string") return null;
20
+ const version = parsed.version.trim();
21
+ return SEMVER_RE.test(version) ? version : null;
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Resolves the version of the running Codexa install:
29
+ * 1. `CODEXA_PACKAGE_ROOT` (set by bin/codexa.js) → that package.json's version
30
+ * 2. Walk up from this module for a package.json named "@golba98/codexa" (local dev)
31
+ * 3. The committed buildInfo.ts APP_VERSION as the last resort
32
+ *
33
+ * `startDir` overrides the walk-up starting directory (test seam).
34
+ */
35
+ export function resolveAppVersion(
36
+ env: NodeJS.ProcessEnv = process.env,
37
+ startDir?: string,
38
+ ): string {
39
+ const packageRoot = env.CODEXA_PACKAGE_ROOT?.trim();
40
+ if (packageRoot) {
41
+ const version = readPackageVersion(join(packageRoot, "package.json"));
42
+ if (version) return version;
43
+ }
44
+
45
+ let dir = startDir ?? dirname(fileURLToPath(import.meta.url));
46
+ for (;;) {
47
+ const version = readPackageVersion(join(dir, "package.json"), CODEXA_PACKAGE_NAME);
48
+ if (version) return version;
49
+ const parent = dirname(dir);
50
+ if (parent === dir) break;
51
+ dir = parent;
52
+ }
53
+
54
+ return BUILD_INFO_VERSION;
55
+ }
56
+
57
+ let cachedVersion: string | null = null;
58
+
59
+ /** Cached form of resolveAppVersion() — the installed version cannot change mid-process. */
60
+ export function getAppVersion(): string {
61
+ if (cachedVersion === null) {
62
+ cachedVersion = resolveAppVersion();
63
+ }
64
+ return cachedVersion;
65
+ }
@@ -1,3 +1,3 @@
1
1
  // Generated by scripts/gen-build-info.mjs — do not edit manually.
2
- export const BUILD_COMMIT = "aaffb655176a0acb1bc7800ddbb3e6402e5933f4" as const;
3
- export const APP_VERSION = "1.0.5" as const;
2
+ export const BUILD_COMMIT = "8a275f0a19434d0cf59ff59cebe1aae734559bde" as const;
3
+ export const APP_VERSION = "1.0.6" as const;
@@ -9,7 +9,11 @@ function smartJoin(base: string, ...parts: string[]): string {
9
9
  return isWindowsStylePath(base) ? win32.join(base, ...parts) : join(base, ...parts);
10
10
  }
11
11
 
12
- export { APP_VERSION } from "./buildInfo.js";
12
+ import { getAppVersion } from "./appVersion.js";
13
+
14
+ // Authoritative runtime version: resolved from the installed package.json at
15
+ // startup (buildInfo.ts is only the committed fallback and can drift).
16
+ export const APP_VERSION: string = getAppVersion();
13
17
  export const APP_NAME = "Codexa";
14
18
  export const DEFAULT_BACKEND = "codex-subprocess";
15
19
  export const DEFAULT_MODEL = "gpt-5.4";
@@ -9,11 +9,16 @@ export interface UpdateCheckCache {
9
9
  updateAvailable: boolean;
10
10
  }
11
11
 
12
- const CACHE_FILE = join(homedir(), ".codexa-update-check.json");
12
+ // Resolved per call (not at module load) so HOME changes — e.g. test isolation —
13
+ // are honored. See the same pattern in src/core/models/providerModelCache.ts.
14
+ export function getUpdateCheckCacheFilePath(): string {
15
+ const home = process.env.USERPROFILE ?? process.env.HOME ?? homedir();
16
+ return join(home, ".codexa-update-check.json");
17
+ }
13
18
 
14
- export function loadUpdateCheckCache(): UpdateCheckCache | null {
19
+ export function loadUpdateCheckCache(filePath = getUpdateCheckCacheFilePath()): UpdateCheckCache | null {
15
20
  try {
16
- const text = readFileSync(CACHE_FILE, "utf-8");
21
+ const text = readFileSync(filePath, "utf-8");
17
22
  const data = JSON.parse(text) as Record<string, unknown>;
18
23
  if (typeof data.lastChecked !== "number") return null;
19
24
  if (typeof data.currentVersion !== "string") return null;
@@ -28,12 +33,12 @@ export function loadUpdateCheckCache(): UpdateCheckCache | null {
28
33
  }
29
34
  }
30
35
 
31
- export function saveUpdateCheckCache(cache: UpdateCheckCache): void {
36
+ export function saveUpdateCheckCache(cache: UpdateCheckCache, filePath = getUpdateCheckCacheFilePath()): void {
32
37
  try {
33
- mkdirSync(dirname(CACHE_FILE), { recursive: true });
34
- const tmp = `${CACHE_FILE}.tmp`;
38
+ mkdirSync(dirname(filePath), { recursive: true });
39
+ const tmp = `${filePath}.tmp`;
35
40
  writeFileSync(tmp, JSON.stringify(cache, null, 2), "utf-8");
36
- renameSync(tmp, CACHE_FILE);
41
+ renameSync(tmp, filePath);
37
42
  } catch {
38
43
  // Best-effort — never crash on cache write failure.
39
44
  }
@@ -43,6 +48,11 @@ function stripV(v: string): string {
43
48
  return v.startsWith("v") ? v.slice(1) : v;
44
49
  }
45
50
 
51
+ /** Returns true when a cache entry was created by the running Codexa version. */
52
+ export function isCacheForRunningVersion(cache: UpdateCheckCache, runningVersion: string): boolean {
53
+ return stripV(cache.currentVersion) === stripV(runningVersion);
54
+ }
55
+
46
56
  /**
47
57
  * Returns true only when the cache is still usable:
48
58
  * - `runningVersion` matches the version the cache was written for (version-mismatch = stale)
@@ -54,7 +64,7 @@ export function isCacheValid(
54
64
  runningVersion?: string,
55
65
  ): boolean {
56
66
  if (runningVersion !== undefined) {
57
- if (stripV(cache.currentVersion) !== stripV(runningVersion)) {
67
+ if (!isCacheForRunningVersion(cache, runningVersion)) {
58
68
  return false;
59
69
  }
60
70
  }
@@ -46,30 +46,18 @@ function readCacheFile(cacheFile: string): ProviderModelCacheFile | null {
46
46
  }
47
47
  }
48
48
 
49
- // A model id is passed straight to a provider CLI, so it must be a bare token.
50
- // Discovery scrapes CLI output, and a buggy build can bake an ANSI escape into
51
- // an id (e.g. "claude-fable-5\x1b[1m"). Reject rather than repair: a cached id
52
- // we cannot vouch for is dropped so discovery re-probes for a clean one.
53
- const VALID_MODEL_ID = /^[A-Za-z0-9._:\/-]+$/;
54
-
55
- function isUsableModel(model: unknown): model is ProviderModel {
56
- if (typeof model !== "object" || model === null) {
57
- return false;
58
- }
59
- const candidate = model as ProviderModel;
60
- return typeof candidate.id === "string"
61
- && typeof candidate.modelId === "string"
62
- && typeof candidate.label === "string"
63
- && VALID_MODEL_ID.test(candidate.id)
64
- && VALID_MODEL_ID.test(candidate.modelId);
65
- }
66
-
67
49
  function isValidEntry(entry: unknown): entry is CachedProviderModels {
68
50
  if (typeof entry !== "object" || entry === null) {
69
51
  return false;
70
52
  }
71
53
  const candidate = entry as CachedProviderModels;
72
- return typeof candidate.discoveredAt === "number" && Array.isArray(candidate.models);
54
+ return typeof candidate.discoveredAt === "number"
55
+ && Array.isArray(candidate.models)
56
+ && candidate.models.every((model) =>
57
+ typeof model === "object" && model !== null
58
+ && typeof model.id === "string"
59
+ && typeof model.modelId === "string"
60
+ && typeof model.label === "string");
73
61
  }
74
62
 
75
63
  export function loadCachedProviderModels(
@@ -78,14 +66,10 @@ export function loadCachedProviderModels(
78
66
  ): CachedProviderModels | null {
79
67
  const cache = readCacheFile(cacheFile);
80
68
  const entry = cache?.providers?.[providerId];
81
- if (!entry || !isValidEntry(entry)) {
82
- return null;
83
- }
84
- const models = entry.models.filter(isUsableModel);
85
- if (models.length === 0) {
69
+ if (!entry || !isValidEntry(entry) || entry.models.length === 0) {
86
70
  return null;
87
71
  }
88
- return { discoveredAt: entry.discoveredAt, models };
72
+ return entry;
89
73
  }
90
74
 
91
75
  export function saveCachedProviderModels(
@@ -165,7 +165,18 @@ export function getAgyModelSelector(
165
165
  }
166
166
 
167
167
  export function getAntigravityModelLabel(modelId: string): string {
168
- return getActiveAgyModels().find((m) => m.id === modelId)?.label ?? modelId;
168
+ const discovered = getActiveAgyModels().find((m) => m.id === modelId || m.modelId === modelId);
169
+ if (discovered) return discovered.label;
170
+
171
+ const normalizedId = migrateAntigravityLegacyModelId(modelId).modelId;
172
+ const knownLabels: Record<string, string> = {
173
+ "gemini-3.5-flash": "Gemini 3.5 Flash",
174
+ "gemini-3.1-pro": "Gemini 3.1 Pro",
175
+ "claude-sonnet-4.6-thinking": "Claude Sonnet 4.6 (Thinking)",
176
+ "claude-opus-4.6-thinking": "Claude Opus 4.6 (Thinking)",
177
+ "gpt-oss-120b-medium": "GPT-OSS 120B",
178
+ };
179
+ return knownLabels[normalizedId] ?? modelId;
169
180
  }
170
181
 
171
182
  // ---------------------------------------------------------------------------
@@ -199,17 +199,21 @@ export function resolveActiveProviderRoute(options: {
199
199
  route.modelId = resolveGeminiModelId(route.modelSelection);
200
200
  } else if (route.providerId === "google") {
201
201
  route.modelId = normalizeGeminiModelId(route.modelId);
202
- } else if (route.providerId === "anthropic") {
203
- const discovery = discoverProviderModels("anthropic");
202
+ } else if (route.providerId === "anthropic") {
203
+ const discovery = discoverProviderModels("anthropic");
204
204
  const stillAvailable = discovery.models.some((model) =>
205
205
  model.modelId === route.modelId ||
206
206
  model.id === route.modelId ||
207
207
  model.canonicalId === route.modelId
208
208
  );
209
- const hasNonFallbackModels = discovery.models.some((model) => model.source !== "fallback");
210
- if (discovery.status === "ready" && hasNonFallbackModels && discovery.models.length > 0 && !stillAvailable) {
211
- route.modelId = discovery.models[0]!.modelId;
212
- }
209
+ const hasNonFallbackModels = discovery.models.some((model) => model.source !== "fallback");
210
+ // Preserve explicit versioned Claude IDs even when the current discovery
211
+ // output does not advertise that historical model. Short aliases are the
212
+ // values that need migration to the first currently discovered model.
213
+ const isKnownShortAlias = ANTHROPIC_FALLBACK_MODELS.some((model) => model.modelId === route.modelId);
214
+ if (discovery.status === "ready" && hasNonFallbackModels && discovery.models.length > 0 && !stillAvailable && isKnownShortAlias) {
215
+ route.modelId = discovery.models[0]!.modelId;
216
+ }
213
217
  } else if (route.providerId === "local") {
214
218
  const discovery = discoverProviderModels("local");
215
219
  const selectedModel = typeof discovery.diagnostics?.selectedModel === "string"
@@ -0,0 +1,119 @@
1
+ import type { ChildProcess } from "child_process";
2
+ import {
3
+ runCommand,
4
+ runShellCommand,
5
+ type CommandResult,
6
+ type CommandSpec,
7
+ type CommandStreamHandlers,
8
+ } from "../process/CommandRunner.js";
9
+ import { CODEXA_NPM_PACKAGE } from "./updateCheck.js";
10
+
11
+ export type GlobalPackageManager = "npm" | "pnpm" | "yarn" | "bun";
12
+
13
+ const UPDATE_TIMEOUT_MS = 300_000;
14
+
15
+ const PACKAGE_SPEC = `${CODEXA_NPM_PACKAGE}@latest`;
16
+
17
+ // Yarn Classic only — Yarn Berry (v2+) removed `yarn global`, but Berry installs
18
+ // don't produce the global launcher paths we detect, so Classic is the only case.
19
+ const UPDATE_ARGV: Record<GlobalPackageManager, readonly string[]> = {
20
+ npm: ["npm", "install", "-g", PACKAGE_SPEC],
21
+ pnpm: ["pnpm", "add", "-g", PACKAGE_SPEC],
22
+ yarn: ["yarn", "global", "add", PACKAGE_SPEC],
23
+ bun: ["bun", "add", "-g", PACKAGE_SPEC],
24
+ };
25
+
26
+ /**
27
+ * Infers which package manager owns the global Codexa install from the
28
+ * launcher script location (CODEXA_LAUNCHER_SCRIPT, set by bin/codexa.js).
29
+ */
30
+ export function detectGlobalPackageManager(
31
+ env: NodeJS.ProcessEnv = process.env,
32
+ launcherPathOverride?: string,
33
+ ): GlobalPackageManager {
34
+ const launcherPath = launcherPathOverride ?? env.CODEXA_LAUNCHER_SCRIPT ?? process.argv[1] ?? "";
35
+ const normalized = launcherPath.toLowerCase().replace(/\\/g, "/");
36
+ if (!normalized) return "npm";
37
+
38
+ if (normalized.includes("pnpm")) return "pnpm";
39
+ if (normalized.includes("/.bun/") || normalized.includes("/bun/install/global/")) return "bun";
40
+ if (normalized.includes("/.yarn/") || normalized.includes("/yarn/") || normalized.includes(".config/yarn")) {
41
+ return "yarn";
42
+ }
43
+ return "npm";
44
+ }
45
+
46
+ export function getUpdateCommand(pm: GlobalPackageManager): {
47
+ displayCommand: string;
48
+ argv: readonly string[];
49
+ } {
50
+ const argv = UPDATE_ARGV[pm];
51
+ return { displayCommand: argv.join(" "), argv };
52
+ }
53
+
54
+ export interface RunUpdateCommandDeps {
55
+ platform?: NodeJS.Platform;
56
+ cwd?: string;
57
+ runCommandFn?: (
58
+ spec: CommandSpec,
59
+ handlers?: CommandStreamHandlers,
60
+ ) => { child: ChildProcess; result: Promise<CommandResult>; cancel: () => void };
61
+ runShellCommandFn?: (
62
+ command: string,
63
+ options: Pick<CommandSpec, "cwd" | "env" | "timeoutMs">,
64
+ handlers?: CommandStreamHandlers,
65
+ ) => { child: ChildProcess; result: Promise<CommandResult>; cancel: () => void };
66
+ }
67
+
68
+ /**
69
+ * Runs the global update command for the detected package manager.
70
+ * POSIX spawns the argv directly; Windows routes the constant, whitelisted
71
+ * command string through cmd.exe so .cmd shims (npm.cmd, pnpm.cmd) resolve —
72
+ * spawning them without a shell throws EINVAL on Node >= 18.20.
73
+ */
74
+ export function runUpdateCommand(
75
+ pm: GlobalPackageManager,
76
+ handlers: CommandStreamHandlers = {},
77
+ deps: RunUpdateCommandDeps = {},
78
+ ): { result: Promise<CommandResult>; cancel: () => void } {
79
+ const platform = deps.platform ?? process.platform;
80
+ const cwd = deps.cwd ?? process.cwd();
81
+ const { displayCommand, argv } = getUpdateCommand(pm);
82
+
83
+ if (platform === "win32") {
84
+ const runShell = deps.runShellCommandFn ?? runShellCommand;
85
+ const { result, cancel } = runShell(displayCommand, { cwd, timeoutMs: UPDATE_TIMEOUT_MS }, handlers);
86
+ return { result, cancel };
87
+ }
88
+
89
+ const run = deps.runCommandFn ?? runCommand;
90
+ const { result, cancel } = run(
91
+ { executable: argv[0]!, args: [...argv.slice(1)], cwd, timeoutMs: UPDATE_TIMEOUT_MS },
92
+ handlers,
93
+ );
94
+ return { result, cancel };
95
+ }
96
+
97
+ const PERMISSION_STDERR_RE = /EACCES|EPERM|permission denied/i;
98
+
99
+ /** npm reports EACCES via stderr with a nonzero exit, not as a spawn error. */
100
+ export function isPermissionError(result: CommandResult): boolean {
101
+ if (result.errorCode === "EACCES" || result.errorCode === "EPERM") return true;
102
+ return result.exitCode !== 0 && PERMISSION_STDERR_RE.test(result.stderr);
103
+ }
104
+
105
+ export function formatPermissionGuidance(pm: GlobalPackageManager): string {
106
+ const { displayCommand } = getUpdateCommand(pm);
107
+ const lines = [
108
+ `${pm} could not write to its global package directory, so the update was not installed.`,
109
+ ];
110
+ if (pm === "npm") {
111
+ lines.push(
112
+ "Check where global packages are installed with `npm config get prefix` and make sure that directory is writable by your user, or switch to a user-writable prefix (for example `npm config set prefix ~/.npm-global`, then add its bin directory to PATH).",
113
+ );
114
+ } else {
115
+ lines.push(`Make sure the ${pm} global package directory is writable by your user.`);
116
+ }
117
+ lines.push(`You can also run the command manually in a terminal with the right permissions: ${displayCommand}`);
118
+ return lines.join("\n");
119
+ }
@@ -154,7 +154,10 @@ export async function checkForUpdates(
154
154
  }
155
155
  }
156
156
 
157
- export function formatUpdateInstructions(result: UpdateCheckResult | null): string {
157
+ export function formatUpdateInstructions(
158
+ result: UpdateCheckResult | null,
159
+ updateCommand: string = CODEXA_UPDATE_COMMAND,
160
+ ): string {
158
161
  const current = result?.currentVersion ?? APP_VERSION;
159
162
  const latest = result?.latestVersion ?? "unknown";
160
163
 
@@ -166,21 +169,24 @@ export function formatUpdateInstructions(result: UpdateCheckResult | null): stri
166
169
  ].join("\n");
167
170
  }
168
171
 
169
- let statusLine: string;
170
- if (result?.status === "update-available" && result.latestVersion) {
171
- statusLine = `Update available: Codexa ${formatVersionLabel(result.latestVersion)}`;
172
- } else if (result?.status === "up-to-date") {
173
- statusLine = "Already up to date.";
174
- } else {
175
- statusLine = "Status unknown — could not reach npm registry.";
172
+ if (result?.status === "up-to-date") {
173
+ return [
174
+ "Codexa is up to date.",
175
+ `Current installed version: ${current}`,
176
+ `npm latest version: ${latest}`,
177
+ ].join("\n");
176
178
  }
177
179
 
180
+ const statusLine = result?.status === "update-available" && result.latestVersion
181
+ ? `Update available: Codexa ${formatVersionLabel(result.latestVersion)}`
182
+ : "Status unknown — could not reach npm registry.";
183
+
178
184
  return [
179
185
  `Current installed version: ${current}`,
180
186
  `npm latest version: ${latest}`,
181
187
  `Status: ${statusLine}`,
182
188
  "",
183
- `Run: ${CODEXA_UPDATE_COMMAND}`,
189
+ `Run: ${updateCommand}`,
184
190
  ].join("\n");
185
191
  }
186
192
 
@@ -1,11 +1,4 @@
1
- import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
- import { tmpdir } from "node:os";
3
- import { join } from "node:path";
4
1
  import { resolveRuntimeConfig, type RuntimeConfig, DEFAULT_RUNTIME_CONFIG, type ResolvedRuntimeConfig } from "../config/runtimeConfig.js";
5
- import type { ProviderId } from "../core/providerLauncher/types.js";
6
- import { resetAnthropicRouteValidationCacheForTests } from "../core/providerRuntime/anthropic.js";
7
- import { resetAntigravityRouteValidationCacheForTests } from "../core/providerRuntime/antigravity.js";
8
- import type { ProviderModel } from "../core/providerRuntime/types.js";
9
2
 
10
3
  export function makeResolvedRuntime(overrides: Partial<RuntimeConfig> = {}): ResolvedRuntimeConfig {
11
4
  return resolveRuntimeConfig({
@@ -19,86 +12,3 @@ export function makeResolvedRuntime(overrides: Partial<RuntimeConfig> = {}): Res
19
12
  }
20
13
 
21
14
  export const TEST_RUNTIME = makeResolvedRuntime();
22
-
23
- function agyModel(id: string, label: string): ProviderModel {
24
- return {
25
- id,
26
- modelId: id,
27
- label,
28
- description: null,
29
- defaultReasoningLevel: null,
30
- supportedReasoningLevels: null,
31
- source: "discovered",
32
- };
33
- }
34
-
35
- /**
36
- * The catalog `agy models` reports on a configured machine. Antigravity resolves
37
- * its status, backend kind, and model labels from whatever discovery last found,
38
- * so tests that assert on any of those need a catalog seeded rather than
39
- * whatever happens to be installed.
40
- */
41
- export const ANTIGRAVITY_FIXTURE_MODELS: readonly ProviderModel[] = [
42
- agyModel("gemini-3.5-flash", "Gemini 3.5 Flash"),
43
- agyModel("gemini-3.1-pro", "Gemini 3.1 Pro"),
44
- agyModel("claude-sonnet-4-6", "Claude Sonnet 4.6"),
45
- agyModel("claude-sonnet-4-6-think", "Claude Sonnet 4.6 (Thinking)"),
46
- agyModel("gpt-oss-120b", "GPT-OSS 120B"),
47
- ];
48
-
49
- /**
50
- * Drops every provider's in-memory discovery result.
51
- *
52
- * Bun shares the module registry across test files, so a catalog discovered by
53
- * one test (some probe the real `claude` / `agy` CLIs) otherwise survives into
54
- * the next and takes precedence over both the cache and the fallback list —
55
- * making assertions depend on file order and on what is installed locally.
56
- */
57
- export function resetProviderDiscoveryState(): void {
58
- resetAntigravityRouteValidationCacheForTests();
59
- resetAnthropicRouteValidationCacheForTests();
60
- }
61
-
62
- /**
63
- * Runs `fn` with HOME pointed at a throwaway directory holding only the provider
64
- * model cache built from `seed`.
65
- *
66
- * Provider discovery reads `~/.codexa-model-cache.json`, so a test that calls it
67
- * without this is asserting against the developer's own machine — it passes with
68
- * a warm cache and fails on a cold one (or in CI). HOME is read per call (see
69
- * `getProviderModelCacheFile`), so redirecting the env var here is enough.
70
- *
71
- * Antigravity's in-memory discovery is reset around the body as well: Bun shares
72
- * the module registry across test files, so a catalog discovered by one file
73
- * otherwise leaks into the next and takes precedence over the seeded cache,
74
- * making results depend on file order. Seed exactly what the assertions need.
75
- */
76
- export function withSeededModelCache<T>(
77
- seed: Partial<Record<ProviderId, readonly ProviderModel[]>>,
78
- fn: () => T,
79
- ): T {
80
- const tempHome = mkdtempSync(join(tmpdir(), "codexa-test-home-"));
81
- const previousHome = process.env.HOME;
82
- const previousUserProfile = process.env.USERPROFILE;
83
- try {
84
- process.env.HOME = tempHome;
85
- delete process.env.USERPROFILE;
86
- const providers = Object.fromEntries(
87
- Object.entries(seed).map(([providerId, models]) => [providerId, { discoveredAt: 1, models }]),
88
- );
89
- writeFileSync(
90
- join(tempHome, ".codexa-model-cache.json"),
91
- `${JSON.stringify({ version: 1, providers }, null, 2)}\n`,
92
- "utf8",
93
- );
94
- resetProviderDiscoveryState();
95
- return fn();
96
- } finally {
97
- if (previousHome === undefined) delete process.env.HOME;
98
- else process.env.HOME = previousHome;
99
- if (previousUserProfile === undefined) delete process.env.USERPROFILE;
100
- else process.env.USERPROFILE = previousUserProfile;
101
- rmSync(tempHome, { recursive: true, force: true });
102
- resetProviderDiscoveryState();
103
- }
104
- }
@@ -2,7 +2,7 @@ import React, { memo } from "react";
2
2
  import { Box, Text } from "ink";
3
3
  import { HEADER_CONFIG_DEFAULTS, type HeaderConfig } from "../../config/settings.js";
4
4
  import { formatCodexaBrandLabel } from "../../core/version/channel.js";
5
- import { formatVersionLabel } from "../../core/version/updateCheck.js";
5
+ import { CODEXA_UPDATE_COMMAND, formatVersionLabel } from "../../core/version/updateCheck.js";
6
6
  import type { RuntimeSummary } from "../../config/runtimeConfig.js";
7
7
  import type { CodexAuthState } from "../../core/auth/codexAuth.js";
8
8
  import { getAuthStateLabel } from "../../core/auth/codexAuth.js";
@@ -58,6 +58,7 @@ type HeaderMetadataLine = { key: string; text: string; color: string; bold: bool
58
58
  export interface UpdateAvailableInfo {
59
59
  latestVersion: string;
60
60
  currentVersion: string;
61
+ updateCommand?: string;
61
62
  }
62
63
 
63
64
  interface TopHeaderProps {
@@ -343,6 +344,7 @@ export function TopHeader({
343
344
  <UpdateAvailableCard
344
345
  latestVersion={updateAvailable.latestVersion}
345
346
  currentVersion={updateAvailable.currentVersion}
347
+ updateCommand={updateAvailable.updateCommand}
346
348
  width={metadataWidth}
347
349
  />
348
350
  <Box height={UPDATE_CARD_GAP_ROWS} />
@@ -359,7 +361,7 @@ export function TopHeader({
359
361
  )}
360
362
  {metadataColumn}
361
363
  {updateAvailable && (
362
- <Text color={theme.warning} wrap="truncate">{`Update available: Codexa ${formatVersionLabel(updateAvailable.latestVersion)} — Run: npm install -g @golba98/codexa@latest`}</Text>
364
+ <Text color={theme.warning} wrap="truncate">{`Update available: Codexa ${formatVersionLabel(updateAvailable.latestVersion)} — Run: ${updateAvailable.updateCommand ?? CODEXA_UPDATE_COMMAND}`}</Text>
363
365
  )}
364
366
  </Box>
365
367
  )}
@@ -10,13 +10,14 @@ export const UPDATE_CARD_ROWS = UPDATE_CARD_CONTENT_ROWS + 2; // +2 for top/bott
10
10
  export interface UpdateAvailableCardProps {
11
11
  latestVersion: string;
12
12
  currentVersion: string;
13
+ updateCommand?: string;
13
14
  /** Total box width including borders. Long lines are truncated to fit. */
14
15
  width?: number;
15
16
  }
16
17
 
17
- export function UpdateAvailableCard({ latestVersion, currentVersion, width }: UpdateAvailableCardProps) {
18
+ export function UpdateAvailableCard({ latestVersion, currentVersion, updateCommand = CODEXA_UPDATE_COMMAND, width }: UpdateAvailableCardProps) {
18
19
  const theme = useTheme();
19
- const command = `Run: ${CODEXA_UPDATE_COMMAND}`;
20
+ const command = `Run: ${updateCommand}`;
20
21
  // Inner content width = boxWidth - 2 (left/right border cols)
21
22
  const innerWidth = width !== undefined ? Math.max(8, width - 2) : undefined;
22
23
 
@@ -24,6 +24,7 @@ export const SLASH_COMMANDS = [
24
24
  { cmd: "/auth", desc: "Manage authentication" },
25
25
  { cmd: "/workspace", desc: "Show the locked workspace" },
26
26
  { cmd: "/copy", desc: "Copy the full conversation transcript to clipboard" },
27
+ { cmd: "/update", desc: "Check for updates and install the latest Codexa" },
27
28
  { cmd: "/mouse", desc: "Toggle mouse capture for wheel scrolling (off by default)" },
28
29
  { cmd: "/exit", desc: "Quit the application" },
29
30
  ] as const satisfies readonly SlashCommandSuggestion[];
@@ -1,31 +1,45 @@
1
1
  import React, { useEffect, useRef, useState } from "react";
2
2
  import { Box, Text, useFocus, useInput } from "ink";
3
- import { spawn } from "child_process";
4
3
  import { useTheme } from "../theme.js";
5
- import { CODEXA_NPM_PACKAGE, CODEXA_UPDATE_COMMAND, formatVersionLabel } from "../../core/version/updateCheck.js";
4
+ import { CODEXA_NPM_PACKAGE, formatVersionLabel } from "../../core/version/updateCheck.js";
5
+ import {
6
+ formatPermissionGuidance,
7
+ getUpdateCommand,
8
+ isPermissionError,
9
+ runUpdateCommand,
10
+ type GlobalPackageManager,
11
+ } from "../../core/version/packageManager.js";
12
+ import type { CommandResult, CommandStreamHandlers } from "../../core/process/CommandRunner.js";
6
13
 
7
14
  type Phase = "menu" | "running" | "done" | "error";
8
15
 
16
+ export type RunUpdateFn = (
17
+ pm: GlobalPackageManager,
18
+ handlers?: CommandStreamHandlers,
19
+ ) => { result: Promise<CommandResult>; cancel: () => void };
20
+
9
21
  const MENU_ITEMS = [
10
22
  { label: "Update now" },
11
- { label: "Skip" },
12
- { label: "Skip until next version" },
23
+ { label: "Later" },
13
24
  ] as const;
14
25
 
15
26
  interface UpdatePromptPanelProps {
16
27
  focusId: string;
17
28
  currentVersion: string;
18
29
  latestVersion: string;
30
+ packageManager: GlobalPackageManager;
31
+ /** Test seam — defaults to the real cross-platform runner. */
32
+ runUpdate?: RunUpdateFn;
19
33
  onSkip: () => void;
20
- onSkipUntilNextVersion: (version: string) => void;
21
34
  }
22
35
 
23
36
  export function UpdatePromptPanel({
24
37
  focusId,
25
38
  currentVersion,
26
39
  latestVersion,
40
+ packageManager,
41
+ runUpdate,
27
42
  onSkip,
28
- onSkipUntilNextVersion,
29
43
  }: UpdatePromptPanelProps) {
30
44
  const theme = useTheme();
31
45
  const { isFocused } = useFocus({ id: focusId, autoFocus: true });
@@ -35,7 +49,7 @@ export function UpdatePromptPanel({
35
49
  const [outputLines, setOutputLines] = useState<string[]>([]);
36
50
  const [errorMessage, setErrorMessage] = useState<string | null>(null);
37
51
 
38
- const spawnStartedRef = useRef(false);
52
+ const runStartedRef = useRef(false);
39
53
 
40
54
  useInput((input, key) => {
41
55
  if (key.escape) {
@@ -54,10 +68,8 @@ export function UpdatePromptPanel({
54
68
  if (key.return) {
55
69
  if (selectedIndex === 0) {
56
70
  setPhase("running");
57
- } else if (selectedIndex === 1) {
58
- onSkip();
59
71
  } else {
60
- onSkipUntilNextVersion(latestVersion);
72
+ onSkip();
61
73
  }
62
74
  return;
63
75
  }
@@ -70,42 +82,42 @@ export function UpdatePromptPanel({
70
82
 
71
83
  useEffect(() => {
72
84
  if (phase !== "running") return;
73
- if (spawnStartedRef.current) return;
74
- spawnStartedRef.current = true;
85
+ if (runStartedRef.current) return;
86
+ runStartedRef.current = true;
75
87
 
76
- const child = spawn("npm", ["install", "-g", `${CODEXA_NPM_PACKAGE}@latest`], {
77
- shell: false,
78
- stdio: ["ignore", "pipe", "pipe"],
79
- });
80
-
81
- const appendLines = (buf: Buffer) => {
82
- const lines = buf.toString("utf8").split(/\r?\n/).filter(Boolean);
88
+ const appendLines = (text: string) => {
89
+ const lines = text.split(/\r?\n/).filter(Boolean);
83
90
  if (lines.length > 0) {
84
91
  setOutputLines((prev) => [...prev, ...lines]);
85
92
  }
86
93
  };
87
94
 
88
- child.stdout?.on("data", appendLines);
89
- child.stderr?.on("data", appendLines);
90
-
91
- child.once("error", (err: Error) => {
92
- setErrorMessage(err.message);
93
- setPhase("error");
95
+ const runner = runUpdate ?? runUpdateCommand;
96
+ const { result, cancel } = runner(packageManager, {
97
+ onStdout: appendLines,
98
+ onStderr: appendLines,
94
99
  });
95
100
 
96
- child.once("close", (code: number | null) => {
97
- if (code === 0) {
101
+ let disposed = false;
102
+ void result.then((res) => {
103
+ if (disposed) return;
104
+ if (res.status === "completed" && res.exitCode === 0) {
98
105
  setPhase("done");
106
+ return;
107
+ }
108
+ if (isPermissionError(res)) {
109
+ setErrorMessage(formatPermissionGuidance(packageManager));
99
110
  } else {
100
- setErrorMessage(`npm exited with code ${code ?? "unknown"}.`);
101
- setPhase("error");
111
+ setErrorMessage(res.userMessage);
102
112
  }
113
+ setPhase("error");
103
114
  });
104
115
 
105
116
  return () => {
106
- try { child.kill(); } catch { /* ignore */ }
117
+ disposed = true;
118
+ cancel();
107
119
  };
108
- }, [phase]);
120
+ }, [phase, packageManager, runUpdate]);
109
121
 
110
122
  const footerText = phase === "menu"
111
123
  ? "Esc to close · Enter to confirm"
@@ -122,16 +134,16 @@ export function UpdatePromptPanel({
122
134
  flexDirection="column"
123
135
  >
124
136
  <Box>
125
- <Text color={theme.accent} bold>{`Update available: Codexa ${formatVersionLabel(latestVersion)}`}</Text>
137
+ <Text color={theme.accent} bold>{`Update available: Codexa ${latestVersion}`}</Text>
126
138
  </Box>
127
139
  <Box marginTop={1}>
128
- <Text color={theme.text}>{`${formatVersionLabel(currentVersion)} -> ${formatVersionLabel(latestVersion)}`}</Text>
140
+ <Text color={theme.text}>{`Current version: ${currentVersion}`}</Text>
129
141
  </Box>
130
142
  <Box>
131
143
  <Text color={theme.textMuted}>{`Package: ${CODEXA_NPM_PACKAGE}`}</Text>
132
144
  </Box>
133
145
  <Box>
134
- <Text color={theme.textMuted}>{`Run: ${CODEXA_UPDATE_COMMAND}`}</Text>
146
+ <Text color={theme.textMuted}>{`Run: ${getUpdateCommand(packageManager).displayCommand}`}</Text>
135
147
  </Box>
136
148
  </Box>
137
149
 
@@ -146,19 +158,17 @@ export function UpdatePromptPanel({
146
158
  >
147
159
  {phase === "menu" && (
148
160
  <>
149
- {MENU_ITEMS.map((item, index) => (
150
- <Box key={item.label}>
151
- <Text color={index === selectedIndex ? theme.accent : theme.textMuted}>
152
- {index === selectedIndex ? "› " : " "}
153
- </Text>
161
+ <Box>
162
+ {MENU_ITEMS.map((item, index) => (
154
163
  <Text
164
+ key={item.label}
155
165
  color={index === selectedIndex ? theme.text : theme.textMuted}
156
166
  bold={index === selectedIndex}
157
167
  >
158
- {`${index + 1}. ${item.label}`}
168
+ {`[ ${item.label} ]${index === 0 ? " " : ""}`}
159
169
  </Text>
160
- </Box>
161
- ))}
170
+ ))}
171
+ </Box>
162
172
  </>
163
173
  )}
164
174
 
@@ -173,7 +183,7 @@ export function UpdatePromptPanel({
173
183
 
174
184
  {phase === "done" && (
175
185
  <>
176
- <Text color={theme.success}>{"Codexa was updated successfully."}</Text>
186
+ <Text color={theme.success}>{`Codexa ${formatVersionLabel(latestVersion)} installed successfully.`}</Text>
177
187
  <Text color={theme.textMuted}>{"Restart Codexa to use the new version."}</Text>
178
188
  </>
179
189
  )}