Browse Source

feat: implement companion auto-update

Alvin Unreal 2 months ago
parent
commit
9631d0243f

+ 30 - 2
docs/companion.md

@@ -53,6 +53,8 @@ You can enable the companion by adding a `companion` section to your setting con
 
 - **`companion.binaryPath`**: optional path to a custom companion binary. When
   set, the runtime launches this binary instead of the default install path.
+  Custom binaries are user-managed and are not replaced by automatic companion
+  updates.
 
 ### Remembered Window Position
 
@@ -133,6 +135,22 @@ If `XDG_DATA_HOME` is unset, this resolves to:
 If the binary is not located in this directory, set `companion.binaryPath` to
 the binary you want the plugin runtime to launch.
 
+When Companion is enabled and uses the default install path, the plugin keeps
+the native binary aligned with the companion version bundled by the installed
+plugin package. The updater writes install metadata beside the binary so future
+starts can skip unnecessary downloads. Existing `companion-v0.1.2` installs that
+predate metadata are migrated in place without re-downloading.
+
+Startup checks run before the Companion is spawned, but they use a short timeout
+so OpenCode startup is not blocked by a slow network. Plugin auto-update also
+tries to update the Companion binary after the new package is installed. If a
+download fails, the plugin update still succeeds and Companion update is retried
+on the next OpenCode restart.
+
+Automatic native updates only use release archives listed in the packaged
+companion manifest, and every archive must have a matching SHA256 checksum.
+Custom binaries configured with `companion.binaryPath` are never overwritten.
+
 ---
 
 ## V2 Release Strategy
@@ -146,7 +164,10 @@ plan:
    plugin can ship beta updates without rebuilding native binaries every time.
 3. **OS/Arch Detection**: `--companion=yes` selects the archive for the current
    target.
-4. **No R2 Required**: GitHub Releases are the source of truth. R2 can be added
+4. **Manifest + Checksums**: the plugin ships
+   `src/companion/companion-manifest.json`, which names the companion release
+   and SHA256 checksum for each supported archive.
+5. **No R2 Required**: GitHub Releases are the source of truth. R2 can be added
    later as a mirror if download volume becomes a problem.
 
 Current release assets are named:
@@ -229,7 +250,14 @@ gh release view companion-v0.1.2
 ```
 
 Confirm the release contains the archive names expected by the installer for the
-targets you built.
+targets you built. Then update `src/companion/companion-manifest.json` with the
+new version, tag, and each asset's SHA256 digest. GitHub release asset metadata
+includes `digest: sha256:<hash>`, which can be copied into the manifest without
+the `sha256:` prefix.
+
+The runtime updater has a matching manifest constant in
+`src/companion/updater.ts`; tests assert the JSON manifest and runtime constant
+stay in sync.
 
 ### 4. Install with the plugin installer
 

+ 1 - 0
package.json

@@ -40,6 +40,7 @@
   "homepage": "https://github.com/alvinunreal/oh-my-opencode-slim#readme",
   "files": [
     "dist",
+    "src/companion/companion-manifest.json",
     "src/skills",
     "oh-my-opencode-slim.schema.json",
     "README.md",

+ 1 - 0
scripts/verify-release-artifact.ts

@@ -30,6 +30,7 @@ const packagedRequiredFiles = [
   'dist/index.d.ts',
   'dist/cli/index.js',
   'oh-my-opencode-slim.schema.json',
+  'src/companion/companion-manifest.json',
   'src/skills/simplify/SKILL.md',
   'src/skills/codemap/SKILL.md',
   'src/skills/clonedeps/SKILL.md',

+ 23 - 130
src/cli/companion.ts

@@ -1,50 +1,12 @@
 import {
-  chmodSync,
-  copyFileSync,
-  existsSync,
-  mkdirSync,
-  mkdtempSync,
-  renameSync,
-  rmSync,
-  writeFileSync,
-} from 'node:fs';
-import { homedir, tmpdir } from 'node:os';
-import * as path from 'node:path';
+  COMPANION_MANIFEST,
+  ensureCompanionVersion,
+  getCompanionBinaryPath,
+  getCompanionTarget,
+} from '../companion/updater';
 import type { ConfigMergeResult, InstallConfig } from './types';
 
-const COMPANION_VERSION = '0.1.2';
-const COMPANION_TAG = 'companion-v0.1.2';
-const GITHUB_REPO = 'alvinunreal/oh-my-opencode-slim';
-
-export function getCompanionTarget(): string | null {
-  const p = process.platform;
-  const a = process.arch;
-  if (p === 'darwin') {
-    if (a === 'arm64') return 'aarch64-apple-darwin';
-  } else if (p === 'linux') {
-    if (a === 'x64') return 'x86_64-unknown-linux-gnu';
-    if (a === 'arm64') return 'aarch64-unknown-linux-gnu';
-  } else if (p === 'win32') {
-    if (a === 'x64') return 'x86_64-pc-windows-msvc';
-  }
-  return null;
-}
-
-export function getCompanionBinaryPath(): string {
-  const xdg = process.env.XDG_DATA_HOME?.trim();
-  const base =
-    xdg && path.isAbsolute(xdg) ? xdg : path.join(homedir(), '.local', 'share');
-  return path.join(
-    base,
-    'opencode',
-    'storage',
-    'oh-my-opencode-slim',
-    'bin',
-    process.platform === 'win32'
-      ? 'oh-my-opencode-slim-companion.exe'
-      : 'oh-my-opencode-slim-companion',
-  );
-}
+export { getCompanionBinaryPath, getCompanionTarget };
 
 export async function installCompanion(
   config: InstallConfig,
@@ -60,10 +22,9 @@ export async function installCompanion(
     };
   }
 
-  const isWindows = process.platform === 'win32';
-  const ext = isWindows ? 'zip' : 'tar.gz';
-  const archiveName = `oh-my-opencode-slim-companion-v${COMPANION_VERSION}-${target}.${ext}`;
-  const downloadUrl = `https://github.com/${GITHUB_REPO}/releases/download/${COMPANION_TAG}/${archiveName}`;
+  const ext = process.platform === 'win32' ? 'zip' : 'tar.gz';
+  const archiveName = `oh-my-opencode-slim-companion-v${COMPANION_MANIFEST.version}-${target}.${ext}`;
+  const downloadUrl = `https://github.com/${COMPANION_MANIFEST.repo}/releases/download/${COMPANION_MANIFEST.tag}/${archiveName}`;
 
   if (config.dryRun) {
     console.log(`  [dry-run] Detected companion target: ${target}`);
@@ -75,91 +36,23 @@ export async function installCompanion(
     };
   }
 
-  let buffer: ArrayBuffer;
-  try {
-    const res = await fetch(downloadUrl);
-    if (!res.ok) {
-      return {
-        success: false,
-        configPath: finalBinaryPath,
-        error: `Failed to download companion binary (HTTP ${res.status}): ${res.statusText}`,
-      };
-    }
-    buffer = await res.arrayBuffer();
-  } catch (err) {
-    return {
-      success: false,
-      configPath: finalBinaryPath,
-      error: `Failed to fetch companion archive: ${err instanceof Error ? err.message : String(err)}`,
-    };
-  }
-
-  let tempDir = '';
-  try {
-    tempDir = mkdtempSync(path.join(tmpdir(), 'companion-install-'));
-    const archivePath = path.join(tempDir, archiveName);
-    writeFileSync(archivePath, Buffer.from(buffer));
-
-    const extractedDir = path.join(tempDir, 'extracted');
-    mkdirSync(extractedDir, { recursive: true });
-
-    if (isWindows) {
-      const { extractZip } = await import('../utils/zip-extractor');
-      await extractZip(archivePath, extractedDir);
-    } else {
-      const { crossSpawn } = await import('../utils/compat');
-      const proc = crossSpawn(['tar', '-xzf', archivePath, '-C', extractedDir]);
-      const exitCode = await proc.exited;
-      if (exitCode !== 0) {
-        const stderr = await proc.stderr();
-        return {
-          success: false,
-          configPath: finalBinaryPath,
-          error: `Archive extraction failed (tar exited with ${exitCode}): ${stderr}`,
-        };
-      }
-    }
-
-    const binaryName = isWindows
-      ? 'oh-my-opencode-slim-companion.exe'
-      : 'oh-my-opencode-slim-companion';
-    const extractedBinaryPath = path.join(extractedDir, binaryName);
-
-    if (!existsSync(extractedBinaryPath)) {
-      return {
-        success: false,
-        configPath: finalBinaryPath,
-        error: `Binary ${binaryName} not found in extracted archive`,
-      };
-    }
-
-    const binDir = path.dirname(finalBinaryPath);
-    mkdirSync(binDir, { recursive: true });
-
-    const tmpFinalPath = `${finalBinaryPath}.tmp`;
-    copyFileSync(extractedBinaryPath, tmpFinalPath);
-
-    if (!isWindows) {
-      chmodSync(tmpFinalPath, 0o755);
-    }
-
-    renameSync(tmpFinalPath, finalBinaryPath);
-
+  const result = await ensureCompanionVersion({
+    config: { enabled: true },
+    manifest: COMPANION_MANIFEST,
+  });
+  if (result.status === 'installed' || result.status === 'current') {
     return {
       success: true,
       configPath: finalBinaryPath,
     };
-  } catch (err) {
-    return {
-      success: false,
-      configPath: finalBinaryPath,
-      error: `Failed to install companion: ${err instanceof Error ? err.message : String(err)}`,
-    };
-  } finally {
-    if (tempDir) {
-      try {
-        rmSync(tempDir, { recursive: true, force: true });
-      } catch {}
-    }
   }
+
+  return {
+    success: false,
+    configPath: finalBinaryPath,
+    error:
+      result.status === 'failed'
+        ? result.error
+        : `Companion install skipped: ${result.reason}`,
+  };
 }

+ 11 - 0
src/companion/companion-manifest.json

@@ -0,0 +1,11 @@
+{
+  "version": "0.1.2",
+  "tag": "companion-v0.1.2",
+  "repo": "alvinunreal/oh-my-opencode-slim",
+  "checksums": {
+    "oh-my-opencode-slim-companion-v0.1.2-aarch64-apple-darwin.tar.gz": "6f66fdfe895ab39b96ddf1f395c4547cb428cfe68c94a1a3a9f11c1ab46863c2",
+    "oh-my-opencode-slim-companion-v0.1.2-aarch64-unknown-linux-gnu.tar.gz": "af979907a429904db4449a79e0d8d99edf994ce373d298284b440549d1eb507b",
+    "oh-my-opencode-slim-companion-v0.1.2-x86_64-pc-windows-msvc.zip": "fca87451bcc3a3f5eb5ab6dcd83999bb35e2a4f30d5a666c427b7bb892ac1eb0",
+    "oh-my-opencode-slim-companion-v0.1.2-x86_64-unknown-linux-gnu.tar.gz": "e6352a4e6c71773617ca2831af2102b011d3114c0fb8d40635b6af8076b757bc"
+  }
+}

+ 239 - 0
src/companion/updater.test.ts

@@ -0,0 +1,239 @@
+import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
+import {
+  existsSync,
+  mkdirSync,
+  readFileSync,
+  rmSync,
+  writeFileSync,
+} from 'node:fs';
+import * as os from 'node:os';
+import * as path from 'node:path';
+import {
+  COMPANION_MANIFEST,
+  ensureCompanionVersion,
+  getCompanionBinaryPath,
+  getCompanionTarget,
+  loadCompanionManifestFromPackageRoot,
+} from './updater';
+
+const TEST_DIR = path.join(
+  os.tmpdir(),
+  `companion-updater-test-${process.pid}`,
+);
+const originalXdg = process.env.XDG_DATA_HOME;
+const originalFetch = globalThis.fetch;
+
+describe('companion updater', () => {
+  beforeEach(() => {
+    rmSync(TEST_DIR, { recursive: true, force: true });
+    process.env.XDG_DATA_HOME = TEST_DIR;
+    globalThis.fetch = originalFetch;
+  });
+
+  afterEach(() => {
+    rmSync(TEST_DIR, { recursive: true, force: true });
+    if (originalXdg === undefined) {
+      delete process.env.XDG_DATA_HOME;
+    } else {
+      process.env.XDG_DATA_HOME = originalXdg;
+    }
+    globalThis.fetch = originalFetch;
+  });
+
+  test('skips disabled companion config', async () => {
+    const result = await ensureCompanionVersion({
+      config: { enabled: false },
+    });
+
+    expect(result.status).toBe('skipped');
+    expect(result).toMatchObject({ reason: 'disabled' });
+  });
+
+  test('skips user-managed custom companion binaries', async () => {
+    const result = await ensureCompanionVersion({
+      config: { enabled: true, binaryPath: '/custom/companion' },
+    });
+
+    expect(result.status).toBe('skipped');
+    expect(result).toMatchObject({ reason: 'custom-binary' });
+  });
+
+  test('treats matching installed metadata as current', async () => {
+    const bin = getCompanionBinaryPath();
+    mkdirSync(path.dirname(bin), { recursive: true });
+    writeFileSync(bin, 'binary');
+    writeFileSync(
+      `${bin}.json`,
+      JSON.stringify({
+        version: '0.1.2',
+        tag: 'companion-v0.1.2',
+        target: getCompanionTarget(),
+        installedAt: new Date().toISOString(),
+        archiveName: 'archive.tar.gz',
+      }),
+    );
+
+    const result = await ensureCompanionVersion({
+      config: { enabled: true },
+    });
+
+    expect(result).toMatchObject({
+      status: 'current',
+      binaryPath: bin,
+      version: '0.1.2',
+    });
+  });
+
+  test('dry-run reports the install without downloading', async () => {
+    const result = await ensureCompanionVersion({
+      config: { enabled: true },
+      dryRun: true,
+    });
+
+    expect(result).toMatchObject({
+      status: 'installed',
+      binaryPath: getCompanionBinaryPath(),
+      version: '0.1.2',
+    });
+  });
+
+  test('migrates existing v0.1.2 binaries without metadata', async () => {
+    const bin = getCompanionBinaryPath();
+    mkdirSync(path.dirname(bin), { recursive: true });
+    writeFileSync(bin, 'existing-binary');
+    globalThis.fetch = (() => {
+      throw new Error('should not download existing 0.1.2 binary');
+    }) as unknown as typeof fetch;
+
+    const result = await ensureCompanionVersion({
+      config: { enabled: true },
+    });
+
+    expect(result).toMatchObject({ status: 'current', version: '0.1.2' });
+    expect(existsSync(`${bin}.json`)).toBe(true);
+    expect(JSON.parse(readFileSync(`${bin}.json`, 'utf8'))).toMatchObject({
+      version: '0.1.2',
+      tag: 'companion-v0.1.2',
+      target: getCompanionTarget(),
+    });
+  });
+
+  test('fails closed when archive checksum is missing', async () => {
+    const fetchCalls: string[] = [];
+    globalThis.fetch = ((url: RequestInfo | URL) => {
+      fetchCalls.push(String(url));
+      throw new Error('should not fetch without checksum');
+    }) as unknown as typeof fetch;
+
+    const result = await ensureCompanionVersion({
+      config: { enabled: true },
+      manifest: {
+        version: '0.2.0',
+        tag: 'companion-v0.2.0',
+        repo: 'owner/repo',
+      },
+    });
+
+    expect(result.status).toBe('failed');
+    expect(result).toMatchObject({
+      error: expect.stringContaining('Missing SHA256 checksum'),
+    });
+    expect(fetchCalls).toEqual([]);
+  });
+
+  test('checksum mismatch fails without replacing existing binary', async () => {
+    const bin = getCompanionBinaryPath();
+    mkdirSync(path.dirname(bin), { recursive: true });
+    writeFileSync(bin, 'old-binary');
+    writeFileSync(
+      `${bin}.json`,
+      JSON.stringify({
+        version: '0.1.1',
+        tag: 'companion-v0.1.1',
+        target: getCompanionTarget(),
+        installedAt: new Date().toISOString(),
+        archiveName: 'old.tar.gz',
+      }),
+    );
+    globalThis.fetch = (async () =>
+      new Response(new Uint8Array([1, 2, 3]), {
+        status: 200,
+        statusText: 'OK',
+      })) as unknown as typeof fetch;
+
+    const target = getCompanionTarget() ?? 'unsupported';
+    const result = await ensureCompanionVersion({
+      config: { enabled: true },
+      manifest: {
+        version: '0.2.0',
+        tag: 'companion-v0.2.0',
+        repo: 'owner/repo',
+        checksums: {
+          [archiveName('0.2.0', target)]: 'bad',
+        },
+      },
+    });
+
+    expect(result).toMatchObject({
+      status: 'failed',
+      error: 'Companion archive checksum mismatch',
+    });
+    expect(readFileSync(bin, 'utf8')).toBe('old-binary');
+  });
+
+  test('fails instead of installing without a lock after lock timeout', async () => {
+    const bin = getCompanionBinaryPath();
+    mkdirSync(`${bin}.lock`, { recursive: true });
+    globalThis.fetch = (() => {
+      throw new Error('should not install without lock');
+    }) as unknown as typeof fetch;
+
+    const result = await ensureCompanionVersion({
+      config: { enabled: true },
+      manifest: {
+        version: '0.2.0',
+        tag: 'companion-v0.2.0',
+        repo: 'owner/repo',
+        checksums: {},
+      },
+      lockTimeoutMs: 1,
+    });
+
+    expect(result).toMatchObject({
+      status: 'failed',
+      error: 'Timed out waiting for companion install lock',
+    });
+  });
+
+  test('loads a companion manifest from an installed package root', () => {
+    const packageRoot = path.join(TEST_DIR, 'package');
+    const manifestDir = path.join(packageRoot, 'src', 'companion');
+    mkdirSync(manifestDir, { recursive: true });
+    writeFileSync(
+      path.join(manifestDir, 'companion-manifest.json'),
+      JSON.stringify({
+        version: '0.2.0',
+        tag: 'companion-v0.2.0',
+        repo: 'owner/repo',
+      }),
+    );
+
+    expect(loadCompanionManifestFromPackageRoot(packageRoot)).toEqual({
+      version: '0.2.0',
+      tag: 'companion-v0.2.0',
+      repo: 'owner/repo',
+    });
+  });
+
+  test('bundled JSON manifest matches the runtime manifest constant', () => {
+    const packageRoot = path.resolve(import.meta.dir, '..', '..');
+    expect(loadCompanionManifestFromPackageRoot(packageRoot)).toEqual(
+      COMPANION_MANIFEST,
+    );
+  });
+});
+
+function archiveName(version: string, target: string): string {
+  const ext = process.platform === 'win32' ? 'zip' : 'tar.gz';
+  return `oh-my-opencode-slim-companion-v${version}-${target}.${ext}`;
+}

+ 434 - 0
src/companion/updater.ts

@@ -0,0 +1,434 @@
+import { createHash } from 'node:crypto';
+import {
+  chmodSync,
+  copyFileSync,
+  existsSync,
+  mkdirSync,
+  mkdtempSync,
+  readFileSync,
+  renameSync,
+  rmSync,
+  writeFileSync,
+} from 'node:fs';
+import { homedir, platform, tmpdir } from 'node:os';
+import * as path from 'node:path';
+import { setTimeout as delay } from 'node:timers/promises';
+import type { CompanionConfig } from '../config/schema';
+import { crossSpawn } from '../utils/compat';
+import { log } from '../utils/logger';
+
+export interface CompanionManifest {
+  version: string;
+  tag: string;
+  repo: string;
+  checksums?: Record<string, string>;
+}
+
+interface CompanionInstallMetadata {
+  version: string;
+  tag: string;
+  target: string;
+  installedAt: string;
+  archiveName: string;
+  checksum?: string;
+}
+
+export type CompanionUpdateResult =
+  | { status: 'installed'; binaryPath: string; version: string }
+  | { status: 'current'; binaryPath: string; version: string }
+  | { status: 'skipped'; reason: string; binaryPath?: string }
+  | { status: 'failed'; error: string; binaryPath: string };
+
+const DOWNLOAD_TIMEOUT_MS = 30_000;
+const LOCK_TIMEOUT_MS = 2_000;
+const FIRST_METADATA_VERSION = '0.1.2';
+
+export const COMPANION_MANIFEST: CompanionManifest = {
+  version: '0.1.2',
+  tag: 'companion-v0.1.2',
+  repo: 'alvinunreal/oh-my-opencode-slim',
+  checksums: {
+    'oh-my-opencode-slim-companion-v0.1.2-aarch64-apple-darwin.tar.gz':
+      '6f66fdfe895ab39b96ddf1f395c4547cb428cfe68c94a1a3a9f11c1ab46863c2',
+    'oh-my-opencode-slim-companion-v0.1.2-aarch64-unknown-linux-gnu.tar.gz':
+      'af979907a429904db4449a79e0d8d99edf994ce373d298284b440549d1eb507b',
+    'oh-my-opencode-slim-companion-v0.1.2-x86_64-pc-windows-msvc.zip':
+      'fca87451bcc3a3f5eb5ab6dcd83999bb35e2a4f30d5a666c427b7bb892ac1eb0',
+    'oh-my-opencode-slim-companion-v0.1.2-x86_64-unknown-linux-gnu.tar.gz':
+      'e6352a4e6c71773617ca2831af2102b011d3114c0fb8d40635b6af8076b757bc',
+  },
+};
+
+export function getCompanionTarget(): string | null {
+  const p = process.platform;
+  const a = process.arch;
+  if (p === 'darwin') {
+    if (a === 'arm64') return 'aarch64-apple-darwin';
+  } else if (p === 'linux') {
+    if (a === 'x64') return 'x86_64-unknown-linux-gnu';
+    if (a === 'arm64') return 'aarch64-unknown-linux-gnu';
+  } else if (p === 'win32') {
+    if (a === 'x64') return 'x86_64-pc-windows-msvc';
+  }
+  return null;
+}
+
+export function getCompanionBinaryPath(): string {
+  const xdg = process.env.XDG_DATA_HOME?.trim();
+  const base =
+    xdg && path.isAbsolute(xdg) ? xdg : path.join(homedir(), '.local', 'share');
+  return path.join(
+    base,
+    'opencode',
+    'storage',
+    'oh-my-opencode-slim',
+    'bin',
+    platform() === 'win32'
+      ? 'oh-my-opencode-slim-companion.exe'
+      : 'oh-my-opencode-slim-companion',
+  );
+}
+
+export function loadCompanionManifestFromPackageRoot(
+  packageRoot: string,
+): CompanionManifest | null {
+  const manifestPath = path.join(
+    packageRoot,
+    'src',
+    'companion',
+    'companion-manifest.json',
+  );
+  try {
+    const parsed = JSON.parse(
+      readFileSync(manifestPath, 'utf8'),
+    ) as Partial<CompanionManifest>;
+    if (parsed.version && parsed.tag && parsed.repo) {
+      return {
+        version: parsed.version,
+        tag: parsed.tag,
+        repo: parsed.repo,
+        checksums: parsed.checksums,
+      };
+    }
+  } catch {}
+  return null;
+}
+
+export async function ensureCompanionVersion(options: {
+  config?: CompanionConfig;
+  manifest?: CompanionManifest;
+  dryRun?: boolean;
+  downloadTimeoutMs?: number;
+  lockTimeoutMs?: number;
+}): Promise<CompanionUpdateResult> {
+  const { config, dryRun = false } = options;
+  const manifest = options.manifest ?? COMPANION_MANIFEST;
+  const binaryPath = getCompanionBinaryPath();
+
+  if (config?.enabled !== true) {
+    return { status: 'skipped', reason: 'disabled', binaryPath };
+  }
+
+  if (config.binaryPath?.trim()) {
+    return { status: 'skipped', reason: 'custom-binary', binaryPath };
+  }
+
+  const target = getCompanionTarget();
+  if (!target) {
+    return {
+      status: 'failed',
+      binaryPath,
+      error: `Unsupported platform/architecture: ${process.platform} ${process.arch}`,
+    };
+  }
+
+  const current = readInstallMetadata(binaryPath);
+  if (
+    existsSync(binaryPath) &&
+    !current &&
+    manifest.version === FIRST_METADATA_VERSION
+  ) {
+    const archiveName = companionArchiveName(manifest.version, target);
+    writeInstallMetadata(binaryPath, {
+      version: manifest.version,
+      tag: manifest.tag,
+      target,
+      installedAt: new Date().toISOString(),
+      archiveName,
+      checksum: manifest.checksums?.[archiveName],
+    });
+    return { status: 'current', binaryPath, version: manifest.version };
+  }
+
+  if (
+    existsSync(binaryPath) &&
+    current?.target === target &&
+    compareSemver(current.version, manifest.version) >= 0
+  ) {
+    return { status: 'current', binaryPath, version: current.version };
+  }
+
+  if (dryRun) {
+    return { status: 'installed', binaryPath, version: manifest.version };
+  }
+
+  return withCompanionInstallLock(
+    binaryPath,
+    options.lockTimeoutMs,
+    async () => {
+      const lockedCurrent = readInstallMetadata(binaryPath);
+      if (
+        existsSync(binaryPath) &&
+        !lockedCurrent &&
+        manifest.version === FIRST_METADATA_VERSION
+      ) {
+        const archiveName = companionArchiveName(manifest.version, target);
+        writeInstallMetadata(binaryPath, {
+          version: manifest.version,
+          tag: manifest.tag,
+          target,
+          installedAt: new Date().toISOString(),
+          archiveName,
+          checksum: manifest.checksums?.[archiveName],
+        });
+        return { status: 'current', binaryPath, version: manifest.version };
+      }
+
+      if (
+        existsSync(binaryPath) &&
+        lockedCurrent?.target === target &&
+        compareSemver(lockedCurrent.version, manifest.version) >= 0
+      ) {
+        return {
+          status: 'current',
+          binaryPath,
+          version: lockedCurrent.version,
+        };
+      }
+
+      return installCompanionArchive(
+        binaryPath,
+        target,
+        manifest,
+        options.downloadTimeoutMs ?? DOWNLOAD_TIMEOUT_MS,
+      );
+    },
+  );
+}
+
+async function installCompanionArchive(
+  finalBinaryPath: string,
+  target: string,
+  manifest: CompanionManifest,
+  downloadTimeoutMs: number,
+): Promise<CompanionUpdateResult> {
+  const isWindows = process.platform === 'win32';
+  const archiveName = companionArchiveName(manifest.version, target, isWindows);
+  const downloadUrl = `https://github.com/${manifest.repo}/releases/download/${manifest.tag}/${archiveName}`;
+  const expectedChecksum = manifest.checksums?.[archiveName];
+  if (!expectedChecksum) {
+    return {
+      status: 'failed',
+      binaryPath: finalBinaryPath,
+      error: `Missing SHA256 checksum for companion archive: ${archiveName}`,
+    };
+  }
+
+  let buffer: ArrayBuffer;
+  const controller = new AbortController();
+  const timeout = setTimeout(() => controller.abort(), downloadTimeoutMs);
+  try {
+    const res = await fetch(downloadUrl, { signal: controller.signal });
+    if (!res.ok) {
+      return {
+        status: 'failed',
+        binaryPath: finalBinaryPath,
+        error: `Failed to download companion binary (HTTP ${res.status}): ${res.statusText}`,
+      };
+    }
+    buffer = await res.arrayBuffer();
+  } catch (err) {
+    return {
+      status: 'failed',
+      binaryPath: finalBinaryPath,
+      error: `Failed to fetch companion archive: ${formatError(err)}`,
+    };
+  } finally {
+    clearTimeout(timeout);
+  }
+
+  const checksum = createHash('sha256')
+    .update(Buffer.from(buffer))
+    .digest('hex');
+  if (checksum !== expectedChecksum) {
+    return {
+      status: 'failed',
+      binaryPath: finalBinaryPath,
+      error: 'Companion archive checksum mismatch',
+    };
+  }
+
+  let tempDir = '';
+  try {
+    tempDir = mkdtempSync(path.join(tmpdir(), 'companion-install-'));
+    const archivePath = path.join(tempDir, archiveName);
+    writeFileSync(archivePath, Buffer.from(buffer));
+
+    const extractedDir = path.join(tempDir, 'extracted');
+    mkdirSync(extractedDir, { recursive: true });
+
+    if (isWindows) {
+      const { extractZip } = await import('../utils/zip-extractor');
+      await extractZip(archivePath, extractedDir);
+    } else {
+      const proc = crossSpawn(['tar', '-xzf', archivePath, '-C', extractedDir]);
+      const exitCode = await proc.exited;
+      if (exitCode !== 0) {
+        const stderr = await proc.stderr();
+        return {
+          status: 'failed',
+          binaryPath: finalBinaryPath,
+          error: `Archive extraction failed (tar exited with ${exitCode}): ${stderr}`,
+        };
+      }
+    }
+
+    const binaryName = isWindows
+      ? 'oh-my-opencode-slim-companion.exe'
+      : 'oh-my-opencode-slim-companion';
+    const extractedBinaryPath = path.join(extractedDir, binaryName);
+
+    if (!existsSync(extractedBinaryPath)) {
+      return {
+        status: 'failed',
+        binaryPath: finalBinaryPath,
+        error: `Binary ${binaryName} not found in extracted archive`,
+      };
+    }
+
+    const binDir = path.dirname(finalBinaryPath);
+    mkdirSync(binDir, { recursive: true });
+
+    const tmpFinalPath = `${finalBinaryPath}.tmp`;
+    copyFileSync(extractedBinaryPath, tmpFinalPath);
+
+    if (!isWindows) {
+      chmodSync(tmpFinalPath, 0o755);
+    }
+
+    renameSync(tmpFinalPath, finalBinaryPath);
+    writeInstallMetadata(finalBinaryPath, {
+      version: manifest.version,
+      tag: manifest.tag,
+      target,
+      installedAt: new Date().toISOString(),
+      archiveName,
+      checksum,
+    });
+
+    return {
+      status: 'installed',
+      binaryPath: finalBinaryPath,
+      version: manifest.version,
+    };
+  } catch (err) {
+    return {
+      status: 'failed',
+      binaryPath: finalBinaryPath,
+      error: `Failed to install companion: ${formatError(err)}`,
+    };
+  } finally {
+    if (tempDir) {
+      try {
+        rmSync(tempDir, { recursive: true, force: true });
+      } catch {}
+    }
+  }
+}
+
+function readInstallMetadata(
+  binaryPath: string,
+): CompanionInstallMetadata | null {
+  try {
+    const parsed = JSON.parse(
+      readFileSync(metadataPath(binaryPath), 'utf8'),
+    ) as Partial<CompanionInstallMetadata> | null;
+    if (parsed?.version && parsed.tag && parsed.target) {
+      return parsed as CompanionInstallMetadata;
+    }
+  } catch {}
+  return null;
+}
+
+function writeInstallMetadata(
+  binaryPath: string,
+  metadata: CompanionInstallMetadata,
+): void {
+  writeFileSync(metadataPath(binaryPath), JSON.stringify(metadata, null, 2));
+}
+
+function metadataPath(binaryPath: string): string {
+  return `${binaryPath}.json`;
+}
+
+async function withCompanionInstallLock(
+  binaryPath: string,
+  timeoutMs: number | undefined,
+  run: () => Promise<CompanionUpdateResult>,
+): Promise<CompanionUpdateResult> {
+  const lock = `${binaryPath}.lock`;
+  const deadline = Date.now() + (timeoutMs ?? LOCK_TIMEOUT_MS);
+  mkdirSync(path.dirname(binaryPath), { recursive: true });
+  while (Date.now() <= deadline) {
+    try {
+      mkdirSync(lock);
+      try {
+        return await run();
+      } finally {
+        try {
+          rmSync(lock, { recursive: true, force: true });
+        } catch {}
+      }
+    } catch (err) {
+      const code = (err as NodeJS.ErrnoException).code;
+      if (code !== 'EEXIST') throw err;
+      await delay(25);
+    }
+  }
+  log('[companion] install lock timed out', lock);
+  return {
+    status: 'failed',
+    binaryPath,
+    error: 'Timed out waiting for companion install lock',
+  };
+}
+
+function companionArchiveName(
+  version: string,
+  target: string,
+  isWindows = process.platform === 'win32',
+): string {
+  const ext = isWindows ? 'zip' : 'tar.gz';
+  return `oh-my-opencode-slim-companion-v${version}-${target}.${ext}`;
+}
+
+function compareSemver(a: string, b: string): number {
+  const left = parseSemver(a);
+  const right = parseSemver(b);
+  if (!left || !right) return a.localeCompare(b);
+  for (let i = 0; i < 3; i++) {
+    const diff = left[i] - right[i];
+    if (diff !== 0) return diff;
+  }
+  return 0;
+}
+
+function parseSemver(version: string): [number, number, number] | null {
+  const match = version.match(/^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/);
+  if (!match) return null;
+  return [Number(match[1]), Number(match[2]), Number(match[3])];
+}
+
+function formatError(err: unknown): string {
+  return err instanceof Error ? err.message : String(err);
+}

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

@@ -29,6 +29,15 @@ const skillSyncMocks = {
   })),
 };
 
+const companionUpdaterMocks = {
+  ensureCompanionVersion: mock(async () => ({
+    status: 'current' as const,
+    binaryPath: '/tmp/companion',
+    version: '0.1.2',
+  })),
+  loadCompanionManifestFromPackageRoot: mock(() => null),
+};
+
 const crossSpawnMock = mock((_command: string[]) => ({
   exited: Promise.resolve(0),
   exitCode: 0,
@@ -48,6 +57,8 @@ mock.module('./cache', () => cacheMocks);
 
 mock.module('./skill-sync', () => skillSyncMocks);
 
+mock.module('../../companion/updater', () => companionUpdaterMocks);
+
 mock.module('../../utils/compat', () => ({
   crossSpawn: crossSpawnMock,
   crossWrite: mock(() => Promise.resolve()),
@@ -131,6 +142,19 @@ describe('auto-update-checker/index', () => {
       skippedExisting: [],
       failed: [],
     }));
+
+    companionUpdaterMocks.ensureCompanionVersion.mockReset();
+    companionUpdaterMocks.ensureCompanionVersion.mockImplementation(
+      async () => ({
+        status: 'current' as const,
+        binaryPath: '/tmp/companion',
+        version: '0.1.2',
+      }),
+    );
+    companionUpdaterMocks.loadCompanionManifestFromPackageRoot.mockReset();
+    companionUpdaterMocks.loadCompanionManifestFromPackageRoot.mockImplementation(
+      () => null,
+    );
   });
 
   afterEach(() => {
@@ -250,6 +274,106 @@ describe('auto-update-checker/index', () => {
     });
   });
 
+  test('updates enabled companion after plugin auto-update', async () => {
+    checkerMocks.findPluginEntry.mockImplementation(() => ({
+      pinnedVersion: null,
+      isPinned: false,
+    }));
+    checkerMocks.getCachedVersion.mockImplementation(() => '0.9.1');
+    checkerMocks.getLatestCompatibleVersion.mockImplementation(async () => ({
+      latestVersion: '0.9.11',
+      latestMajorVersion: null,
+      blockedByMajor: false,
+    }));
+    companionUpdaterMocks.loadCompanionManifestFromPackageRoot.mockImplementation(
+      () => ({
+        version: '0.2.0',
+        tag: 'companion-v0.2.0',
+        repo: 'owner/repo',
+      }),
+    );
+    companionUpdaterMocks.ensureCompanionVersion.mockImplementation(
+      async () => ({
+        status: 'installed' as const,
+        binaryPath: '/tmp/companion',
+        version: '0.2.0',
+      }),
+    );
+
+    const { createAutoUpdateCheckerHook } = await import(
+      `./index?test=${importCounter++}`
+    );
+    const { ctx, showToast } = createCtx();
+
+    const hook = createAutoUpdateCheckerHook(ctx as never, {
+      companion: { enabled: true },
+    });
+    hook.event({ event: { type: 'session.created', properties: {} } });
+    await waitForCalls(showToast);
+
+    expect(
+      companionUpdaterMocks.loadCompanionManifestFromPackageRoot,
+    ).toHaveBeenCalledWith('/tmp/opencode/node_modules/oh-my-opencode-slim');
+    expect(companionUpdaterMocks.ensureCompanionVersion).toHaveBeenCalledWith({
+      config: { enabled: true },
+      manifest: {
+        version: '0.2.0',
+        tag: 'companion-v0.2.0',
+        repo: 'owner/repo',
+      },
+    });
+    expect(showToast).toHaveBeenCalledWith({
+      body: {
+        title: 'OMO-Slim Updated!',
+        message:
+          'v0.9.1 → v0.9.11\nCompanion updated.\nRestart OpenCode to apply.',
+        variant: 'success',
+        duration: 8000,
+      },
+    });
+  });
+
+  test('keeps plugin update successful when companion update fails', async () => {
+    checkerMocks.findPluginEntry.mockImplementation(() => ({
+      pinnedVersion: null,
+      isPinned: false,
+    }));
+    checkerMocks.getCachedVersion.mockImplementation(() => '0.9.1');
+    checkerMocks.getLatestCompatibleVersion.mockImplementation(async () => ({
+      latestVersion: '0.9.11',
+      latestMajorVersion: null,
+      blockedByMajor: false,
+    }));
+    companionUpdaterMocks.ensureCompanionVersion.mockImplementation(
+      async () => ({
+        status: 'failed' as const,
+        binaryPath: '/tmp/companion',
+        error: 'network down',
+      }),
+    );
+
+    const { createAutoUpdateCheckerHook } = await import(
+      `./index?test=${importCounter++}`
+    );
+    const { ctx, showToast } = createCtx();
+
+    const hook = createAutoUpdateCheckerHook(ctx as never, {
+      companion: { enabled: true },
+    });
+    hook.event({ event: { type: 'session.created', properties: {} } });
+    await waitForCalls(showToast);
+
+    expect(showToast).toHaveBeenCalledWith({
+      body: {
+        title: 'OMO-Slim Updated!',
+        message:
+          'v0.9.1 → v0.9.11\nCompanion update will retry on restart.\nRestart OpenCode to apply.',
+        variant: 'success',
+        duration: 8000,
+      },
+    });
+  });
+
   test('still reports update success when bundled skill sync has failures', async () => {
     checkerMocks.findPluginEntry.mockImplementation(() => ({
       pinnedVersion: null,

+ 55 - 6
src/hooks/auto-update-checker/index.ts

@@ -1,5 +1,9 @@
 import * as path from 'node:path';
 import type { PluginInput } from '@opencode-ai/plugin';
+import {
+  ensureCompanionVersion,
+  loadCompanionManifestFromPackageRoot,
+} from '../../companion/updater';
 import { crossSpawn } from '../../utils/compat';
 import { log } from '../../utils/logger';
 import { preparePackageUpdate, resolveInstallContext } from './cache';
@@ -24,7 +28,7 @@ export function createAutoUpdateCheckerHook(
   ctx: PluginInput,
   options: AutoUpdateCheckerOptions = {},
 ) {
-  const { autoUpdate = true } = options;
+  const { autoUpdate = true, companion } = options;
 
   let hasChecked = false;
 
@@ -48,7 +52,7 @@ export function createAutoUpdateCheckerHook(
           return;
         }
 
-        runBackgroundUpdateCheck(ctx, autoUpdate).catch((err) => {
+        runBackgroundUpdateCheck(ctx, autoUpdate, companion).catch((err) => {
           log('[auto-update-checker] Background update check failed:', err);
         });
       }, 0);
@@ -64,6 +68,7 @@ export function createAutoUpdateCheckerHook(
 async function runBackgroundUpdateCheck(
   ctx: PluginInput,
   autoUpdate: boolean,
+  companion: AutoUpdateCheckerOptions['companion'],
 ): Promise<void> {
   const pluginInfo = findPluginEntry(ctx.directory);
   if (!pluginInfo) {
@@ -166,8 +171,10 @@ async function runBackgroundUpdateCheck(
 
   if (installSuccess) {
     let installedSkills: string[] = [];
+    let companionUpdated = false;
+    let companionWillRetry = false;
+    const packageRoot = path.join(installDir, 'node_modules', PACKAGE_NAME);
     try {
-      const packageRoot = path.join(installDir, 'node_modules', PACKAGE_NAME);
       const syncResult = syncBundledSkillsFromPackage(packageRoot);
       installedSkills = syncResult.installed;
       if (syncResult.failed.length > 0) {
@@ -184,12 +191,54 @@ async function runBackgroundUpdateCheck(
       log('[auto-update-checker] Skill sync failed silently:', err);
     }
 
-    let message = `v${currentVersion} → v${latestVersion}\nRestart OpenCode to apply.`;
+    if (companion?.enabled === true) {
+      try {
+        const manifest = loadCompanionManifestFromPackageRoot(packageRoot);
+        const companionResult = await ensureCompanionVersion({
+          config: companion,
+          manifest: manifest ?? undefined,
+        });
+        if (companionResult.status === 'installed') {
+          companionUpdated = true;
+        } else if (companionResult.status === 'failed') {
+          companionWillRetry = true;
+          log(
+            '[auto-update-checker] Companion update failed; will retry on restart:',
+            companionResult.error,
+          );
+        } else if (companionResult.status === 'skipped') {
+          log(
+            '[auto-update-checker] Companion update skipped:',
+            companionResult.reason,
+          );
+        }
+      } catch (err) {
+        companionWillRetry = true;
+        log(
+          '[auto-update-checker] Companion update failed silently; will retry on restart:',
+          err,
+        );
+      }
+    }
+
+    const messageLines = [`v${currentVersion} → v${latestVersion}`];
     if (installedSkills.length > 0) {
-      message = `v${currentVersion} → v${latestVersion}\nAdded bundled skills: ${installedSkills.join(', ')}\nRestart OpenCode to apply.`;
+      messageLines.push(`Added bundled skills: ${installedSkills.join(', ')}`);
     }
+    if (companionUpdated) {
+      messageLines.push('Companion updated.');
+    } else if (companionWillRetry) {
+      messageLines.push('Companion update will retry on restart.');
+    }
+    messageLines.push('Restart OpenCode to apply.');
 
-    showToast(ctx, 'OMO-Slim Updated!', message, 'success', 8000);
+    showToast(
+      ctx,
+      'OMO-Slim Updated!',
+      messageLines.join('\n'),
+      'success',
+      8000,
+    );
     log(
       `[auto-update-checker] Update installed: ${currentVersion} → ${latestVersion}`,
     );

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

@@ -1,3 +1,5 @@
+import type { CompanionConfig } from '../../config/schema';
+
 export interface NpmDistTags {
   latest: string;
   [key: string]: string;
@@ -28,6 +30,7 @@ export interface PackageJson {
 
 export interface AutoUpdateCheckerOptions {
   autoUpdate?: boolean;
+  companion?: CompanionConfig;
 }
 
 export interface PluginEntryInfo {

+ 19 - 0
src/index.ts

@@ -2,6 +2,7 @@ import type { Plugin } from '@opencode-ai/plugin';
 import { createAgents, getAgentConfigs, getDisabledAgents } from './agents';
 import { buildOrchestratorPrompt } from './agents/orchestrator';
 import { CompanionManager } from './companion/manager';
+import { ensureCompanionVersion } from './companion/updater';
 import {
   type AgentOverrideConfig,
   deepMerge,
@@ -257,6 +258,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
     // Initialize auto-update checker hook
     autoUpdateChecker = createAutoUpdateCheckerHook(ctx, {
       autoUpdate: config.autoUpdate ?? true,
+      companion: config.companion,
     });
 
     // Initialize phase reminder hook for workflow compliance
@@ -377,6 +379,23 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
     }
   });
 
+  if (config.companion?.enabled === true) {
+    try {
+      const companionResult = await ensureCompanionVersion({
+        config: config.companion,
+        downloadTimeoutMs: 3_000,
+        lockTimeoutMs: 500,
+      });
+      if (companionResult.status === 'installed') {
+        log('[companion] updated before startup', companionResult.version);
+      } else if (companionResult.status === 'failed') {
+        log('[companion] startup update failed', companionResult.error);
+      }
+    } catch (err) {
+      log('[companion] startup update failed', String(err));
+    }
+  }
+
   companionManager.onLoad();
 
   return {