Browse Source

Merge pull request #525 from alvinunreal/v1-safe-auto-update

fix(auto-update): avoid major-version upgrades
Alvin 2 months ago
parent
commit
705846db8b

+ 4 - 0
docs/configuration.md

@@ -173,6 +173,10 @@ With `autoUpdate` set to `false`, this becomes notification-only mode: you'll
 see that a new version is available, but the plugin won't install it
 automatically.
 
+Auto-update never crosses major versions. For example, a 1.x install can
+auto-update to a newer 1.x release, but it won't auto-install 2.x. When a newer
+major is available, the plugin shows a migration command instead.
+
 > Pinned plugin entries in `opencode.json` (for example
 > `"oh-my-opencode-slim@1.0.1"`) are the true version lock. Those stay pinned
 > regardless of `autoUpdate`.

+ 257 - 0
src/hooks/auto-update-checker/checker.test.ts

@@ -156,4 +156,261 @@ describe('auto-update-checker/checker', () => {
       readSpy.mockRestore();
     });
   });
+
+  describe('getLatestCompatibleVersion', () => {
+    test('selects latest version within current major', async () => {
+      const originalFetch = globalThis.fetch;
+      globalThis.fetch = mock(async () =>
+        Response.json({
+          'dist-tags': {
+            latest: '2.0.0',
+          },
+          versions: {
+            '1.1.0': {},
+            '1.1.2': {},
+            '2.0.0': {},
+          },
+        }),
+      ) as never;
+
+      const { getLatestCompatibleVersion } = await import(
+        `./checker?test=${importCounter++}`
+      );
+
+      const result = await getLatestCompatibleVersion('1.1.1');
+
+      expect(result).toEqual({
+        latestVersion: '1.1.2',
+        latestMajorVersion: '2.0.0',
+        blockedByMajor: true,
+      });
+
+      globalThis.fetch = originalFetch;
+    });
+
+    test('does not report major block when latest is same major', async () => {
+      const originalFetch = globalThis.fetch;
+      globalThis.fetch = mock(async () =>
+        Response.json({
+          'dist-tags': {
+            latest: '1.1.2',
+          },
+          versions: {
+            '1.1.1': {},
+            '1.1.2': {},
+          },
+        }),
+      ) as never;
+
+      const { getLatestCompatibleVersion } = await import(
+        `./checker?test=${importCounter++}`
+      );
+
+      const result = await getLatestCompatibleVersion('1.1.1');
+
+      expect(result).toEqual({
+        latestVersion: '1.1.2',
+        latestMajorVersion: null,
+        blockedByMajor: false,
+      });
+
+      globalThis.fetch = originalFetch;
+    });
+
+    test('uses channel tag as blocking major version', async () => {
+      const originalFetch = globalThis.fetch;
+      globalThis.fetch = mock(async () =>
+        Response.json({
+          'dist-tags': {
+            latest: '1.9.0',
+            beta: '2.0.0-beta.1',
+          },
+          versions: {
+            '1.9.0': {},
+            '2.0.0-beta.1': {},
+          },
+        }),
+      ) as never;
+
+      const { getLatestCompatibleVersion } = await import(
+        `./checker?test=${importCounter++}`
+      );
+
+      const result = await getLatestCompatibleVersion('1.8.0-beta.1', 'beta');
+
+      expect(result).toEqual({
+        latestVersion: null,
+        latestMajorVersion: '2.0.0-beta.1',
+        blockedByMajor: true,
+      });
+
+      globalThis.fetch = originalFetch;
+    });
+
+    test('treats unparseable current version as unsafe for auto-update', async () => {
+      const originalFetch = globalThis.fetch;
+      globalThis.fetch = mock(async () =>
+        Response.json({
+          latest: '2.0.0',
+        }),
+      ) as never;
+
+      const { getLatestCompatibleVersion } = await import(
+        `./checker?test=${importCounter++}`
+      );
+
+      const result = await getLatestCompatibleVersion('workspace:*');
+
+      expect(result).toEqual({
+        latestVersion: null,
+        latestMajorVersion: '2.0.0',
+        blockedByMajor: true,
+        unsafeReason: 'unparseable-current-version',
+      });
+
+      globalThis.fetch = originalFetch;
+    });
+
+    test('parses range prefixes before checking major compatibility', async () => {
+      const originalFetch = globalThis.fetch;
+      globalThis.fetch = mock(async () =>
+        Response.json({
+          'dist-tags': {
+            latest: '1.9.0',
+          },
+          versions: {
+            '1.8.0': {},
+            '1.9.0': {},
+          },
+        }),
+      ) as never;
+
+      const { getLatestCompatibleVersion } = await import(
+        `./checker?test=${importCounter++}`
+      );
+
+      const result = await getLatestCompatibleVersion('^1.0.0');
+
+      expect(result).toEqual({
+        latestVersion: '1.9.0',
+        latestMajorVersion: null,
+        blockedByMajor: false,
+      });
+
+      globalThis.fetch = originalFetch;
+    });
+
+    test('sorts prerelease numeric suffixes numerically', async () => {
+      const originalFetch = globalThis.fetch;
+      globalThis.fetch = mock(async () =>
+        Response.json({
+          'dist-tags': {
+            beta: '1.0.0-beta.10',
+            latest: '1.0.0',
+          },
+          versions: {
+            '1.0.0-beta.2': {},
+            '1.0.0-beta.10': {},
+          },
+        }),
+      ) as never;
+
+      const { getLatestCompatibleVersion } = await import(
+        `./checker?test=${importCounter++}`
+      );
+
+      const result = await getLatestCompatibleVersion('1.0.0-beta.1', 'beta');
+
+      expect(result).toEqual({
+        latestVersion: '1.0.0-beta.10',
+        latestMajorVersion: null,
+        blockedByMajor: false,
+      });
+
+      globalThis.fetch = originalFetch;
+    });
+
+    test('supports custom prerelease dist-tag channel names', async () => {
+      const originalFetch = globalThis.fetch;
+      globalThis.fetch = mock(async () =>
+        Response.json({
+          'dist-tags': {
+            latest: '1.0.0',
+            nightly: '1.0.0-nightly.2',
+          },
+          versions: {
+            '1.0.0-nightly.1': {},
+            '1.0.0-nightly.2': {},
+          },
+        }),
+      ) as never;
+
+      const { getLatestCompatibleVersion } = await import(
+        `./checker?test=${importCounter++}`
+      );
+
+      const result = await getLatestCompatibleVersion(
+        '1.0.0-nightly.1',
+        'nightly',
+      );
+
+      expect(result).toEqual({
+        latestVersion: '1.0.0-nightly.2',
+        latestMajorVersion: null,
+        blockedByMajor: false,
+      });
+
+      globalThis.fetch = originalFetch;
+    });
+
+    test('fallback dist-tags never return lower-major versions as compatible', async () => {
+      const originalFetch = globalThis.fetch;
+      globalThis.fetch = mock(async (url: string) => {
+        if (url.includes('/-/package/')) {
+          return Response.json({ latest: '1.5.0' });
+        }
+
+        return new Response(null, { status: 503 });
+      }) as never;
+
+      const { getLatestCompatibleVersion } = await import(
+        `./checker?test=${importCounter++}`
+      );
+
+      const result = await getLatestCompatibleVersion('2.0.0');
+
+      expect(result).toEqual({
+        latestVersion: null,
+        latestMajorVersion: null,
+        blockedByMajor: false,
+      });
+
+      globalThis.fetch = originalFetch;
+    });
+
+    test('fallback dist-tags never return stable latest for prerelease channel', async () => {
+      const originalFetch = globalThis.fetch;
+      globalThis.fetch = mock(async (url: string) => {
+        if (url.includes('/-/package/')) {
+          return Response.json({ latest: '1.5.0' });
+        }
+
+        return new Response(null, { status: 503 });
+      }) as never;
+
+      const { getLatestCompatibleVersion } = await import(
+        `./checker?test=${importCounter++}`
+      );
+
+      const result = await getLatestCompatibleVersion('1.4.0-beta.1', 'beta');
+
+      expect(result).toEqual({
+        latestVersion: null,
+        latestMajorVersion: null,
+        blockedByMajor: false,
+      });
+
+      globalThis.fetch = originalFetch;
+    });
+  });
 });

+ 197 - 1
src/hooks/auto-update-checker/checker.ts

@@ -6,18 +6,28 @@ import { log } from '../../utils/logger';
 import {
   INSTALLED_PACKAGE_JSON,
   NPM_FETCH_TIMEOUT,
+  NPM_PACKAGE_URL,
   NPM_REGISTRY_URL,
   PACKAGE_NAME,
   USER_OPENCODE_CONFIG,
   USER_OPENCODE_CONFIG_JSONC,
 } from './constants';
 import type {
+  CompatibleVersionResult,
   NpmDistTags,
+  NpmPackageMetadata,
   OpencodeConfig,
   PackageJson,
   PluginEntryInfo,
 } from './types';
 
+interface ParsedVersion {
+  major: number;
+  minor: number;
+  patch: number;
+  prerelease: string | null;
+}
+
 function isString(value: unknown): value is string {
   return typeof value === 'string';
 }
@@ -40,6 +50,82 @@ function isDistTag(version: string): boolean {
   return !/^\d/.test(version);
 }
 
+function parseVersion(version: string): ParsedVersion | null {
+  const normalized = version.trim().replace(/^[~^=<>\s]+/, '');
+  const match = normalized.match(/^(\d+)\.(\d+)\.(\d+)(?:-([\w.-]+))?/);
+  if (!match) return null;
+
+  return {
+    major: Number(match[1]),
+    minor: Number(match[2]),
+    patch: Number(match[3]),
+    prerelease: match[4] ?? null,
+  };
+}
+
+function compareVersions(a: string, b: string): number {
+  const parsedA = parseVersion(a);
+  const parsedB = parseVersion(b);
+  if (!parsedA || !parsedB) return a.localeCompare(b);
+
+  const parts: Array<keyof Pick<ParsedVersion, 'major' | 'minor' | 'patch'>> = [
+    'major',
+    'minor',
+    'patch',
+  ];
+  for (const part of parts) {
+    if (parsedA[part] !== parsedB[part]) {
+      return parsedA[part] - parsedB[part];
+    }
+  }
+
+  if (parsedA.prerelease === parsedB.prerelease) return 0;
+  if (!parsedA.prerelease) return 1;
+  if (!parsedB.prerelease) return -1;
+  return comparePrerelease(parsedA.prerelease, parsedB.prerelease);
+}
+
+function comparePrerelease(a: string, b: string): number {
+  const segmentsA = a.split('.');
+  const segmentsB = b.split('.');
+  const length = Math.max(segmentsA.length, segmentsB.length);
+
+  for (let i = 0; i < length; i++) {
+    const segmentA = segmentsA[i];
+    const segmentB = segmentsB[i];
+    if (segmentA === segmentB) continue;
+    if (segmentA === undefined) return -1;
+    if (segmentB === undefined) return 1;
+
+    const numberA = Number(segmentA);
+    const numberB = Number(segmentB);
+    const numericA = Number.isInteger(numberA);
+    const numericB = Number.isInteger(numberB);
+
+    if (numericA && numericB) return numberA - numberB;
+    if (numericA) return -1;
+    if (numericB) return 1;
+
+    const comparison = segmentA.localeCompare(segmentB);
+    if (comparison !== 0) return comparison;
+  }
+
+  return 0;
+}
+
+function getPrereleaseChannel(version: ParsedVersion): string | null {
+  if (!version.prerelease) return null;
+
+  return version.prerelease.split('.')[0] ?? null;
+}
+
+function isVersionInChannel(version: string, channel: string): boolean {
+  const parsed = parseVersion(version);
+  if (!parsed) return false;
+  if (channel === 'latest') return parsed.prerelease === null;
+  return getPrereleaseChannel(parsed) === channel;
+}
+
 /**
  * Extracts the update channel (latest, alpha, beta, etc.) from a version string.
  * @param version The version or tag to analyze.
@@ -287,6 +373,11 @@ export function updatePinnedVersion(
 export async function getLatestVersion(
   channel: string = 'latest',
 ): Promise<string | null> {
+  const distTags = await fetchDistTags();
+  return distTags?.[channel] ?? distTags?.latest ?? null;
+}
+
+async function fetchDistTags(): Promise<NpmDistTags | null> {
   const controller = new AbortController();
   const timeoutId = setTimeout(() => controller.abort(), NPM_FETCH_TIMEOUT);
 
@@ -299,10 +390,115 @@ export async function getLatestVersion(
     if (!response.ok) return null;
 
     const data = (await response.json()) as NpmDistTags;
-    return data[channel] ?? data.latest ?? null;
+    return data;
   } catch {
     return null;
   } finally {
     clearTimeout(timeoutId);
   }
 }
+
+/**
+ * Resolves the newest version that is safe for the current install to use.
+ * Auto-update never crosses major versions; newer majors are surfaced as a
+ * manual migration notification instead.
+ */
+export async function getLatestCompatibleVersion(
+  currentVersion: string,
+  channel: string = 'latest',
+): Promise<CompatibleVersionResult> {
+  const current = parseVersion(currentVersion);
+  if (!current) {
+    const latestVersion = await getLatestVersion(channel);
+    return {
+      latestVersion: null,
+      latestMajorVersion: latestVersion,
+      blockedByMajor: latestVersion !== null,
+      unsafeReason: latestVersion ? 'unparseable-current-version' : undefined,
+    };
+  }
+
+  const controller = new AbortController();
+  const timeoutId = setTimeout(() => controller.abort(), NPM_FETCH_TIMEOUT);
+
+  try {
+    const response = await fetch(NPM_PACKAGE_URL, {
+      signal: controller.signal,
+      headers: { Accept: 'application/json' },
+    });
+
+    if (!response.ok) return await getCompatibleFromDistTags(current, channel);
+
+    const data = (await response.json()) as NpmPackageMetadata;
+    const distTags = data['dist-tags'] ?? { latest: '' };
+    const taggedVersion = distTags[channel] ?? distTags.latest ?? null;
+    const latestMajorVersion = getBlockingMajorVersion(current, [
+      taggedVersion,
+      distTags.latest,
+    ]);
+    const blockedByMajor = latestMajorVersion !== null;
+
+    const versions = Object.keys(data.versions ?? {})
+      .filter((version) => {
+        const parsed = parseVersion(version);
+        return (
+          parsed?.major === current.major &&
+          isVersionInChannel(version, channel)
+        );
+      })
+      .sort(compareVersions);
+    const latestVersion = versions.at(-1) ?? null;
+
+    return { latestVersion, latestMajorVersion, blockedByMajor };
+  } catch {
+    return await getCompatibleFromDistTags(current, channel);
+  } finally {
+    clearTimeout(timeoutId);
+  }
+}
+
+async function getCompatibleFromDistTags(
+  current: ParsedVersion,
+  channel: string,
+): Promise<CompatibleVersionResult> {
+  const distTags = await fetchDistTags();
+  if (!distTags) {
+    return {
+      latestVersion: null,
+      latestMajorVersion: null,
+      blockedByMajor: false,
+    };
+  }
+
+  const latestVersion = distTags[channel] ?? distTags.latest ?? null;
+  const latestMajorVersion = getBlockingMajorVersion(current, [
+    latestVersion,
+    distTags.latest,
+  ]);
+  const blockedByMajor = latestMajorVersion !== null;
+  const parsedLatest = latestVersion ? parseVersion(latestVersion) : null;
+  const compatibleLatestVersion =
+    parsedLatest?.major === current.major &&
+    latestVersion &&
+    isVersionInChannel(latestVersion, channel)
+      ? latestVersion
+      : null;
+
+  return {
+    latestVersion: compatibleLatestVersion,
+    latestMajorVersion,
+    blockedByMajor,
+  };
+}
+
+function getBlockingMajorVersion(
+  current: ParsedVersion,
+  candidates: Array<string | null | undefined>,
+): string | null {
+  for (const candidate of candidates) {
+    const parsed = candidate ? parseVersion(candidate) : null;
+    if (parsed && parsed.major > current.major) return candidate ?? null;
+  }
+
+  return null;
+}

+ 1 - 0
src/hooks/auto-update-checker/constants.ts

@@ -4,6 +4,7 @@ import { getOpenCodeConfigPaths } from '../../cli/config-manager';
 
 export const PACKAGE_NAME = 'oh-my-opencode-slim';
 export const NPM_REGISTRY_URL = `https://registry.npmjs.org/-/package/${PACKAGE_NAME}/dist-tags`;
+export const NPM_PACKAGE_URL = `https://registry.npmjs.org/${PACKAGE_NAME}`;
 export const NPM_FETCH_TIMEOUT = 5000;
 
 function getCacheDir(): string {

+ 132 - 4
src/hooks/auto-update-checker/index.test.ts

@@ -6,6 +6,11 @@ const checkerMocks = {
   extractChannel: mock(() => 'latest'),
   findPluginEntry: mock(() => null),
   getCachedVersion: mock(() => null),
+  getLatestCompatibleVersion: mock(async () => ({
+    latestVersion: null,
+    latestMajorVersion: null,
+    blockedByMajor: false,
+  })),
   getLatestVersion: mock(async () => null),
   getLocalDevVersion: mock(() => null),
   getCurrentRuntimePackageJsonPath: mock(() => null),
@@ -82,6 +87,12 @@ describe('auto-update-checker/index', () => {
     checkerMocks.findPluginEntry.mockImplementation(() => null);
     checkerMocks.getCachedVersion.mockReset();
     checkerMocks.getCachedVersion.mockImplementation(() => null);
+    checkerMocks.getLatestCompatibleVersion.mockReset();
+    checkerMocks.getLatestCompatibleVersion.mockImplementation(async () => ({
+      latestVersion: null,
+      latestMajorVersion: null,
+      blockedByMajor: false,
+    }));
     checkerMocks.getLatestVersion.mockReset();
     checkerMocks.getLatestVersion.mockImplementation(async () => null);
     checkerMocks.getLocalDevVersion.mockReset();
@@ -140,7 +151,11 @@ describe('auto-update-checker/index', () => {
       isPinned: false,
     }));
     checkerMocks.getCachedVersion.mockImplementation(() => '0.9.1');
-    checkerMocks.getLatestVersion.mockImplementation(async () => '0.9.11');
+    checkerMocks.getLatestCompatibleVersion.mockImplementation(async () => ({
+      latestVersion: '0.9.11',
+      latestMajorVersion: null,
+      blockedByMajor: false,
+    }));
 
     crossSpawnMock.mockImplementation(() => ({
       exited: Promise.resolve(0),
@@ -184,7 +199,11 @@ describe('auto-update-checker/index', () => {
       isPinned: false,
     }));
     checkerMocks.getCachedVersion.mockImplementation(() => '0.9.1');
-    checkerMocks.getLatestVersion.mockImplementation(async () => '0.9.11');
+    checkerMocks.getLatestCompatibleVersion.mockImplementation(async () => ({
+      latestVersion: '0.9.11',
+      latestMajorVersion: null,
+      blockedByMajor: false,
+    }));
 
     const { createAutoUpdateCheckerHook } = await import(
       `./index?test=${importCounter++}`
@@ -215,7 +234,11 @@ describe('auto-update-checker/index', () => {
       isPinned: false,
     }));
     checkerMocks.getCachedVersion.mockImplementation(() => '0.9.1');
-    checkerMocks.getLatestVersion.mockImplementation(async () => '0.9.11');
+    checkerMocks.getLatestCompatibleVersion.mockImplementation(async () => ({
+      latestVersion: '0.9.11',
+      latestMajorVersion: null,
+      blockedByMajor: false,
+    }));
     cacheMocks.preparePackageUpdate.mockImplementation(() => null);
 
     const { createAutoUpdateCheckerHook } = await import(
@@ -245,7 +268,11 @@ describe('auto-update-checker/index', () => {
       isPinned: false,
     }));
     checkerMocks.getCachedVersion.mockImplementation(() => '0.9.1');
-    checkerMocks.getLatestVersion.mockImplementation(async () => '0.9.11');
+    checkerMocks.getLatestCompatibleVersion.mockImplementation(async () => ({
+      latestVersion: '0.9.11',
+      latestMajorVersion: null,
+      blockedByMajor: false,
+    }));
 
     crossSpawnMock.mockImplementation(() => ({
       exited: Promise.resolve(1),
@@ -279,4 +306,105 @@ describe('auto-update-checker/index', () => {
       },
     });
   });
+
+  test('does not auto-update across major versions', async () => {
+    checkerMocks.findPluginEntry.mockImplementation(() => ({
+      pinnedVersion: null,
+      isPinned: false,
+    }));
+    checkerMocks.getCachedVersion.mockImplementation(() => '1.1.2');
+    checkerMocks.getLatestCompatibleVersion.mockImplementation(async () => ({
+      latestVersion: '1.1.2',
+      latestMajorVersion: '2.0.0',
+      blockedByMajor: true,
+    }));
+
+    const { createAutoUpdateCheckerHook } = await import(
+      `./index?test=${importCounter++}`
+    );
+    const { ctx, showToast } = createCtx();
+
+    const hook = createAutoUpdateCheckerHook(ctx as never);
+    hook.event({ event: { type: 'session.created', properties: {} } });
+    await waitForCalls(showToast);
+
+    expect(showToast).toHaveBeenCalledWith({
+      body: {
+        title: 'oh-my-opencode-slim v2.0.0 is available.',
+        message:
+          'It requires OpenCode background subagents.\nRun: bunx oh-my-opencode-slim@latest install --background-subagents=yes',
+        variant: 'info',
+        duration: 12000,
+      },
+    });
+    expect(cacheMocks.preparePackageUpdate).not.toHaveBeenCalled();
+    expect(crossSpawnMock).not.toHaveBeenCalled();
+  });
+
+  test('shows only migration toast when compatible and blocked major updates coexist', async () => {
+    checkerMocks.findPluginEntry.mockImplementation(() => ({
+      pinnedVersion: null,
+      isPinned: false,
+    }));
+    checkerMocks.getCachedVersion.mockImplementation(() => '1.0.0');
+    checkerMocks.getLatestCompatibleVersion.mockImplementation(async () => ({
+      latestVersion: '1.5.0',
+      latestMajorVersion: '2.0.0',
+      blockedByMajor: true,
+    }));
+
+    const { createAutoUpdateCheckerHook } = await import(
+      `./index?test=${importCounter++}`
+    );
+    const { ctx, showToast } = createCtx();
+
+    const hook = createAutoUpdateCheckerHook(ctx as never);
+    hook.event({ event: { type: 'session.created', properties: {} } });
+    await waitForCalls(showToast);
+
+    expect(showToast).toHaveBeenCalledTimes(1);
+    expect(showToast).toHaveBeenCalledWith({
+      body: expect.objectContaining({
+        title: 'oh-my-opencode-slim v2.0.0 is available.',
+      }),
+    });
+    expect(cacheMocks.preparePackageUpdate).not.toHaveBeenCalled();
+    expect(crossSpawnMock).not.toHaveBeenCalled();
+  });
+
+  test('does not show migration copy for unparseable current versions', async () => {
+    checkerMocks.findPluginEntry.mockImplementation(() => ({
+      pinnedVersion: 'workspace:*',
+      isPinned: true,
+    }));
+    checkerMocks.getCachedVersion.mockImplementation(() => null);
+    checkerMocks.getLatestCompatibleVersion.mockImplementation(async () => ({
+      latestVersion: null,
+      latestMajorVersion: '1.9.0',
+      blockedByMajor: true,
+      unsafeReason: 'unparseable-current-version',
+    }));
+
+    const { createAutoUpdateCheckerHook } = await import(
+      `./index?test=${importCounter++}`
+    );
+    const { ctx, showToast } = createCtx();
+
+    const hook = createAutoUpdateCheckerHook(ctx as never);
+    hook.event({ event: { type: 'session.created', properties: {} } });
+    await waitForCalls(showToast);
+
+    expect(showToast).toHaveBeenCalledTimes(1);
+    expect(showToast).toHaveBeenCalledWith({
+      body: {
+        title: 'OMO-Slim 1.9.0',
+        message:
+          'v1.9.0 available. Auto-update skipped because the current version could not be compared safely.',
+        variant: 'info',
+        duration: 8000,
+      },
+    });
+    expect(cacheMocks.preparePackageUpdate).not.toHaveBeenCalled();
+    expect(crossSpawnMock).not.toHaveBeenCalled();
+  });
 });

+ 37 - 2
src/hooks/auto-update-checker/index.ts

@@ -6,7 +6,7 @@ import {
   extractChannel,
   findPluginEntry,
   getCachedVersion,
-  getLatestVersion,
+  getLatestCompatibleVersion,
   getLocalDevVersion,
 } from './checker';
 import { CACHE_DIR, PACKAGE_NAME } from './constants';
@@ -77,7 +77,32 @@ async function runBackgroundUpdateCheck(
   }
 
   const channel = extractChannel(pluginInfo.pinnedVersion ?? currentVersion);
-  const latestVersion = await getLatestVersion(channel);
+  const latestInfo = await getLatestCompatibleVersion(currentVersion, channel);
+  if (latestInfo.unsafeReason === 'unparseable-current-version') {
+    log(
+      `[auto-update-checker] Current version is not semver; skipping auto-update: ${currentVersion}`,
+    );
+    if (latestInfo.latestMajorVersion) {
+      showToast(
+        ctx,
+        `OMO-Slim ${latestInfo.latestMajorVersion}`,
+        `v${latestInfo.latestMajorVersion} available. Auto-update skipped because the current version could not be compared safely.`,
+        'info',
+        8000,
+      );
+    }
+    return;
+  }
+
+  if (latestInfo.blockedByMajor && latestInfo.latestMajorVersion) {
+    showMajorUpgradeToast(ctx, latestInfo.latestMajorVersion);
+    log(
+      `[auto-update-checker] Major update available; skipping auto-update: ${latestInfo.latestMajorVersion}`,
+    );
+    return;
+  }
+
+  const latestVersion = latestInfo.latestVersion;
   if (!latestVersion) {
     log(
       '[auto-update-checker] Failed to fetch latest version for channel:',
@@ -160,6 +185,16 @@ async function runBackgroundUpdateCheck(
   }
 }
 
+function showMajorUpgradeToast(ctx: PluginInput, version: string): void {
+  showToast(
+    ctx,
+    `oh-my-opencode-slim v${version} is available.`,
+    'It requires OpenCode background subagents.\nRun: bunx oh-my-opencode-slim@latest install --background-subagents=yes',
+    'info',
+    12_000,
+  );
+}
+
 export function getAutoUpdateInstallDir(): string {
   return resolveInstallContext()?.installDir ?? CACHE_DIR;
 }

+ 12 - 0
src/hooks/auto-update-checker/types.ts

@@ -3,6 +3,18 @@ export interface NpmDistTags {
   [key: string]: string;
 }
 
+export interface NpmPackageMetadata {
+  'dist-tags'?: NpmDistTags;
+  versions?: Record<string, unknown>;
+}
+
+export interface CompatibleVersionResult {
+  latestVersion: string | null;
+  latestMajorVersion: string | null;
+  blockedByMajor: boolean;
+  unsafeReason?: 'unparseable-current-version';
+}
+
 export interface OpencodeConfig {
   plugin?: unknown[];
   [key: string]: unknown;