﻿// @ts-nocheck
/**
 * Elyntic Auto-Update Extension v4
 *
 * CTO Audit v4 — All 17 issues resolved:
 *  C2: Branch name sanitization
 *  C3: No fire-and-forget npm install — notification only
 *  C5: RELEASE_MANIFEST with inverted merge logic (user files preserved)
 *  H2: Watchdog supports tarball rollback (.rollback dir)
 *  M1: preflightWarned moved into closure
 *  + Atomic state writes (write-then-rename)
 *  + PID-based file locking with dead process detection
 *  + Async JSONL logging (fire-and-forget appendFile)
 *  + Tarball staging dir with atomic swap
 *  + Semver comparison (compareVersions)
 *  + verifyInstallation for staging validation
 *  + mergeUserFiles (inverted manifest logic)
 *  + No sync exec anywhere
 *  + All state writes via writeStateAtomic
 *
 * 3-Layer Rollback System:
 *  Layer 1 — AUTOMATIC:  save HEAD / dir before update, revert on any failure
 *  Layer 2 — WATCHDOG:   on session_start, detect crash-after-update, auto-rollback
 *  Layer 3 — MANUAL:     /rollback [list], /update [--lock|--unlock]
 *
 * State file: ~/helios-agent/.update-state.json
 *  { lastKnownGood, pendingUpdate, updateTimestamp, locked, rollbackHistory, pendingValidated, lastNpmCheck }
 *
 * Pi 0.57.0 API used:
 *  - pi.appendEntry(customType, data)
 *  - ctx.sessionManager.getEntries()
 *  - pi.registerCommand(name, { description, handler(args, ctx) })
 *  - ctx.ui.confirm(title, msg, { timeout })
 *  - ctx.ui.notify(msg, type)
 *  - ctx.ui.setWorkingMessage(msg?)
 */

import type { ExtensionAPI, ExtensionContext } from '@helios-agent/pi-coding-agent';
import { exec as execCb } from 'node:child_process';
import { existsSync, readFileSync, writeFileSync, renameSync, unlinkSync, readdirSync, appendFile, cpSync } from 'node:fs';
import { homedir } from 'node:os';
import { join, resolve } from 'node:path';

// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------

// ELYNTIC_ROOT is set by ElynticInfraService when spawning the daemon.
// Fall back through multiple known locations so the extension works regardless
// of how helios-agent was installed.
// existsSync is already imported at line 40 — use it directly (no require() needed).
const AGENT_DIR = (() => {
  if (process.env['ELYNTIC_ROOT']) return process.env['ELYNTIC_ROOT'];
  // Canonical Desktop location (all platforms)
  const desktopPath = resolve(homedir(), 'Desktop', 'Elyntic', 'helios-agent-main');
  if (existsSync(desktopPath)) return desktopPath;
  // Legacy home-directory clone location
  return resolve(homedir(), 'helios-agent');
})();
const STATE_FILE        = resolve(AGENT_DIR, '.update-state.json');
const LOCK_FILE         = resolve(AGENT_DIR, '.update-lock');
const UPDATE_LOG        = resolve(AGENT_DIR, '.update-log.jsonl');
const COOLDOWN_MS       = 2 * 60 * 60 * 1000;       // 2 hours
const ENTRY_TYPE        = 'auto_update_check';
const NULL_PATH         = process.platform === 'win32' ? 'NUL' : '/dev/null';
const FETCH_TIMEOUT     = 15_000;
const PULL_TIMEOUT      = 60_000;
const PI_UPDATE_TIMEOUT = 180_000;
const CONFIRM_TIMEOUT   = 10_000; // reduced from 30s — forced UI takeover was too long
const NPM_COOLDOWN_MS   = 24 * 60 * 60 * 1000;      // 24 hours
const RELEASE_URL       = 'https://github.com/helios-agi/elyntic-team-installer/releases/latest/download';

// ---------------------------------------------------------------------------
// Release manifest — files/dirs managed by the release (C5)
// User files are everything NOT in this list — preserved via inverted logic.
// ---------------------------------------------------------------------------

export const RELEASE_MANIFEST: readonly string[] = [
  'extensions/', 'skills/', 'agents/', 'config/', 'scripts/', 'lib/', 'daemon/',
  'docs/', 'schemas/', 'mental_models/', 'design-system/', 'design-taste/',
  'docker/', 'bin/', 'installer/', 'git/', 'format-preferences/',
  'AGENTS.md', 'APPEND_SYSTEM.md', 'ARCHITECTURE.md', 'CHANGELOG.md', 'LICENSE',
  'agent-card.json', 'coding-matrix-repos.json', 'dep-aibom.jsonl',
  'extension-registry.json', 'setup.sh', 'install.sh', 'VERSION',
  'package.json', 'package-lock.json', 'pnpm-lock.yaml',
  'internal-backfill-worker.ts', 'internal-backfill-pipeline.ts',
  'run-backfill-30d.ts', 'run-backfill.ps1', 'backfill-dashboard.js',
  '.release-manifest.txt'
] as const;

// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------

interface RollbackEntry {
  from:      string;
  to:        string;
  reason:    string;
  timestamp: number;
}

interface UpdateState {
  lastKnownGood:    string | null;
  pendingUpdate:    string | null;
  updateTimestamp:  number | null;
  locked:           boolean;
  rollbackHistory:  RollbackEntry[];
  pendingValidated: boolean;
  lastNpmCheck:     number | null;
}

interface UpdateResult {
  success:     boolean;
  rolledBack?: boolean;
  error?:      string;
}

interface GitPackageResult {
  package:  string;
  success:  boolean;
  error?:   string;
  skipped?: boolean;
}

// ---------------------------------------------------------------------------
// Async JSONL logger — fire-and-forget, never blocks
// ---------------------------------------------------------------------------

function log(level: 'info' | 'warn' | 'error', msg: string, data?: Record<string, unknown>): void {
  try {
    const entry = JSON.stringify({ ts: new Date().toISOString(), level, msg, ...data }) + '\n';
    appendFile(UPDATE_LOG, entry, () => { /* fire-and-forget */ });
  } catch (e) { process.stderr.write(`[extensions] never crash: ${String(e)}\n`); }
}

// ---------------------------------------------------------------------------
// Async exec helper — never blocks the event loop
// ---------------------------------------------------------------------------

function run(cmd: string, timeout = 30_000): Promise<{ stdout: string; stderr: string }> {
  return new Promise((res, rej) => {
    execCb(cmd, { cwd: AGENT_DIR, timeout, killSignal: 'SIGKILL' }, (err, stdout, stderr) => {
      if (err) rej(new Error(`${cmd.slice(0, 80)}: ${stderr?.trim() || err.message}`));
      else res({ stdout: stdout?.trim() ?? '', stderr: stderr?.trim() ?? '' });
    });
  });
}

// ---------------------------------------------------------------------------
// SHA validation
// ---------------------------------------------------------------------------

function validateSHA(sha: string | null | undefined): string {
  if (!sha || !/^[0-9a-f]{7,40}$/i.test(sha)) {
    throw new Error(`Invalid SHA: ${String(sha).slice(0, 40)}`);
  }
  return sha;
}

// ---------------------------------------------------------------------------
// Quiet startup detection
// ---------------------------------------------------------------------------

function isQuietStartup(): boolean {
  try {
    const settingsPath = join(AGENT_DIR, 'settings.json');
    const settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
    return !!settings.quietStartup;
  } catch (e) { process.stderr.write(`[auto-update.ts] operation failed: ${String(e)}\n`); return false; }
}

// ---------------------------------------------------------------------------
// State file helpers — atomic writes via write-then-rename
// ---------------------------------------------------------------------------

function readState(): UpdateState {
  try {
    const raw = readFileSync(STATE_FILE, 'utf8');
    const parsed = JSON.parse(raw) as Partial<UpdateState>;
    return {
      lastKnownGood:    parsed.lastKnownGood   ?? null,
      pendingUpdate:    parsed.pendingUpdate   ?? null,
      updateTimestamp:  parsed.updateTimestamp ?? null,
      locked:           parsed.locked          ?? false,
      rollbackHistory:  Array.isArray(parsed.rollbackHistory) ? parsed.rollbackHistory : [],
      pendingValidated: parsed.pendingValidated ?? false,
      lastNpmCheck:     parsed.lastNpmCheck     ?? null,
    };
  } catch (e) {
    process.stderr.write(`[auto-update.ts] operation failed: ${String(e)}\n`);
    return {
      lastKnownGood:    null,
      pendingUpdate:    null,
      updateTimestamp:  null,
      locked:           false,
      rollbackHistory:  [],
      pendingValidated: false,
      lastNpmCheck:     null,
    };
  }
}

function writeStateAtomic(state: UpdateState): void {
  try {
    const tmp = STATE_FILE + '.tmp';
    writeFileSync(tmp, JSON.stringify(state, null, 2), 'utf8');
    renameSync(tmp, STATE_FILE);
  } catch (e) { process.stderr.write(`[extensions] non-fatal — state persistence is best-effort: ${String(e)}\n`); }
}

// ---------------------------------------------------------------------------
// Session cooldown persistence (Pi 0.57.0 entry API)
// ---------------------------------------------------------------------------

function getLastCheckTime(ctx: ExtensionContext): number {
  try {
    const entries = ctx.sessionManager.getEntries();
    for (let i = entries.length - 1; i >= 0; i--) {
      const entry = entries[i];
      if (
        entry.type === 'custom' &&
        (entry as any).customType === ENTRY_TYPE
      ) {
        const ts = (entry as any).data?.timestamp;
        if (typeof ts === 'number') return ts;
      }
    }
  } catch (e) { process.stderr.write(`[extensions] best effort: ${String(e)}\n`); }
  return 0;
}

function recordCheck(pi: ExtensionAPI): void {
  try {
    pi.appendEntry(ENTRY_TYPE, { timestamp: Date.now() });
  } catch (e) { process.stderr.write(`[extensions] non-fatal: ${String(e)}\n`); }
}

// ---------------------------------------------------------------------------
// Branch name sanitization (C2) — reject injection attempts
// ---------------------------------------------------------------------------

function sanitizeBranchName(raw: string): string {
  const trimmed = raw.trim();
  if (!/^[a-zA-Z0-9._\/-]+$/.test(trimmed)) {
    throw new Error(`[AUTO-UPDATE] Suspicious branch name rejected: ${trimmed.slice(0, 60)}`);
  }
  return trimmed;
}

// ---------------------------------------------------------------------------
// Semver comparison — returns -1, 0, or 1
// ---------------------------------------------------------------------------

function compareVersions(a: string, b: string): number {
  const normalize = (v: string) => v.replace(/^v/i, '').trim();
  const pa = normalize(a).split('.').map(Number);
  const pb = normalize(b).split('.').map(Number);
  const len = Math.max(pa.length, pb.length);
  for (let i = 0; i < len; i++) {
    const na = pa[i] ?? 0;
    const nb = pb[i] ?? 0;
    if (na > nb) return 1;
    if (na < nb) return -1;
  }
  return 0;
}

export function resolveDeclaredCliPackage(agentDir: string = AGENT_DIR): {
  aliasName: string;
  registryName: string;
  installedVersion: string;
} {
  const aliasName = '@helios-agent/pi-coding-agent';
  const manifest = JSON.parse(readFileSync(join(agentDir, 'package.json'), 'utf-8'));
  const aliasSpec = String(manifest.dependencies?.[aliasName] ?? '');
  const registryMatch = /^npm:((?:@[a-z0-9._-]+\/)?[a-z0-9._-]+)@/i.exec(aliasSpec);
  if (!registryMatch) {
    throw new Error(`package.json must declare ${aliasName} as an npm alias`);
  }
  const registryName = registryMatch[1];
  const installedManifest = JSON.parse(readFileSync(
    join(agentDir, 'node_modules', ...aliasName.split('/'), 'package.json'),
    'utf-8',
  ));
  const installedVersion = String(installedManifest.version ?? '').trim();
  if (!installedVersion) throw new Error(`${aliasName} has no installed version`);
  return { aliasName, registryName, installedVersion };
}

// ---------------------------------------------------------------------------
// Installation verification — existence checks only (<500ms)
// ---------------------------------------------------------------------------

function verifyInstallation(dir: string): { ok: boolean; reason?: string } {
  try {
    if (!existsSync(join(dir, 'extensions'))) return { ok: false, reason: 'Missing extensions/' };
    if (!existsSync(join(dir, 'config')))     return { ok: false, reason: 'Missing config/' };
    if (!existsSync(join(dir, 'agents')))     return { ok: false, reason: 'Missing agents/' };
    if (!existsSync(join(dir, 'VERSION')))    return { ok: false, reason: 'Missing VERSION' };
    // Validate settings.json is parseable
    const settingsPath = join(dir, 'settings.json');
    if (existsSync(settingsPath)) {
      JSON.parse(readFileSync(settingsPath, 'utf-8'));
    }
    return { ok: true };
  } catch (e) {
    return { ok: false, reason: `Verification failed: ${e instanceof Error ? e.message : String(e)}` };
  }
}

// ---------------------------------------------------------------------------
// PID-based file lock with dead process detection
// ---------------------------------------------------------------------------

async function withUpdateLock<T>(fn: () => Promise<T>): Promise<T> {
  // Attempt to acquire lock
  try {
    writeFileSync(LOCK_FILE, `${process.pid}\n`, { flag: 'wx' });
  } catch (e) {
    process.stderr.write(`[auto-update.ts] operation failed: ${String(e)}\n`);
    // Lock file exists — check if owning process is alive
    try {
      const pid = parseInt(readFileSync(LOCK_FILE, 'utf-8').trim(), 10);
      if (!isNaN(pid)) {
        try {
          process.kill(pid, 0); // signal 0 = existence check
          // Process is alive — bail
          log('warn', 'Update lock held by active process', { pid });
          throw new Error(`[AUTO-UPDATE] Update already in progress (PID ${pid})`);
        } catch (killErr: any) {
          if (killErr.code === 'ESRCH') {
            // Dead process — steal lock
            log('info', 'Stealing lock from dead process', { pid });
            try { unlinkSync(LOCK_FILE); } catch (e) { process.stderr.write(`[extensions] race — ok: ${String(e)}\n`); }
            writeFileSync(LOCK_FILE, `${process.pid}\n`, { flag: 'wx' });
          } else {
            throw killErr;
          }
        }
      }
    } catch (readErr: any) {
      if (readErr.message?.includes('AUTO-UPDATE')) throw readErr;
      // Can't read lock file — try to force acquire
      try { unlinkSync(LOCK_FILE); } catch (e) { process.stderr.write(`[extensions] ok: ${String(e)}\n`); }
      writeFileSync(LOCK_FILE, `${process.pid}\n`, { flag: 'wx' });
    }
  }

  try {
    return await fn();
  } finally {
    try { unlinkSync(LOCK_FILE); } catch (e) { process.stderr.write(`[extensions] best effort cleanup: ${String(e)}\n`); }
  }
}

// ---------------------------------------------------------------------------
// Merge user files — copy non-manifest files from live into staging (C5)
// Inverted logic: everything NOT in RELEASE_MANIFEST is a user file.
// ---------------------------------------------------------------------------

export async function mergeUserFiles(liveDir: string, stagingDir: string): Promise<void> {
  try {
    const entries = readdirSync(liveDir);
    for (const entry of entries) {
      // Check if this entry is managed by the release
      const isManifest = RELEASE_MANIFEST.some(m => {
        if (m.endsWith('/')) return entry === m.slice(0, -1);
        return entry === m;
      });
      if (isManifest) continue;
      // Skip hidden update artifacts
      if (entry === '.update-state.json' || entry === '.update-lock' ||
          entry === '.update-log.jsonl' || entry === '.update-tarball.tar.gz' ||
          entry === '.update-state.json.tmp') continue;
      // Copy user file/dir into staging using Node.js cpSync — cross-platform.
      // Previously used `cp -a` (POSIX-only) which silently failed on Windows,
      // dropping ALL user files (company configs, credentials) from staging.
      // cpSync is available since Node 16.7+. The daemon runs on Node 22.
      try {
        const srcPath = join(liveDir, entry);
        const dstPath = join(stagingDir, entry);
        cpSync(srcPath, dstPath, { recursive: true, force: true });
      } catch (e) {
        process.stderr.write(`[auto-update.ts] operation failed: ${String(e)}\n`);
        log('warn', `Failed to merge user file: ${entry}`);
      }
    }
  } catch (e) {
    log('error', 'mergeUserFiles failed', { error: e instanceof Error ? e.message : String(e) });
  }
}

// ---------------------------------------------------------------------------
// Tarball update — staging dir with atomic swap (complete rewrite)
// ---------------------------------------------------------------------------

async function tarballUpdate(ctx: ExtensionContext): Promise<{ ok: boolean; reason?: string }> {
  const ui = ctx.ui;
  const stagingDir  = AGENT_DIR + '.staging';
  const rollbackDir = AGENT_DIR + '.rollback';
  const tarball     = join(AGENT_DIR, '.update-tarball.tar.gz');

  try {
    // Step 1: Check remote VERSION
    ui.setWorkingMessage('[AUTO-UPDATE] Checking for updates...');
    let remoteVersion = '';
    try {
      const { stdout } = await run(`curl -fsSL "${RELEASE_URL}/VERSION"`, 10_000);
      remoteVersion = stdout.trim().replace(/^v/i, '').trim();
    } catch (e) {
      process.stderr.write(`[auto-update.ts] operation failed: ${String(e)}\n`);
      log('warn', 'Cannot fetch remote VERSION');
      return { ok: false, reason: 'Cannot check for updates — network issue' };
    }

    // Step 2: Compare with semver
    const localVersionFile = join(AGENT_DIR, 'VERSION');
    let localVersion = '';
    try { localVersion = readFileSync(localVersionFile, 'utf8').trim().replace(/^v/i, '').trim(); } catch (e) { process.stderr.write(`[extensions] fail-open: file read - use fallback: ${String(e)}\n`); }

    if (localVersion && compareVersions(localVersion, remoteVersion) >= 0) {
      ui.setWorkingMessage(undefined);
      if (!isQuietStartup()) {
        ui.notify(`[AUTO-UPDATE] Already up to date (v${localVersion})`, 'info');
      }
      log('info', 'Tarball update: already up to date', { localVersion, remoteVersion });
      return { ok: true };
    }

    if (!isQuietStartup()) {
      ui.notify(`[AUTO-UPDATE] Update available: v${localVersion || 'unknown'} → v${remoteVersion}`, 'info');
    }
    log('info', 'Tarball update available', { localVersion, remoteVersion });

    // Step 3: Download tarball
    ui.setWorkingMessage('[AUTO-UPDATE] Downloading update...');
    try {
      await run(`curl -fsSL -o "${tarball}" "${RELEASE_URL}/helios-agent-latest.tar.gz"`, 120_000);
    } catch (e) {
      process.stderr.write(`[auto-update.ts] operation failed: ${String(e)}\n`);
      log('error', 'Tarball download failed');
      return { ok: false, reason: 'Download failed' };
    }

    // Step 4: Remove macOS quarantine xattr
    try { await run(`xattr -d com.apple.quarantine "${tarball}" 2>/dev/null || true`, 5_000); } catch (e) { process.stderr.write(`[extensions] fail-open: graceful degradation: ${String(e)}\n`); }

    // Step 5: Verify checksum
    try {
      const { stdout: remoteSha } = await run(`curl -fsSL "${RELEASE_URL}/helios-agent-latest.tar.gz.sha256"`, 10_000);
      const { stdout: localSha } = await run(`shasum -a 256 "${tarball}" | awk '{print $1}'`, 10_000);
      if (remoteSha.trim().split(/\s/)[0] !== localSha.trim()) {
        try { await run(`rm -f "${tarball}"`, 5_000); } catch (e) { process.stderr.write(`[extensions] fail-open: graceful degradation: ${String(e)}\n`); }
        log('error', 'Checksum mismatch', { remote: remoteSha.trim().split(/\s/)[0], local: localSha.trim() });
        return { ok: false, reason: 'Checksum mismatch — download may be corrupted' };
      }
    } catch (e) {
      process.stderr.write(`[auto-update.ts] operation failed: ${String(e)}\n`);
      // Checksum file may not exist — continue without verification
      log('warn', 'Checksum verification skipped (no remote checksum)');
    }

    // Step 6: Check disk space
    try {
      const { stdout: dfOut } = await run(`df -Pk "${AGENT_DIR}" | tail -1 | awk '{print $4}'`, 5_000);
      const availableKB = parseInt(dfOut, 10);
      if (!isNaN(availableKB) && availableKB < 100_000) {
        try { await run(`rm -f "${tarball}"`, 5_000); } catch (e) { process.stderr.write(`[extensions] fail-open: parse failure - use fallback: ${String(e)}\n`); }
        return { ok: false, reason: `Insufficient disk space (${Math.round(availableKB / 1024)}MB free, need ~100MB)` };
      }
    } catch (e) { process.stderr.write(`[extensions] best effort: ${String(e)}\n`); }

    // Step 7: Check download not truncated
    try {
      const { stdout: sizeOut } = await run(`stat -f%z "${tarball}" 2>/dev/null || stat -c%s "${tarball}" 2>/dev/null`, 5_000);
      const fileSize = parseInt(sizeOut, 10);
      if (!isNaN(fileSize) && fileSize < 50_000) {
        try { await run(`rm -f "${tarball}"`, 5_000); } catch (e) { process.stderr.write(`[extensions] fail-open: parse failure - use fallback: ${String(e)}\n`); }
        return { ok: false, reason: `Download appears truncated (${fileSize} bytes). Check your network connection.` };
      }
    } catch (e) { process.stderr.write(`[extensions] best effort: ${String(e)}\n`); }

    // Step 8: Extract to .staging directory (NOT to AGENT_DIR)
    ui.setWorkingMessage('[AUTO-UPDATE] Extracting to .staging...');
    try { await run(`rm -rf "${stagingDir}"`, 10_000); } catch (e) { process.stderr.write(`[extensions] fail-open: graceful degradation: ${String(e)}\n`); }
    try {
      await run(`mkdir -p "${stagingDir}"`, 5_000);
      await run(`tar -xzf "${tarball}" -C "${stagingDir}" --strip-components=1`, 60_000);
    } catch (extractErr) {
      const detail = extractErr instanceof Error ? extractErr.message : String(extractErr);
      log('error', 'Tarball extraction failed', { error: detail });
      try { await run(`rm -rf "${stagingDir}"`, 10_000); } catch (e) { process.stderr.write(`[extensions] fail-open: graceful degradation: ${String(e)}\n`); }
      try { await run(`rm -f "${tarball}"`, 5_000); } catch (e) { process.stderr.write(`[extensions] fail-open: graceful degradation: ${String(e)}\n`); }
      return { ok: false, reason: `Extraction failed: ${detail.slice(0, 200)}` };
    }

    // Step 9: Verify staging
    const verification = verifyInstallation(stagingDir);
    if (!verification.ok) {
      log('error', 'Staging verification failed', { reason: verification.reason });
      try { await run(`rm -rf "${stagingDir}"`, 10_000); } catch (e) { process.stderr.write(`[extensions] fail-open: graceful degradation: ${String(e)}\n`); }
      try { await run(`rm -f "${tarball}"`, 5_000); } catch (e) { process.stderr.write(`[extensions] fail-open: graceful degradation: ${String(e)}\n`); }
      return { ok: false, reason: `Staging verification failed: ${verification.reason}` };
    }

    // Step 10: Merge user files from live into staging
    ui.setWorkingMessage('[AUTO-UPDATE] Preserving user files...');
    await mergeUserFiles(AGENT_DIR, stagingDir);

    // Step 10b: Normalize settings.json package sources.
    // Keep package sources canonical as git:github.com/... identifiers so
    // package-manager can resolve them consistently across installs.
    // Older Elyntic updates rewrote these to git/github.com/... which breaks
    // package resolution and can trigger duplicate raw-extension loads.
    try {
      const mergedSettings = join(stagingDir, 'settings.json');
      if (existsSync(mergedSettings)) {
        let content = readFileSync(mergedSettings, 'utf-8');
        if (content.includes('"git/github.com/')) {
          content = content.replace(/"git\/github\.com\//g, '"git:github.com/');
          writeFileSync(mergedSettings, content, 'utf-8');
          log('info', 'Normalized settings.json packages: git/ → git:');
        }
      }
    } catch (e) { process.stderr.write(`[extensions] non-fatal: ${String(e)}\n`); }

    // Step 11: Write VERSION to staging
    try { writeFileSync(join(stagingDir, 'VERSION'), remoteVersion + '\n'); } catch (e) { process.stderr.write(`[extensions] fail-open: file write - non-critical: ${String(e)}\n`); }

    // Step 12: Atomic swap — mv live → rollback, mv staging → live
    ui.setWorkingMessage('[AUTO-UPDATE] Installing update...');
    try { await run(`rm -rf "${rollbackDir}"`, 10_000); } catch (e) { process.stderr.write(`[extensions] fail-open: graceful degradation: ${String(e)}\n`); }
    try {
      renameSync(AGENT_DIR, rollbackDir);
      renameSync(stagingDir, AGENT_DIR);
    } catch (swapErr) {
      const detail = swapErr instanceof Error ? swapErr.message : String(swapErr);
      log('error', 'Atomic swap failed', { error: detail });
      // Attempt recovery: if AGENT_DIR doesn't exist, rollback is still there
      if (!existsSync(AGENT_DIR) && existsSync(rollbackDir)) {
        try { renameSync(rollbackDir, AGENT_DIR); } catch (e) { process.stderr.write(`[extensions] fail-open: graceful degradation: ${String(e)}\n`); }
      }
      try { await run(`rm -rf "${stagingDir}"`, 10_000); } catch (e) { process.stderr.write(`[extensions] fail-open: graceful degradation: ${String(e)}\n`); }
      try { await run(`rm -f "${tarball}"`, 5_000); } catch (e) { process.stderr.write(`[extensions] fail-open: graceful degradation: ${String(e)}\n`); }
      return { ok: false, reason: `Atomic swap failed — previous version restored: ${detail.slice(0, 200)}` };
    }

    // Step 13: Run pi update
    ui.setWorkingMessage('[AUTO-UPDATE] Updating packages...');
    try { await run('pi update', PI_UPDATE_TIMEOUT); } catch (e) {
      log('warn', 'pi update after tarball swap failed', { error: e instanceof Error ? e.message : String(e) });
    }

    // Step 14: Cleanup tarball (it's now in the rollback dir or AGENT_DIR)
    const tarballInNew = join(AGENT_DIR, '.update-tarball.tar.gz');
    try { await run(`rm -f "${tarballInNew}"`, 5_000); } catch (e) { process.stderr.write(`[extensions] fail-open: graceful degradation: ${String(e)}\n`); }
    // Also clean tarball from rollback dir if present
    try { await run(`rm -f "${join(rollbackDir, '.update-tarball.tar.gz')}"`, 5_000); } catch (e) { process.stderr.write(`[extensions] fail-open: graceful degradation: ${String(e)}\n`); }

    // Update state in the new AGENT_DIR
    const state = readState();
    writeStateAtomic({
      ...state,
      pendingUpdate:    `tarball-v${remoteVersion}`,
      updateTimestamp:  Date.now(),
      pendingValidated: false,
    });

    ui.setWorkingMessage(undefined);
    ui.notify(`[AUTO-UPDATE] ✅ Updated to v${remoteVersion}. Restart Pi to load changes.`, 'info');
    log('info', 'Tarball update successful', { version: remoteVersion });
    return { ok: true };
  } catch (e) {
    ui.setWorkingMessage(undefined);
    const detail = e instanceof Error ? e.message : String(e);
    log('error', 'Tarball update failed (uncaught)', { error: detail });
    // Cleanup staging on any uncaught error
    try { await run(`rm -rf "${stagingDir}"`, 10_000); } catch (e) { process.stderr.write(`[extensions] fail-open: graceful degradation: ${String(e)}\n`); }
    return { ok: false, reason: `[AUTO-UPDATE] Update failed: ${detail}` };
  }
}

// ---------------------------------------------------------------------------
// Pre-flight safety checks
// ---------------------------------------------------------------------------

async function preflight(ctx?: ExtensionContext): Promise<{ ok: boolean; reason?: string }> {
  // Is this a git repo?
  try {
    await run('git rev-parse --git-dir', 5_000);
  } catch {
    // Not a git repo (tarball install) — use tarball update with lock
    if (ctx) {
      return await withUpdateLock(() => tarballUpdate(ctx));
    }
    return { ok: false, reason: 'Not a git repo and no context for tarball update' };
  }

  // On main or master branch only
  try {
    const { stdout: branch } = await run('git rev-parse --abbrev-ref HEAD', 5_000);
    if (branch !== 'main' && branch !== 'master') {
      return { ok: false, reason: `On branch '${branch}' — updates only apply to main/master` };
    }
  } catch (e) {
    process.stderr.write(`[auto-update.ts] operation failed: ${String(e)}\n`);
    return { ok: false, reason: 'Cannot determine current branch' };
  }

  // Clean worktree
  try {
    const { stdout: status } = await run('git status --porcelain', 5_000);
    if (status.length > 0) {
      return { ok: false, reason: 'Uncommitted changes detected — commit or stash first' };
    }
  } catch (e) {
    process.stderr.write(`[auto-update.ts] operation failed: ${String(e)}\n`);
    return { ok: false, reason: 'Cannot check worktree status' };
  }

  return { ok: true };
}

// ---------------------------------------------------------------------------
// Check for available updates (git)
// ---------------------------------------------------------------------------

async function checkForUpdates(): Promise<{ behind: number; commits: string[] } | null> {
  try {
    await run(`git -c core.hooksPath=${NULL_PATH} fetch origin main --quiet`, FETCH_TIMEOUT);
    const { stdout: countStr } = await run('git rev-list --count HEAD..origin/main', 5_000);
    const behind = parseInt(countStr, 10);
    if (isNaN(behind) || behind <= 0) return null;

    const { stdout: logStr } = await run(
      `git log --oneline -${Math.min(behind, 15)} origin/main ^HEAD`,
      5_000
    );
    const commits = logStr ? logStr.split('\n').filter(Boolean) : [];
    return { behind, commits };
  } catch (e) {
    process.stderr.write(`[auto-update.ts] operation failed: ${String(e)}\n`);
    return null;
  }
}

// ---------------------------------------------------------------------------
// Layer 1 helper — rollback to a specific SHA + re-run pi update
// ---------------------------------------------------------------------------

async function rollbackTo(
  sha: string,
  ui: ExtensionContext['ui']
): Promise<boolean> {
  const safeSHA = validateSHA(sha);
  try {
    try { await run(`git -c core.hooksPath=${NULL_PATH} rebase --abort`, 10_000); } catch (e) { process.stderr.write(`[extensions] not rebasing: ${String(e)}\n`); }
    await run(`git -c core.hooksPath=${NULL_PATH} reset --hard ${safeSHA}`, 10_000);
    try { await run('pi update', PI_UPDATE_TIMEOUT); } catch (e) { process.stderr.write(`[extensions] best effort: ${String(e)}\n`); }
    log('info', 'Rollback successful', { sha: safeSHA });
    return true;
  } catch (err) {
    const msg = err instanceof Error ? err.message : String(err);
    ui.notify(`[AUTO-UPDATE] ❌ Rollback to ${safeSHA.slice(0, 8)} failed: ${msg}`, 'error');
    log('error', 'Rollback failed', { sha: safeSHA, error: msg });
    return false;
  }
}

// ---------------------------------------------------------------------------
// Layer 1 — AUTOMATIC rollback during git update
// ---------------------------------------------------------------------------

async function runUpdate(pi: ExtensionAPI, ctx: ExtensionContext): Promise<UpdateResult> {
  return withUpdateLock(async () => {
    const ui = ctx.ui;
    let gitPkgResults: GitPackageResult[] = [];

    let savedHead = '';
    try {
      const { stdout } = await run('git rev-parse HEAD', 5_000);
      savedHead = stdout;
    } catch (e) {
      process.stderr.write(`[auto-update.ts] operation failed: ${String(e)}\n`);
      ui.notify('[AUTO-UPDATE] ⚠️ Cannot determine current HEAD — aborting update', 'warning');
      return { success: false, error: 'Cannot determine HEAD' };
    }

    let newOriginHead = '';
    try {
      const { stdout } = await run('git rev-parse origin/main', 5_000);
      newOriginHead = stdout;
    } catch (e) { process.stderr.write(`[extensions] best effort: ${String(e)}\n`); }

    // Write pendingUpdate BEFORE pull (watchdog key)
    const stateBefore = readState();
    writeStateAtomic({
      ...stateBefore,
      lastKnownGood:    stateBefore.lastKnownGood || savedHead,
      pendingUpdate:    newOriginHead || null,
      updateTimestamp:  Date.now(),
      pendingValidated: false,
    });

    log('info', 'Starting git update', { savedHead, newOriginHead });

    // Step 1: Pull
    ui.setWorkingMessage('[AUTO-UPDATE] Pulling latest changes…');
    try {
      await run(`git -c core.hooksPath=${NULL_PATH} pull --rebase origin main`, PULL_TIMEOUT);
    } catch (err) {
      ui.setWorkingMessage('[AUTO-UPDATE] Pull failed — rolling back…');
      const rolled = await rollbackTo(savedHead, ui);
      writeStateAtomic({ ...readState(), pendingUpdate: null, updateTimestamp: null });
      ui.setWorkingMessage(undefined);
      if (rolled) {
        ui.notify('[AUTO-UPDATE] ⬅️ Rolled back after pull failure. Previous version restored.', 'warning');
        return { success: false, rolledBack: true };
      }
      const msg = err instanceof Error ? err.message : String(err);
      ui.notify(`[AUTO-UPDATE] ❌ Pull failed and rollback failed. Run: cd ~/helios-agent && git rebase --abort && git reset --hard ${savedHead}`, 'error');
      return { success: false, rolledBack: false, error: msg };
    }

    // Step 1b: Update git-based packages
    ui.setWorkingMessage('[AUTO-UPDATE] Updating git packages…');
    gitPkgResults = await updateGitPackages();

    // Step 2: pi update
    ui.setWorkingMessage('[AUTO-UPDATE] Updating packages…');
    try {
      await run('pi update', PI_UPDATE_TIMEOUT);
    } catch (err) {
      ui.setWorkingMessage('[AUTO-UPDATE] Package update failed — rolling back…');
      const rolled = await rollbackTo(savedHead, ui);
      writeStateAtomic({ ...readState(), pendingUpdate: null, updateTimestamp: null });
      ui.setWorkingMessage(undefined);
      if (rolled) {
        ui.notify('[AUTO-UPDATE] ⬅️ Rolled back after pi update failure. Previous version restored.', 'warning');
        return { success: false, rolledBack: true };
      }
      const msg = err instanceof Error ? err.message : String(err);
      ui.notify(`[AUTO-UPDATE] ❌ pi update failed and rollback failed. Run: cd ~/helios-agent && git reset --hard ${savedHead}`, 'error');
      return { success: false, rolledBack: false, error: msg };
    }

    // Step 3: Verify installation
    ui.setWorkingMessage('[AUTO-UPDATE] Verifying update…');
    const verification = verifyInstallation(AGENT_DIR);
    if (!verification.ok) {
      ui.setWorkingMessage('[AUTO-UPDATE] Verification failed — rolling back…');
      const rolled = await rollbackTo(savedHead, ui);
      writeStateAtomic({ ...readState(), pendingUpdate: null, updateTimestamp: null });
      ui.setWorkingMessage(undefined);
      if (rolled) {
        ui.notify(`[AUTO-UPDATE] ⬅️ Rolled back — ${verification.reason}`, 'warning');
        return { success: false, rolledBack: true };
      }
      ui.notify(`[AUTO-UPDATE] ❌ Verification failed and rollback failed. Run: cd ~/helios-agent && git reset --hard ${savedHead}`, 'error');
      return { success: false, rolledBack: false, error: verification.reason };
    }

    // Step 3b: Extension registry validation
    const registryPath = join(AGENT_DIR, 'extension-registry.json');
    if (registryPath.length > 4096) {
      throw new Error(`Registry path exceeds maximum length (4096): ${registryPath.length} chars`);
    }
    if (existsSync(registryPath)) {
      try {
        await run(`bash ${join(AGENT_DIR, 'scripts/validate-extension-manifest.sh')} --quiet`, 10_000);
      } catch (err) {
        ui.setWorkingMessage('[AUTO-UPDATE] Registry validation failed — rolling back…');
        const rolled = await rollbackTo(savedHead, ui);
        writeStateAtomic({ ...readState(), pendingUpdate: null, updateTimestamp: null });
        ui.setWorkingMessage(undefined);
        if (rolled) {
          ui.notify('[AUTO-UPDATE] ⬅️ Rolled back — extension registry validation failed after update.', 'warning');
          return { success: false, rolledBack: true };
        }
        const msg = err instanceof Error ? err.message : String(err);
        ui.notify(`[AUTO-UPDATE] ❌ Registry validation failed and rollback failed. Run: cd ~/helios-agent && git reset --hard ${savedHead}`, 'error');
        return { success: false, rolledBack: false, error: msg };
      }
    }

    // Success: promote
    let newHead = newOriginHead;
    if (!newHead) {
      try {
        const { stdout } = await run('git rev-parse HEAD', 5_000);
        newHead = stdout;
      } catch (e) { process.stderr.write(`[auto-update.ts] operation failed: ${String(e)}\n`); newHead = savedHead; }
    }

    writeStateAtomic({
      ...readState(),
      lastKnownGood:   newHead,
      pendingUpdate:   null,
      updateTimestamp:  null,
    });

    recordCheck(pi);

    ui.setWorkingMessage(undefined);
    ui.notify('[AUTO-UPDATE] ✅ Elyntic updated! Restart your session for changes to take effect.', 'info');
    log('info', 'Git update successful', { newHead });

    // Report git package results
    if (gitPkgResults.length > 0) {
      const updated = gitPkgResults.filter(r => r.success).map(r => r.package);
      const failed  = gitPkgResults.filter(r => !r.success && !r.skipped).map(r => r.package);
      if (updated.length > 0) {
        ui.notify(`[AUTO-UPDATE] 📦 Git packages updated (${updated.length}): ${updated.join(', ')}`, 'info');
      }
      if (failed.length > 0) {
        ui.notify(`[AUTO-UPDATE] ⚠️ Git package pull failed: ${failed.join(', ')}`, 'warning');
      }
    }

    return { success: true };
  });
}

// ---------------------------------------------------------------------------
// Layer 2 — WATCHDOG: crash detection on session_start (H2: tarball support)
// ---------------------------------------------------------------------------

async function watchdog(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
  const ui  = ctx.ui;
  const now = Date.now();
  const state = readState();
  const rollbackDir = AGENT_DIR + '.rollback';

  // H2: Tarball rollback path — check for .rollback dir (no .git required)
  if (existsSync(rollbackDir) && !existsSync(join(AGENT_DIR, '.git'))) {
    // Tarball user — if there's a .rollback dir and we detect instability, swap back
    if (state.pendingUpdate && state.pendingValidated) {
      ui.notify('[AUTO-UPDATE] ⬅️ Previous tarball update may have caused instability — rolling back…', 'warning');
      log('info', 'Watchdog: tarball rollback triggered');
      try {
        const stagingDir = AGENT_DIR + '.staging';
        try { await run(`rm -rf "${stagingDir}"`, 10_000); } catch (e) { process.stderr.write(`[extensions] fail-open: graceful degradation: ${String(e)}\n`); }
        renameSync(AGENT_DIR, stagingDir);
        renameSync(rollbackDir, AGENT_DIR);
        try { await run(`rm -rf "${stagingDir}"`, 10_000); } catch (e) { process.stderr.write(`[extensions] fail-open: graceful degradation: ${String(e)}\n`); }

        const entry: RollbackEntry = {
          from:      state.pendingUpdate || 'unknown',
          to:        'rollback-dir',
          reason:    'watchdog-tarball',
          timestamp: now,
        };
        writeStateAtomic({
          ...readState(),
          pendingUpdate:    null,
          updateTimestamp:  null,
          pendingValidated: false,
          rollbackHistory:  [...state.rollbackHistory, entry].slice(-50),
        });
        ui.notify('[AUTO-UPDATE] ⬅️ Rolled back to previous version. Restart your session.', 'info');
        log('info', 'Watchdog: tarball rollback successful');
      } catch (e) {
        const msg = e instanceof Error ? e.message : String(e);
        ui.notify(`[AUTO-UPDATE] ❌ Tarball rollback failed: ${msg}`, 'error');
        log('error', 'Watchdog: tarball rollback failed', { error: msg });
      }
      return;
    }
  }

  if (!state.pendingUpdate) return;

  if (!state.pendingValidated) {
    // First restart after update — mark as validated, wait for next session
    writeStateAtomic({ ...state, pendingValidated: true });
    return;
  }

  // Git path: pendingValidated = true means second restart before promotion — crash detected
  const target = state.lastKnownGood;
  if (!target) {
    ui.notify(
      '[AUTO-UPDATE] ⚠️ Previous update may have caused instability, but no lastKnownGood to restore. ' +
      'Run: cd ~/helios-agent && git log --oneline -5 to identify a safe SHA, then /rollback',
      'warning'
    );
    writeStateAtomic({ ...state, pendingUpdate: null, updateTimestamp: null, pendingValidated: false });
    return;
  }

  ui.notify('[AUTO-UPDATE] ⬅️ Previous update may have caused instability — rolling back automatically…', 'warning');
  log('info', 'Watchdog: git rollback triggered', { target });

  const rolled = await rollbackTo(target, ui);
  const entry: RollbackEntry = {
    from:      state.pendingUpdate,
    to:        target,
    reason:    'watchdog',
    timestamp: now,
  };

  writeStateAtomic({
    ...state,
    pendingUpdate:    null,
    updateTimestamp:  null,
    pendingValidated: false,
    rollbackHistory:  [...state.rollbackHistory, entry].slice(-50),
  });

  if (rolled) {
    ui.notify(`[AUTO-UPDATE] ⬅️ Rolled back to ${target.slice(0, 8)}. Restart your session.`, 'info');
  } else {
    ui.notify(
      `[AUTO-UPDATE] ❌ Watchdog rollback failed. Manual recovery: cd ~/helios-agent && git reset --hard ${target}`,
      'error'
    );
  }
}

// ---------------------------------------------------------------------------
// Independent npm package check — notification only, no auto-install (C3)
// ---------------------------------------------------------------------------

async function checkAndUpdateNpmPackage(
  pi: ExtensionAPI,
  ctx: ExtensionContext
): Promise<void> {
  const ui = ctx.ui;
  const state = readState();

  if (state.locked) return;
  if (state.lastNpmCheck && Date.now() - state.lastNpmCheck < NPM_COOLDOWN_MS) return;

  try {
    // Resolve the CLI from this package's declared npm alias. The alias is the
    // installation authority; its registry target is the update-check source.
    let cliPackage;
    try {
      cliPackage = resolveDeclaredCliPackage(AGENT_DIR);
    } catch { return; }

    // Write cooldown after local version confirmed
    writeStateAtomic({ ...readState(), lastNpmCheck: Date.now() });

    // Get latest from npm
    let latestVersion = '';
    try {
      const { stdout } = await run(`npm view ${cliPackage.registryName} version`, FETCH_TIMEOUT);
      const match = stdout.match(/(\d+\.\d+\.\d+(?:[-.][a-zA-Z0-9.]+)?)/);
      latestVersion = match ? match[1] : '';
      if (!latestVersion) return;
    } catch (e) { process.stderr.write(`[auto-update.ts] operation failed: ${String(e)}\n`); return; }

    if (compareVersions(cliPackage.installedVersion, latestVersion) >= 0) return;

    if (!isQuietStartup()) {
      ui.notify(
        `[AUTO-UPDATE] ⬆ Elyntic update available (Pi CLI v${cliPackage.installedVersion} → v${latestVersion}). Run: elyntic update`,
        'info'
      );
    }
    log('info', 'npm package update available', {
      package: cliPackage.registryName,
      installedVersion: cliPackage.installedVersion,
      latestVersion,
    });
  } catch (e) { process.stderr.write(`[extensions] never crash Pi: ${String(e)}\n`); }
}

// ---------------------------------------------------------------------------
// Git-based package update — with branch sanitization (C2)
// ---------------------------------------------------------------------------

async function updateGitPackages(): Promise<GitPackageResult[]> {
  const settingsPath = join(AGENT_DIR, 'settings.json');
  const gitBaseDir   = resolve(AGENT_DIR, 'git');
  const results: GitPackageResult[] = [];

  let packages: unknown[] = [];
  try {
    const settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
    packages = Array.isArray(settings.packages) ? settings.packages : [];
  } catch (e) {
    process.stderr.write(`[auto-update.ts] operation failed: ${String(e)}\n`);
    return results;
  }

  for (const pkg of packages) {
    let source = '';
    if (typeof pkg === 'string') {
      source = pkg;
    } else if (pkg && typeof pkg === 'object' && typeof (pkg as Record<string, unknown>).source === 'string') {
      source = (pkg as Record<string, string>).source;
    }

    // Support both legacy git: (clone) and git/ (local bundled) prefixes
    let packagePath = '';
    if (source.startsWith('git:')) {
      packagePath = source.slice('git:'.length);
    } else if (source.startsWith('git/')) {
      packagePath = source.slice('git/'.length);
    } else {
      continue;
    }

    const clonePath   = join(gitBaseDir, packagePath);
    const shortName   = packagePath.split('/').pop() ?? packagePath;

    if (!existsSync(clonePath)) {
      results.push({ package: shortName, success: false, skipped: true, error: 'Clone directory not found' });
      continue;
    }

    // Tarball-bundled packages have no .git — skip git pull (they update via tarball)
    if (!existsSync(join(clonePath, '.git'))) {
      results.push({ package: shortName, success: true, skipped: true });
      continue;
    }

    try {
      let branch = 'main';
      try {
        const { stdout: symbolicRef } = await run(
          `git -c core.hooksPath=${NULL_PATH} -C "${clonePath}" symbolic-ref refs/remotes/origin/HEAD`,
          5_000
        );
        const match = symbolicRef.match(/refs\/remotes\/origin\/(.+)/);
        if (match?.[1]) branch = sanitizeBranchName(match[1]);
      } catch (e) { process.stderr.write(`[extensions] symbolic-ref not set — keep default: ${String(e)}\n`); }

      await run(`git -c core.hooksPath=${NULL_PATH} -C "${clonePath}" fetch origin`, FETCH_TIMEOUT);
      try {
        await run(`git -c core.hooksPath=${NULL_PATH} -C "${clonePath}" pull origin ${branch} --ff-only`, PULL_TIMEOUT);
      } catch (pullErr) {
        if (branch !== 'master') {
          await run(`git -c core.hooksPath=${NULL_PATH} -C "${clonePath}" pull origin master --ff-only`, PULL_TIMEOUT);
        } else {
          throw pullErr;
        }
      }
      results.push({ package: shortName, success: true });
    } catch (err) {
      const msg = err instanceof Error ? err.message : String(err);
      results.push({ package: shortName, success: false, error: msg });
    }
  }

  return results;
}

// ---------------------------------------------------------------------------
// Core update flow (shared by session_start and /update)
// ---------------------------------------------------------------------------

async function triggerUpdate(
  pi: ExtensionAPI,
  ctx: ExtensionContext,
  force: boolean,
  preflightWarned: { value: boolean }
): Promise<boolean> {
  const state = readState();
  if (state.locked) {
    if (force) ctx.ui.notify('[AUTO-UPDATE] 🔒 Updates are locked. Run /update --unlock to re-enable.', 'info');
    return false;
  }

  if (!force) {
    const lastCheck = getLastCheckTime(ctx);
    if (Date.now() - lastCheck < COOLDOWN_MS) return false;
  }

  const check = await preflight(ctx);
  // If preflight returned ok:true from tarballUpdate, we're done
  if (check.ok && !existsSync(join(AGENT_DIR, '.git'))) {
    return false;
  }
  if (!check.ok) {
    if (force) {
      ctx.ui.notify(check.reason ?? '[AUTO-UPDATE] Pre-flight check failed', 'warning');
    } else if (!preflightWarned.value) {
      preflightWarned.value = true;
      const reason = check.reason ?? '';
      if (reason.includes('branch')) {
        const branchMatch = reason.match(/On branch '([^']+)'/);
        const branch = branchMatch?.[1] ?? 'unknown';
        ctx.ui?.notify?.(`[AUTO-UPDATE] Auto-update skipped: on branch '${branch}', not main. Switch with: git checkout main`, 'warn');
      } else if (reason.includes('Uncommitted')) {
        ctx.ui?.notify?.('[AUTO-UPDATE] Auto-update skipped: uncommitted changes in ~/helios-agent', 'warn');
      } else if (reason.includes('Extraction failed') || reason.includes('Download') ||
                 reason.includes('disk space') || reason.includes('truncated') ||
                 reason.includes('Checksum') || reason.includes('network') ||
                 reason.includes('swap failed') || reason.includes('verification failed')) {
        ctx.ui?.notify?.(`[AUTO-UPDATE] ${reason}`, 'warning');
      }
    }
    return false;
  }

  const result = await checkForUpdates();
  ctx.ui.setWorkingMessage(undefined);

  recordCheck(pi);

  if (!result) {
    if (force) ctx.ui.notify('[AUTO-UPDATE] Unable to check for updates (offline or no remote?)', 'warning');
    return false;
  }
  if (result.behind === 0 || result.commits.length === 0) {
    if (force) ctx.ui.notify('[AUTO-UPDATE] ✅ Already up to date!', 'info');
    return false;
  }

  const list = result.commits.slice(0, 8).map(c => `  • ${c}`).join('\n');
  const more = result.behind > 8 ? `\n  … and ${result.behind - 8} more` : '';
  const msg =
    `${result.behind} update${result.behind === 1 ? '' : 's'} available:\n\n` +
    list + more +
    '\n\nAuto-rollback enabled on failure (3-layer protection).';

  let accepted = false;
  try {
    accepted = await ctx.ui.confirm('🔄 Elyntic Update', msg, { timeout: CONFIRM_TIMEOUT });
  } catch (e) {
    process.stderr.write(`[auto-update.ts] operation failed: ${String(e)}\n`);
    return false;
  }

  if (!accepted) {
    if (force) ctx.ui.notify('[AUTO-UPDATE] Update cancelled. Run /update anytime.', 'info');
    return false;
  }

  const outcome = await runUpdate(pi, ctx);
  if (outcome.rolledBack) {
    ctx.ui.notify('[AUTO-UPDATE] ℹ️ Previous stable version has been restored.', 'info');
  }
  return true;
}

// ---------------------------------------------------------------------------
// Extension entry point (M1: preflightWarned in closure)
// ---------------------------------------------------------------------------

export default function autoUpdateExtension(pi: ExtensionAPI): void {
  // M1: preflightWarned scoped to this closure, not module-level
  const preflightWarned = { value: false };

  // ELYNTIC_SKIP_AUTO_UPDATE=1: disable all automatic update checks (set in .env for eval/dev).
  // Manual /update command still works because it calls triggerUpdate(force=true).
  const autoCheckEnabled = process.env.ELYNTIC_SKIP_AUTO_UPDATE !== '1';

  // session_start: watchdog → npm check → triggerUpdate (5s delay)
  pi.on('session_start', async (_event, ctx) => {
    preflightWarned.value = false;
    setTimeout(async () => {
      try {
        await watchdog(pi, ctx);
      } catch (e) { process.stderr.write(`[extensions] never crash Pi: ${String(e)}\n`); }

      if (!autoCheckEnabled) return; // skip git fetch + curl update checks

      try {
        await checkAndUpdateNpmPackage(pi, ctx);
      } catch (e) { process.stderr.write(`[extensions] never crash Pi: ${String(e)}\n`); }

      try {
        await triggerUpdate(pi, ctx, false, preflightWarned);
      } catch (e) { process.stderr.write(`[extensions] never crash Pi: ${String(e)}\n`); }
    }, 5_000);
  });

  // session_shutdown: promote pendingUpdate → lastKnownGood on clean exit
  pi.on('session_shutdown', async (_event, _ctx) => {
    try {
      const state = readState();
      if (state.pendingUpdate) {
        writeStateAtomic({
          ...state,
          lastKnownGood:    state.pendingUpdate,
          pendingUpdate:    null,
          updateTimestamp:  null,
          pendingValidated: false,
        });
      }
    } catch (e) { process.stderr.write(`[extensions] never crash Pi: ${String(e)}\n`); }
  });

  // /update [--lock | --unlock]
  pi.registerCommand('update', {
    description: 'Check for Elyntic updates. --lock / --unlock to pin versions.',
    async handler(args: string, ctx) {
      const trimmed = args.trim().toLowerCase();

      if (trimmed === '--lock') {
        const s = readState();
        writeStateAtomic({ ...s, locked: true });
        ctx.ui.notify('[AUTO-UPDATE] 🔒 Updates locked. Elyntic will not auto-update until /update --unlock.', 'info');
        return;
      }

      if (trimmed === '--unlock') {
        const s = readState();
        writeStateAtomic({ ...s, locked: false });
        ctx.ui.notify('[AUTO-UPDATE] 🔓 Updates unlocked. Auto-update checks will resume.', 'info');
        return;
      }

      let ranFullUpdate = false;
      try {
        ranFullUpdate = await triggerUpdate(pi, ctx as unknown as ExtensionContext, true, preflightWarned);
      } catch (err) {
        const msg = err instanceof Error ? err.message : String(err);
        ctx.ui.notify(`[AUTO-UPDATE] /update error: ${msg}`, 'warning');
      }

      if (!ranFullUpdate) {
        try {
          const state = readState();
          if (!state.locked) {
            ctx.ui.setWorkingMessage('[AUTO-UPDATE] Pulling git packages…');
            const gitResults = await updateGitPackages();
            ctx.ui.setWorkingMessage(undefined);
            if (gitResults.length > 0) {
              const updated = gitResults.filter(r => r.success).map(r => r.package);
              const failed  = gitResults.filter(r => !r.success && !r.skipped).map(r => r.package);
              if (updated.length > 0) {
                ctx.ui.notify(`[AUTO-UPDATE] 📦 Git packages updated (${updated.length}): ${updated.join(', ')}`, 'info');
              }
              if (failed.length > 0) {
                ctx.ui.notify(`[AUTO-UPDATE] ⚠️ Git package pull failed: ${failed.join(', ')}`, 'warning');
              }
            }
          }
        } catch (e) { process.stderr.write(`[extensions] never crash Pi: ${String(e)}\n`); }
      }
    },
  });

  // /rollback [list]
  pi.registerCommand('rollback', {
    description: 'Rollback to last known-good SHA. "list" shows rollback history.',
    async handler(args: string, ctx) {
      const trimmed = args.trim().toLowerCase();

      if (trimmed === 'list') {
        const state = readState();
        if (state.rollbackHistory.length === 0) {
          ctx.ui.notify('[AUTO-UPDATE] No rollback history.', 'info');
          return;
        }
        const lines = state.rollbackHistory
          .slice()
          .reverse()
          .slice(0, 10)
          .map((r, i) => {
            const date = new Date(r.timestamp).toLocaleString();
            return `  ${i + 1}. ${r.from.slice(0, 8)} → ${r.to.slice(0, 8)}  (${r.reason})  ${date}`;
          })
          .join('\n');
        ctx.ui.notify(`[AUTO-UPDATE] Rollback history (most recent first):\n${lines}`, 'info');
        return;
      }

      const state = readState();
      const target = state.lastKnownGood;
      if (!target) {
        ctx.ui.notify('[AUTO-UPDATE] No lastKnownGood SHA recorded yet. Cannot rollback.', 'warning');
        return;
      }

      let currentHead = '';
      try {
        const { stdout } = await run('git rev-parse HEAD', 5_000);
        currentHead = stdout;
      } catch (e) { process.stderr.write(`[extensions] best effort: ${String(e)}\n`); }

      ctx.ui.setWorkingMessage(`[AUTO-UPDATE] Rolling back to ${target.slice(0, 8)}…`);
      const rolled = await rollbackTo(target, ctx.ui as ExtensionContext['ui']);
      ctx.ui.setWorkingMessage(undefined);

      if (rolled) {
        const entry: RollbackEntry = {
          from:      currentHead,
          to:        target,
          reason:    'manual',
          timestamp: Date.now(),
        };
        const finalState = readState();
        writeStateAtomic({
          ...finalState,
          rollbackHistory: [...finalState.rollbackHistory, entry].slice(-50),
        });
        ctx.ui.notify(`[AUTO-UPDATE] ⬅️ Rolled back to ${target.slice(0, 8)}. Restart your session.`, 'info');
      }
    },
  });
}
