/**
 * RHA Provider Vault — background service worker
 *
 * Security model (exposed to user):
 *  1. Vault data encrypted at rest: AES-256-GCM
 *  2. Encryption key derived from user password: PBKDF2, 100 000 iterations, SHA-256
 *  3. Derived AES key is kept in SW memory AND mirrored to chrome.storage.session
 *     (memory-only; never disk; cleared when the browser closes). This survives
 *     MV3 service-worker restarts (~30s idle) so Save after form-fill still works.
 *     Explicit lock / idle alarm / browser restart clear it — password required again.
 *  4. API keys never sent to any external server; only to localhost or the
 *     provider's own endpoint (Claude → api.anthropic.com, etc.)
 *  5. Only origins listed in externally_connectable may send messages here
 *  6. Auto-lock after configurable idle (default 10 min) or on popup view-loss
 *  7. Request audit log (last 50 entries) stored alongside vault (plaintext
 *     metadata only — no keys, no prompt content)
 */

// NOTE: the service worker deliberately imports NOTHING heavy. Tier 1 jobs
// (pipeline + suite execution) run in the offscreen document, which lazily
// imports tier1.bundle.js and embeds the eval-sandbox page. A bad bundle can
// break Tier 1, but it can never take the vault down with it again.

const SESSION_KEY_STORE = 'vaultSessionAesRaw';

// ── Crypto helpers ────────────────────────────────────────────────────────────

async function deriveKey(password, salt) {
  const raw = await crypto.subtle.importKey(
    'raw', new TextEncoder().encode(password), 'PBKDF2', false, ['deriveKey']
  );
  // extractable: true — required so we can exportKey into chrome.storage.session
  return crypto.subtle.deriveKey(
    { name: 'PBKDF2', salt: new Uint8Array(salt), iterations: 100_000, hash: 'SHA-256' },
    raw,
    { name: 'AES-GCM', length: 256 },
    true,
    ['encrypt', 'decrypt']
  );
}

async function encryptVault(key, data) {
  const iv = crypto.getRandomValues(new Uint8Array(12));
  const ct = await crypto.subtle.encrypt(
    { name: 'AES-GCM', iv },
    key,
    new TextEncoder().encode(JSON.stringify(data))
  );
  return { iv: Array.from(iv), ct: Array.from(new Uint8Array(ct)) };
}

async function decryptVault(key, { iv, ct }) {
  const plain = await crypto.subtle.decrypt(
    { name: 'AES-GCM', iv: new Uint8Array(iv) },
    key,
    new Uint8Array(ct)
  );
  return JSON.parse(new TextDecoder().decode(plain));
}

async function sha256HexStr(s) {
  const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(s));
  return [...new Uint8Array(buf)].map(b => b.toString(16).padStart(2, '0')).join('');
}

/** SET 10 — install identity (resets on delete+reinstall). */
async function ensureInstallHash() {
  const { installHash } = await chrome.storage.local.get('installHash');
  if (installHash && /^[0-9a-f]{16}$/.test(installHash)) return installHash;
  const hex = (await sha256HexStr(crypto.randomUUID())).slice(0, 16);
  await chrome.storage.local.set({ installHash: hex });
  return hex;
}

void ensureInstallHash();

// ── SET 9 — OpenRouter pricing feed (public, cached) ─────────────────────────

const PRICING_URL = 'https://openrouter.ai/api/v1/models';
const PRICING_TTL_MS = 24 * 60 * 60 * 1000;
const PRICING_STALE_MS = 7 * 24 * 60 * 60 * 1000;

function reducePricingModels(data) {
  const table = {};
  for (const m of data || []) {
    if (!m?.id || !m.pricing) continue;
    const prompt = Number(m.pricing.prompt);
    const completion = Number(m.pricing.completion);
    if (!Number.isFinite(prompt) || !Number.isFinite(completion)) continue;
    table[m.id] = {
      promptUsdPerTok: prompt,
      completionUsdPerTok: completion,
      ...(Number.isFinite(m.created) ? { created: m.created } : {}),
    };
  }
  return table;
}

async function getPricingTable() {
  const { pricingCache } = await chrome.storage.local.get('pricingCache');
  const now = Date.now();
  if (pricingCache?.table && pricingCache.fetchedAt) {
    const age = now - new Date(pricingCache.fetchedAt).getTime();
    if (age >= 0 && age < PRICING_TTL_MS) {
      return { table: pricingCache.table, fetchedAt: pricingCache.fetchedAt };
    }
  }
  try {
    const res = await fetch(PRICING_URL);
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    const json = await res.json();
    const table = reducePricingModels(json.data);
    const fetchedAt = new Date().toISOString();
    await chrome.storage.local.set({ pricingCache: { table, fetchedAt } });
    return { table, fetchedAt };
  } catch {
    if (pricingCache?.table && pricingCache.fetchedAt) {
      const age = now - new Date(pricingCache.fetchedAt).getTime();
      if (age >= 0 && age < PRICING_STALE_MS) {
        return { table: pricingCache.table, fetchedAt: pricingCache.fetchedAt, stale: true };
      }
    }
    return { unavailable: true };
  }
}

// ── SET 10 — local stats (lifetime totals survive 20-run ring eviction) ───────

// SET 19 — expanded totals (keep lockstep with src/tier1/statsAggregate.ts).
function emptyStatsTotals() {
  return {
    runs: { total: 0, successful: 0, partial: 0, failed: 0, cancelled: 0 },
    byMode: { rha: 0, vanilla: 0, referee: 0 },
    tokens: {
      inSum: 0, outSum: 0, totalSum: 0,
      inMax: 0, outMax: 0, totalMax: 0,
      inMin: null, outMin: null, totalMin: null,
      runsWithTokens: 0,
    },
    perCall: {
      avgInSum: 0, avgOutSum: 0,
      maxInMax: 0, maxOutMax: 0,
      maxInMin: null, maxOutMin: null,
      avgInMin: null, avgOutMin: null,
      avgInMax: 0, avgOutMax: 0,
      runsWith: 0,
    },
    cycles: { sum: 0, max: 0, min: null, runsWith: 0 },
    irGen: { sum: 0, max: 0, min: null, runsWith: 0 },
    substrate: {
      fixes: 0, attempts: 0, refusals: 0, noHandler: 0,
      mechanical: 0, model: 0, templated: 0,
      sbDraws: 0, sbWins: 0,
    },
    perm: { batches: 0, candidates: 0, improved: 0 },
    surgeries: { irCase: 0, crash: 0 },
    duration: { sumMs: 0, maxMs: 0, minMs: null, runsWith: 0 },
    // Legacy flat fields (pre-SET-19) — still updated so old readers don't break.
    mechanicalFixes: 0,
    modelRepairs: 0,
    templatedCases: 0,
    costSum: 0,
    costCount: 0,
    costMax: 0,
    costMin: null,
    cost: { sum: 0, count: 0, max: 0, min: null },
  };
}

function classifyRunOutcome(rec) {
  const err = (rec.error || '').toLowerCase();
  if (err.includes('cancelled')) return 'cancelled';
  if (rec.error) return 'failed';
  const n = rec.problems;
  if (n == null) return rec.result ? 'successful' : 'failed';
  if (n === 0) return 'successful';
  return 'partial';
}

function statsNum(v) {
  const n = Number(v);
  return Number.isFinite(n) ? n : 0;
}

function statsBumpMin(cur, v) {
  return cur == null ? v : Math.min(cur, v);
}

function statsCycles(m) {
  const ppf = m.passesPerFeature;
  if (ppf && typeof ppf === 'object' && Object.keys(ppf).length > 0) {
    let sum = 0;
    for (const n of Object.values(ppf)) sum += statsNum(n);
    return sum;
  }
  return statsNum(m.passesTotal);
}

/** Normalize legacy flat totals → nested SET 19 shape. */
function coerceStatsTotals(raw) {
  const t = emptyStatsTotals();
  if (!raw || typeof raw !== 'object') return t;
  if (raw.runs) Object.assign(t.runs, raw.runs);
  if (raw.byMode) Object.assign(t.byMode, raw.byMode);
  if (raw.tokens) Object.assign(t.tokens, raw.tokens);
  if (raw.perCall) Object.assign(t.perCall, raw.perCall);
  if (raw.cycles) {
    Object.assign(t.cycles, raw.cycles);
    if (raw.cycles.runsWithCycles != null && t.cycles.runsWith === 0) {
      t.cycles.runsWith = raw.cycles.runsWithCycles;
    }
  }
  if (raw.irGen) Object.assign(t.irGen, raw.irGen);
  if (raw.substrate) Object.assign(t.substrate, raw.substrate);
  if (raw.perm) Object.assign(t.perm, raw.perm);
  if (raw.surgeries) Object.assign(t.surgeries, raw.surgeries);
  if (raw.duration) Object.assign(t.duration, raw.duration);
  if (raw.cost && typeof raw.cost === 'object') Object.assign(t.cost, raw.cost);
  // Legacy flat fields
  if (typeof raw.mechanicalFixes === 'number') {
    t.mechanicalFixes = raw.mechanicalFixes;
    if (!raw.substrate) t.substrate.mechanical = raw.mechanicalFixes;
    if (!raw.substrate) t.substrate.fixes = raw.mechanicalFixes;
  }
  if (typeof raw.modelRepairs === 'number') {
    t.modelRepairs = raw.modelRepairs;
    if (!raw.substrate) t.substrate.model = raw.modelRepairs;
  }
  if (typeof raw.templatedCases === 'number') {
    t.templatedCases = raw.templatedCases;
    if (!raw.substrate) t.substrate.templated = raw.templatedCases;
  }
  if (typeof raw.costSum === 'number') {
    t.costSum = raw.costSum;
    if (!raw.cost || raw.cost.sum == null) t.cost.sum = raw.costSum;
  }
  if (typeof raw.costCount === 'number') {
    t.costCount = raw.costCount;
    if (!raw.cost || raw.cost.count == null) t.cost.count = raw.costCount;
  }
  if (typeof raw.costMax === 'number') {
    t.costMax = raw.costMax;
    if (!raw.cost || raw.cost.max == null) t.cost.max = raw.costMax;
  }
  if (raw.costMin != null) {
    t.costMin = raw.costMin;
    if (!raw.cost || raw.cost.min == null) t.cost.min = raw.costMin;
  }
  return t;
}

function contributeRunToTotals(totals, rec) {
  const t = coerceStatsTotals(totals);
  const outcome = classifyRunOutcome(rec);
  t.runs.total += 1;
  t.runs[outcome] = (t.runs[outcome] || 0) + 1;
  const mode = rec.mode || 'rha';
  t.byMode[mode] = (t.byMode[mode] || 0) + 1;
  const m = rec.metrics || {};
  const tin = statsNum(m.tokensIn);
  const tout = statsNum(m.tokensOut);
  const ttot = tin + tout;
  if (tin || tout || m.llmCalls) {
    const tok = t.tokens;
    tok.inSum += tin; tok.outSum += tout; tok.totalSum += ttot;
    tok.inMax = Math.max(tok.inMax, tin);
    tok.outMax = Math.max(tok.outMax, tout);
    tok.totalMax = Math.max(tok.totalMax, ttot);
    tok.inMin = statsBumpMin(tok.inMin, tin);
    tok.outMin = statsBumpMin(tok.outMin, tout);
    tok.totalMin = statsBumpMin(tok.totalMin, ttot);
    tok.runsWithTokens += 1;
  }
  const avgIn = m.avgIn != null && Number.isFinite(Number(m.avgIn)) ? Number(m.avgIn) : null;
  const avgOut = m.avgOut != null && Number.isFinite(Number(m.avgOut)) ? Number(m.avgOut) : null;
  const maxIn = m.maxIn != null && Number.isFinite(Number(m.maxIn)) ? Number(m.maxIn) : null;
  const maxOut = m.maxOut != null && Number.isFinite(Number(m.maxOut)) ? Number(m.maxOut) : null;
  if (avgIn != null || avgOut != null || maxIn != null || maxOut != null) {
    const pc = t.perCall;
    if (avgIn != null) {
      pc.avgInSum += avgIn;
      pc.avgInMin = statsBumpMin(pc.avgInMin, avgIn);
      pc.avgInMax = Math.max(pc.avgInMax, avgIn);
    }
    if (avgOut != null) {
      pc.avgOutSum += avgOut;
      pc.avgOutMin = statsBumpMin(pc.avgOutMin, avgOut);
      pc.avgOutMax = Math.max(pc.avgOutMax, avgOut);
    }
    if (maxIn != null) {
      pc.maxInMax = Math.max(pc.maxInMax, maxIn);
      pc.maxInMin = statsBumpMin(pc.maxInMin, maxIn);
    }
    if (maxOut != null) {
      pc.maxOutMax = Math.max(pc.maxOutMax, maxOut);
      pc.maxOutMin = statsBumpMin(pc.maxOutMin, maxOut);
    }
    pc.runsWith += 1;
  }
  const cycles = statsCycles(m);
  if (cycles || m.elapsedMs != null) {
    t.cycles.sum += cycles;
    t.cycles.max = Math.max(t.cycles.max, cycles);
    t.cycles.min = statsBumpMin(t.cycles.min, cycles);
    t.cycles.runsWith += 1;
  }
  const irGen = statsNum(m.irGenAttempts);
  if (irGen || m.irGenAttempts != null) {
    t.irGen.sum += irGen;
    t.irGen.max = Math.max(t.irGen.max, irGen);
    t.irGen.min = statsBumpMin(t.irGen.min, irGen);
    t.irGen.runsWith += 1;
  }
  const mech = statsNum(m.mechanicalFixes);
  t.substrate.mechanical += mech;
  t.substrate.model += statsNum(m.repairDraws);
  t.substrate.fixes += statsNum(m.substrateFixes) || mech;
  t.substrate.attempts += statsNum(m.substrateAttempts);
  t.substrate.refusals += statsNum(m.substrateRefusals);
  t.substrate.noHandler += statsNum(m.substrateNoHandler);
  t.substrate.templated += statsNum(m.templatedCases);
  t.substrate.sbDraws += statsNum(m.superbufferDraws);
  t.substrate.sbWins += statsNum(m.superbufferWins);
  t.mechanicalFixes += mech;
  t.modelRepairs += statsNum(m.repairDraws);
  t.templatedCases += statsNum(m.templatedCases);
  // SET 35e — never fold permutationDraws into batches (draws ≈ candidates).
  t.perm.batches += statsNum(m.permBatches);
  t.perm.candidates += statsNum(m.permCandidates != null ? m.permCandidates : m.permutationDraws);
  t.perm.improved += statsNum(m.permImproved ?? m.permutationWins);
  t.surgeries.irCase += statsNum(m.irSurgeries);
  t.surgeries.crash += statsNum(m.crashSurgeries);
  const elapsed = m.elapsedMs != null && Number.isFinite(Number(m.elapsedMs)) ? Number(m.elapsedMs) : null;
  if (elapsed != null) {
    t.duration.sumMs += elapsed;
    t.duration.maxMs = Math.max(t.duration.maxMs, elapsed);
    t.duration.minMs = statsBumpMin(t.duration.minMs, elapsed);
    t.duration.runsWith += 1;
  }
  const cost = rec.cost?.usdTotal;
  if (typeof cost === 'number' && Number.isFinite(cost)) {
    t.cost.sum += cost;
    t.cost.count += 1;
    t.cost.max = Math.max(t.cost.max, cost);
    t.cost.min = statsBumpMin(t.cost.min, cost);
    t.costSum += cost;
    t.costCount += 1;
    t.costMax = Math.max(t.costMax, cost);
    t.costMin = t.costMin == null ? cost : Math.min(t.costMin, cost);
  }
  // SET 57d — run-report counter buckets (records without SET 57 counters
  // simply do not contribute).
  if (REPORT_COUNTER_KEYS.some(k => m[k] != null)) {
    const rep = (t.report = t.report || {});
    const bump = (key, v) => {
      if (v == null || !Number.isFinite(v)) return;
      const b = rep[key] || (rep[key] = { sum: 0, max: 0, min: null, runsWith: 0 });
      b.sum += v;
      b.max = Math.max(b.max, v);
      b.min = statsBumpMin(b.min, v);
      b.runsWith += 1;
    };
    for (const k of REPORT_COUNTER_KEYS) {
      const v = m[k] != null && Number.isFinite(Number(m[k])) ? Number(m[k]) : null;
      bump(k, v);
    }
    const scores = reportScoresJs(m);
    bump('scoreIr', scores.scoreIr);
    bump('scoreBuild', scores.scoreBuild);
    bump('scoreRha', scores.scoreRha);
  }
  return t;
}

function subtractRunFromTotals(totals, rec) {
  // Re-persist of same id: reverse prior contribution before re-adding.
  const t = coerceStatsTotals(JSON.parse(JSON.stringify(totals || emptyStatsTotals())));
  const outcome = classifyRunOutcome(rec);
  t.runs.total = Math.max(0, t.runs.total - 1);
  t.runs[outcome] = Math.max(0, (t.runs[outcome] || 0) - 1);
  const mode = rec.mode || 'rha';
  t.byMode[mode] = Math.max(0, (t.byMode[mode] || 0) - 1);
  const m = rec.metrics || {};
  const tin = statsNum(m.tokensIn);
  const tout = statsNum(m.tokensOut);
  const ttot = tin + tout;
  if (tin || tout || m.llmCalls) {
    const tok = t.tokens;
    tok.inSum = Math.max(0, tok.inSum - tin);
    tok.outSum = Math.max(0, tok.outSum - tout);
    tok.totalSum = Math.max(0, tok.totalSum - ttot);
    tok.runsWithTokens = Math.max(0, tok.runsWithTokens - 1);
  }
  if (m.avgIn != null || m.avgOut != null || m.maxIn != null || m.maxOut != null) {
    const pc = t.perCall;
    if (m.avgIn != null) pc.avgInSum = Math.max(0, pc.avgInSum - statsNum(m.avgIn));
    if (m.avgOut != null) pc.avgOutSum = Math.max(0, pc.avgOutSum - statsNum(m.avgOut));
    pc.runsWith = Math.max(0, pc.runsWith - 1);
  }
  const cycles = statsCycles(m);
  if (cycles || m.elapsedMs != null) {
    t.cycles.sum = Math.max(0, t.cycles.sum - cycles);
    t.cycles.runsWith = Math.max(0, t.cycles.runsWith - 1);
  }
  const irGen = statsNum(m.irGenAttempts);
  if (irGen || m.irGenAttempts != null) {
    t.irGen.sum = Math.max(0, t.irGen.sum - irGen);
    t.irGen.runsWith = Math.max(0, t.irGen.runsWith - 1);
  }
  const mech = statsNum(m.mechanicalFixes);
  const model = statsNum(m.repairDraws);
  const templated = statsNum(m.templatedCases);
  t.substrate.mechanical = Math.max(0, t.substrate.mechanical - mech);
  t.substrate.model = Math.max(0, t.substrate.model - model);
  t.substrate.fixes = Math.max(0, t.substrate.fixes - (statsNum(m.substrateFixes) || mech));
  t.substrate.attempts = Math.max(0, t.substrate.attempts - statsNum(m.substrateAttempts));
  t.substrate.refusals = Math.max(0, t.substrate.refusals - statsNum(m.substrateRefusals));
  t.substrate.noHandler = Math.max(0, t.substrate.noHandler - statsNum(m.substrateNoHandler));
  t.substrate.templated = Math.max(0, t.substrate.templated - templated);
  t.substrate.sbDraws = Math.max(0, t.substrate.sbDraws - statsNum(m.superbufferDraws));
  t.substrate.sbWins = Math.max(0, t.substrate.sbWins - statsNum(m.superbufferWins));
  t.mechanicalFixes = Math.max(0, t.mechanicalFixes - mech);
  t.modelRepairs = Math.max(0, t.modelRepairs - model);
  t.templatedCases = Math.max(0, t.templatedCases - templated);
  t.perm.batches = Math.max(0, t.perm.batches - statsNum(m.permBatches));
  t.perm.candidates = Math.max(0, t.perm.candidates - statsNum(
    m.permCandidates != null ? m.permCandidates : m.permutationDraws,
  ));
  t.perm.improved = Math.max(0, t.perm.improved - statsNum(m.permImproved ?? m.permutationWins));
  t.surgeries.irCase = Math.max(0, t.surgeries.irCase - statsNum(m.irSurgeries));
  t.surgeries.crash = Math.max(0, t.surgeries.crash - statsNum(m.crashSurgeries));
  const elapsed = m.elapsedMs != null && Number.isFinite(Number(m.elapsedMs)) ? Number(m.elapsedMs) : null;
  if (elapsed != null) {
    t.duration.sumMs = Math.max(0, t.duration.sumMs - elapsed);
    t.duration.runsWith = Math.max(0, t.duration.runsWith - 1);
  }
  const cost = rec.cost?.usdTotal;
  if (typeof cost === 'number' && Number.isFinite(cost)) {
    t.cost.sum = Math.max(0, t.cost.sum - cost);
    t.cost.count = Math.max(0, t.cost.count - 1);
    t.costSum = Math.max(0, t.costSum - cost);
    t.costCount = Math.max(0, t.costCount - 1);
  }
  return t;
}

// ── SET 31d — cross-run recurrence scan (clade-candidate proposals) ─────────
// Mirrors src/tier1/clades.ts::scanRecurringFailures / superbuffer.ts::
// draftCladePromotion — kept as a hand-written JS port, NOT an import, per the
// standing rule that the service worker imports nothing heavy (a bad
// tier1.bundle.js must never take the vault down with it). Keep these two in
// sync; tests/tier1-set31d.test.ts pins the TS source of truth's behavior.

const CLADE_MODEL_SCHEMA_FAMILY = 'model-schema:*Model';
const CLADE_MODEL_NAME_RE = /^[A-Z][A-Za-z0-9_]*Model$/;
const CLADE_DROPPED_CASE_RE = /case "([^"]+)"\s+(?:dropped|unfilled)/;

function cladeExtractDroppedCaseNames(problems) {
  if (!Array.isArray(problems) || !problems.length) return [];
  const out = [];
  for (const p of problems) {
    const m = CLADE_DROPPED_CASE_RE.exec(String(p ?? ''));
    if (m) out.push(m[1]);
  }
  return out;
}

function cladeCaseFamily(name) {
  if (CLADE_MODEL_NAME_RE.test(name)) return CLADE_MODEL_SCHEMA_FAMILY;
  const norm = String(name).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
  return norm || 'freeform';
}

function cladeNameTokenOverlap(a, b) {
  const tok = s => new Set(String(s).toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim().split(/\s+/).filter(Boolean));
  const A = tok(a); const B = tok(b);
  if (!A.size || !B.size) return 0;
  let inter = 0;
  for (const t of A) if (B.has(t)) inter++;
  return inter / (A.size + B.size - inter);
}

function cladeSamePromptLineage(a, b) {
  if (!a || !b) return false;
  if (a === b) return true;
  return cladeNameTokenOverlap(a, b) >= 0.5;
}

function cladeGroupLineages(records) {
  const byProject = new Map();
  const loose = [];
  for (const r of records) {
    if (r.projectId) {
      const arr = byProject.get(r.projectId) || [];
      arr.push(r);
      byProject.set(r.projectId, arr);
    } else {
      loose.push(r);
    }
  }
  const clusters = [];
  for (const r of loose) {
    const home = clusters.find(c => cladeSamePromptLineage(c[0].prompt, r.prompt));
    if (home) home.push(r);
    else clusters.push([r]);
  }
  const groups = [...byProject.values(), ...clusters];
  for (const g of groups) g.sort((a, b) => String(a.ts || '').localeCompare(String(b.ts || '')));
  return groups;
}

function cladeDraftPromotion(opts) {
  const family = String(opts.patternKey).replace(/^(family|class|case):/, '').slice(0, 48) || 'freeform-promoted';
  const proposal = {
    family,
    patternKey: opts.patternKey,
    wins: opts.wins,
    draft: {
      stubSketch: (opts.stubSketch || 'export function SYM(...args: unknown[]): unknown { throw new Error("stub"); }').slice(0, 400),
      caseBody: (opts.caseBody || 'expect(SYM()).toBeDefined();').slice(0, 400),
    },
    featureId: opts.featureId,
    ts: new Date().toISOString(),
  };
  if (opts.source) proposal.source = opts.source;
  if (opts.runIds) proposal.runIds = opts.runIds;
  return proposal;
}

/** Scan stored tier1Run:* snapshots for a case family that died ≥minConsecutive
 *  runs in a row within the same lineage (same projectId, or near-identical
 *  prompt). Returns CladePromotionProposal[] — 0 tokens, pure local arithmetic. */
function scanRecurringFailures(records, opts = {}) {
  const minConsecutive = opts.minConsecutive || 3;
  const proposals = [];
  for (const group of cladeGroupLineages(records)) {
    if (group.length < minConsecutive) continue;
    const perRun = group.map(r => {
      const names = cladeExtractDroppedCaseNames(r.problems);
      const byFamily = new Map();
      for (const n of names) {
        const f = cladeCaseFamily(n);
        if (!byFamily.has(f)) byFamily.set(f, n);
      }
      return byFamily;
    });
    const allFamilies = new Set();
    for (const byFamily of perRun) for (const f of byFamily.keys()) allFamilies.add(f);
    for (const family of allFamilies) {
      let streak = 0;
      let exampleName = family;
      const runIds = [];
      for (let i = perRun.length - 1; i >= 0; i--) {
        const hit = perRun[i].get(family);
        if (!hit) break;
        if (streak === 0) exampleName = hit; // most-recent occurrence — current shape
        streak++;
        runIds.unshift(group[i].id);
      }
      if (streak < minConsecutive) continue;
      const isModelSchema = family === CLADE_MODEL_SCHEMA_FAMILY;
      proposals.push(cladeDraftPromotion({
        patternKey: `family:${family}`,
        featureId: group[group.length - 1].id,
        wins: streak,
        source: 'cross-run-failure',
        runIds,
        stubSketch: isModelSchema
          ? `export class ${exampleName} { constructor(_fields: Record<string, unknown>) { throw new Error('stub'); } }`
          : undefined,
        caseBody: isModelSchema
          ? `expect(() => new ${exampleName}({} as never)).not.toThrow();`
          : undefined,
      }));
    }
  }
  return proposals;
}

/** SET 17 storage: dedup by (patternKey, featureId), cap 100, never auto-admitted. */
async function persistCladeCandidate(p) {
  if (!p?.patternKey || !p?.family) return { error: 'malformed clade candidate' };
  const { cladeCandidates = [] } = await chrome.storage.local.get('cladeCandidates');
  const dup = cladeCandidates.some(x => x.patternKey === p.patternKey && x.featureId === p.featureId);
  if (!dup) {
    cladeCandidates.unshift({ ...p, ts: p.ts || new Date().toISOString() });
    await chrome.storage.local.set({ cladeCandidates: cladeCandidates.slice(0, 100) });
  }
  const { cladeCandidates: latest = cladeCandidates } = await chrome.storage.local.get('cladeCandidates');
  return { ok: true, count: latest.length };
}

/** SET 31d — run at the end of every tier1 run (0 tokens): the corpus does not
 *  compound when the same planned case dies every run and nothing escalates
 *  it. Scans the bounded run ring (≤20 records) for a case family that failed
 *  ≥3 runs in a row and proposes clade promotion via the existing SET 17
 *  storage — never auto-admitted. */
async function scanAndProposeClades() {
  try {
    const all = await chrome.storage.local.get(null);
    const runKeys = Object.keys(all).filter(k => k.startsWith('tier1Run:'));
    const snapshots = runKeys.map(k => {
      const rec = all[k];
      return {
        id: rec?.id || k.slice('tier1Run:'.length),
        ts: rec?.ts || '',
        prompt: rec?.prompt || null,
        projectId: rec?.projectId || null,
        problems: rec?.result?.problems ?? [],
      };
    });
    for (const proposal of scanRecurringFailures(snapshots)) {
      await persistCladeCandidate(proposal);
    }
  } catch { /* recurrence scan is best-effort; never blocks run persistence */ }
}

function facetCell(latest, avg, min, max, lifetime) {
  return { latest, avg, min, max, lifetime };
}

// SET 57d — run-report facet keys + score formulas (mirror of
// src/tier1/statsAggregate.ts REPORT_COUNTER_KEYS / reportScoresFromMetrics).
const REPORT_COUNTER_KEYS = [
  'featuresTotal', 'featuresFrozenWithoutRedraw', 'irRedraws',
  'nodesTotal', 'nodesBuildable', 'nodesBuilt', 'nodesBuiltCleanFirstPass',
  'boundedLoopNodes', 'boundedLoopIterations',
  'candidatesDrawn', 'candidatesAccepted',
  'fillCandidatesDrawn', 'repairCandidatesDrawn', 'salvagedCases',
  'sickRedCases', 'testRhaSubstrateFixes', 'testRhaDraws', 'irSurgeriesInRha',
  'reEnqueuedCases', 'recoveredCases',
  'l2Draws', 'l2Wins', 'l2Bullseyes',
  'fixesL1', 'fixesL2', 'fixesL3', 'fixesL4',
  'corpseRejections', 'classifyAttempts', 'classifyCataloged',
  'grammarConformant', 'grammarFallbacks', 'shellLockApplied', 'shellLockFallbacks',
  'judgmentGates', 'judgmentOperator', 'judgmentLlm', 'judgmentDropped',
  'watcherSyntheses', 'watcherReadings', 'watcherObservations',
  'watcherCacheHits', 'watcherCacheMisses', 'watcherWorldStaleSkips',
  'corpseRejectionCeilingBreaches',
  'nodeRepairDraws', 'nodeRepairWins',
  'observationPolls', 'observationsNew', 'observationsCorroborated',
  'errorsTotal', 'errorsParse', 'errorsConformance', 'errorsExecution', 'errorsCrash',
  // SET 89 §§10.5 / 11.4 — canonical registry / ledger counters. UI reads
  // these preferentially; `errors*` above are legacy aliases retained for
  // one release.
  'findingOccurrencesTotal', 'findingOccurrencesParse',
  'findingOccurrencesConformance', 'findingOccurrencesExecution',
  'findingOccurrencesCrash', 'findingOccurrencesTestFailure',
  'uniqueDefectsObserved', 'uniqueDefectsResolved', 'uniqueDefectsUnresolved',
  'actionStarts', 'actionTerminals', 'actionDecisions', 'actionPending', 'actionFailed',
  // SET 89 §11.4 Gap E — separate counter for nonfatal action failures.
  'actionFailedNonfatal',
  'deferredTotal', 'deferredRelitigated', 'deferredResolvedAfterRelitigation', 'deferredPermanent',
  'nodesHealed', 'fixesUnsuccessful', 'fixesUnparseable',
];
const REPORT_SCORE_KEYS = ['scoreIr', 'scoreBuild', 'scoreRha'];
function reportScoresJs(m) {
  const ratio = (n, d) => (d > 0 ? Math.max(0, Math.min(1, n / d)) : null);
  return {
    scoreIr: ratio(statsNum(m.featuresFrozenWithoutRedraw), statsNum(m.featuresTotal)),
    scoreBuild: statsNum(m.nodesBuildable) > 0
      ? ratio(statsNum(m.nodesBuilt), statsNum(m.nodesBuildable))
      : ratio(statsNum(m.nodesBuiltCleanFirstPass), statsNum(m.nodesTotal)),
    scoreRha: ratio(statsNum(m.nodesHealed), statsNum(m.boundedLoopNodes)),
  };
}

function pickLatestRun(records) {
  let best = null;
  let bestTs = -Infinity;
  for (const r of records) {
    const t = r?.ts ? Date.parse(r.ts) : NaN;
    const n = Number.isFinite(t) ? t : -Infinity;
    if (n >= bestTs) { bestTs = n; best = r; }
  }
  return best;
}

function runSnap(rec) {
  const m = rec.metrics || {};
  const tin = statsNum(m.tokensIn);
  const tout = statsNum(m.tokensOut);
  const outcome = classifyRunOutcome(rec);
  const mode = rec.mode || 'rha';
  const cost = typeof rec.cost?.usdTotal === 'number' && Number.isFinite(rec.cost.usdTotal)
    ? rec.cost.usdTotal : null;
  const mech = statsNum(m.mechanicalFixes);
  const model = statsNum(m.repairDraws);
  const nOr = (v) => (v != null && Number.isFinite(Number(v)) ? Number(v) : null);
  return {
    runsTotal: 1,
    runsClean: outcome === 'successful' ? 1 : 0,
    runsPartial: outcome === 'partial' ? 1 : 0,
    runsFailed: outcome === 'failed' ? 1 : 0,
    runsCancelled: outcome === 'cancelled' ? 1 : 0,
    modeRha: mode === 'rha' ? 1 : 0,
    modeVanilla: mode === 'vanilla' ? 1 : 0,
    modeReferee: mode === 'referee' ? 1 : 0,
    tokensIn: tin, tokensOut: tout, tokensTotal: tin + tout,
    perCallAvgIn: nOr(m.avgIn), perCallMaxIn: nOr(m.maxIn),
    perCallAvgOut: nOr(m.avgOut), perCallMaxOut: nOr(m.maxOut),
    cycles: statsCycles(m),
    irGenAttempts: statsNum(m.irGenAttempts),
    substrateFixes: statsNum(m.substrateFixes) || mech,
    substrateAttempts: statsNum(m.substrateAttempts),
    substrateRefusals: statsNum(m.substrateRefusals),
    substrateNoHandler: statsNum(m.substrateNoHandler),
    mechModelRatio: model > 0 ? mech / model : (mech > 0 ? Infinity : null),
    templatedCases: statsNum(m.templatedCases),
    superbufferDraws: statsNum(m.superbufferDraws),
    superbufferWins: statsNum(m.superbufferWins),
    permBatches: statsNum(m.permBatches),
    permCandidates: statsNum(m.permCandidates != null ? m.permCandidates : m.permutationDraws),
    permImproved: statsNum(m.permImproved ?? m.permutationWins),
    surgeryIrCase: statsNum(m.irSurgeries),
    surgeryCrash: nOr(m.crashSurgeries),
    durationMs: nOr(m.elapsedMs),
    costUsd: cost,
    // SET 57d — run-report counters + function scores.
    ...Object.fromEntries(REPORT_COUNTER_KEYS.map(k => [k, nOr(m[k])])),
    ...reportScoresJs(m),
  };
}

function summarizeTotals(totals, records, pricing) {
  const t = coerceStatsTotals(totals);
  const nTok = t.tokens.runsWithTokens || 0;
  const nPc = t.perCall.runsWith || 0;
  const nCyc = t.cycles.runsWith || 0;
  const nIr = t.irGen.runsWith || 0;
  const nDur = t.duration.runsWith || 0;
  const nCost = t.cost.count || t.costCount || 0;
  const costSum = t.cost.sum || t.costSum || 0;
  const costMax = t.cost.max || t.costMax || 0;
  const costMin = t.cost.min != null ? t.cost.min : t.costMin;
  const mech = t.substrate.mechanical || t.mechanicalFixes || 0;
  const model = t.substrate.model || t.modelRepairs || 0;
  const avg = (sum, n) => (n > 0 ? sum / n : null);
  const latest = pickLatestRun(records);
  const snap = latest ? runSnap(latest) : null;
  const ratioLife = model > 0 ? mech / model : (mech > 0 ? Infinity : null);
  const facets = {
    runsTotal: facetCell(snap?.runsTotal ?? null, null, null, null, t.runs.total),
    runsClean: facetCell(snap?.runsClean ?? null, null, null, null, t.runs.successful),
    runsPartial: facetCell(snap?.runsPartial ?? null, null, null, null, t.runs.partial),
    runsFailed: facetCell(snap?.runsFailed ?? null, null, null, null, t.runs.failed),
    runsCancelled: facetCell(snap?.runsCancelled ?? null, null, null, null, t.runs.cancelled),
    modeRha: facetCell(snap?.modeRha ?? null, null, null, null, t.byMode.rha || 0),
    modeVanilla: facetCell(snap?.modeVanilla ?? null, null, null, null, t.byMode.vanilla || 0),
    modeReferee: facetCell(snap?.modeReferee ?? null, null, null, null, t.byMode.referee || 0),
    tokensIn: facetCell(snap?.tokensIn ?? null, avg(t.tokens.inSum, nTok), t.tokens.inMin, t.tokens.inMax, t.tokens.inSum),
    tokensOut: facetCell(snap?.tokensOut ?? null, avg(t.tokens.outSum, nTok), t.tokens.outMin, t.tokens.outMax, t.tokens.outSum),
    tokensTotal: facetCell(snap?.tokensTotal ?? null, avg(t.tokens.totalSum, nTok), t.tokens.totalMin, t.tokens.totalMax, t.tokens.totalSum),
    perCallAvgIn: facetCell(snap?.perCallAvgIn ?? null, avg(t.perCall.avgInSum, nPc), t.perCall.avgInMin, t.perCall.avgInMax || null, null),
    perCallMaxIn: facetCell(snap?.perCallMaxIn ?? null, null, t.perCall.maxInMin, t.perCall.maxInMax || null, null),
    perCallAvgOut: facetCell(snap?.perCallAvgOut ?? null, avg(t.perCall.avgOutSum, nPc), t.perCall.avgOutMin, t.perCall.avgOutMax || null, null),
    perCallMaxOut: facetCell(snap?.perCallMaxOut ?? null, null, t.perCall.maxOutMin, t.perCall.maxOutMax || null, null),
    cycles: facetCell(snap?.cycles ?? null, avg(t.cycles.sum, nCyc), t.cycles.min, t.cycles.max || null, t.cycles.sum),
    irGenAttempts: facetCell(snap?.irGenAttempts ?? null, avg(t.irGen.sum, nIr), t.irGen.min, t.irGen.max || null, t.irGen.sum),
    substrateFixes: facetCell(snap?.substrateFixes ?? null, null, null, null, t.substrate.fixes || mech),
    substrateAttempts: facetCell(snap?.substrateAttempts ?? null, null, null, null, t.substrate.attempts),
    substrateRefusals: facetCell(snap?.substrateRefusals ?? null, null, null, null, t.substrate.refusals),
    substrateNoHandler: facetCell(snap?.substrateNoHandler ?? null, null, null, null, t.substrate.noHandler),
    mechModelRatio: facetCell(snap?.mechModelRatio ?? null, null, null, null, ratioLife),
    templatedCases: facetCell(snap?.templatedCases ?? null, null, null, null, t.substrate.templated || t.templatedCases),
    superbufferDraws: facetCell(snap?.superbufferDraws ?? null, null, null, null, t.substrate.sbDraws),
    superbufferWins: facetCell(snap?.superbufferWins ?? null, null, null, null, t.substrate.sbWins),
    permBatches: facetCell(snap?.permBatches ?? null, null, null, null, t.perm.batches),
    permCandidates: facetCell(snap?.permCandidates ?? null, null, null, null, t.perm.candidates),
    permImproved: facetCell(snap?.permImproved ?? null, null, null, null, t.perm.improved),
    surgeryIrCase: facetCell(snap?.surgeryIrCase ?? null, null, null, null, t.surgeries.irCase),
    surgeryCrash: facetCell(snap?.surgeryCrash ?? null, null, null, null, t.surgeries.crash || null),
    durationMs: facetCell(snap?.durationMs ?? null, avg(t.duration.sumMs, nDur), t.duration.minMs, t.duration.maxMs || null, t.duration.sumMs),
    costUsd: facetCell(snap?.costUsd ?? null, avg(costSum, nCost), costMin, costMax || null, costSum),
  };
  const latestCost = snap?.costUsd ?? null;
  const avgCost = avg(costSum, nCost);
  let latestVsAvgPct = null;
  if (latestCost != null && avgCost != null && avgCost > 0) {
    latestVsAvgPct = ((latestCost - avgCost) / avgCost) * 100;
  }
  facets.latestVsAvgPct = facetCell(latestVsAvgPct, null, null, null, null);

  // SET 57d — run-report facets (counter history summed; scores avg/min/max).
  {
    const rep = t.report || {};
    for (const k of REPORT_COUNTER_KEYS) {
      const b = rep[k];
      facets[k] = facetCell(
        snap?.[k] ?? null,
        b ? avg(b.sum, b.runsWith) : null,
        b?.min ?? null,
        b ? b.max : null,
        b ? b.sum : null,
      );
    }
    for (const k of REPORT_SCORE_KEYS) {
      const b = rep[k];
      facets[k] = facetCell(
        snap?.[k] ?? null,
        b ? avg(b.sum, b.runsWith) : null,
        b?.min ?? null,
        b ? b.max : null,
        null,
      );
    }
  }

  // Comparative pricing on lifetime tokens — thin family roster (mirrors pricing.ts).
  const comparativeLifetime = [];
  if (pricing && !pricing.unavailable && pricing.table) {
    const FAMILIES = [
      { label: 'Claude', test: id => /^anthropic\/claude-/i.test(id) },
      { label: 'ChatGPT', test: id => /^openai\/gpt-/i.test(id) },
      { label: 'Gemini', test: id => /^google\/gemini-/i.test(id) },
      { label: 'Grok', test: id => /^x-ai\/grok-/i.test(id) },
      { label: 'Kimi', test: id => /^moonshotai\/kimi-/i.test(id) },
      { label: 'Z.ai GLM', test: id => /^z-ai\/glm-/i.test(id) },
      { label: 'DeepSeek', test: id => /^deepseek\/deepseek-/i.test(id) },
      { label: 'Qwen', test: id => /^qwen\/qwen/i.test(id) },
    ];
    const isSuffix = id => /:free\b/i.test(id) || /:[a-z0-9_-]+$/i.test(id)
      || /[-]it$/i.test((id.split('/').pop() || ''));
    for (const fam of FAMILIES) {
      let best = null;
      for (const [id, price] of Object.entries(pricing.table)) {
        if (!fam.test(id) || isSuffix(id)) continue;
        const created = price.created ?? 0;
        if (!best || created > best.created || (created === best.created && id > best.id)) {
          best = { id, price, created };
        }
      }
      if (best) {
        const usdIn = t.tokens.inSum * best.price.promptUsdPerTok;
        const usdOut = t.tokens.outSum * best.price.completionUsdPerTok;
        comparativeLifetime.push({
          label: fam.label, id: best.id,
          usdTotal: usdIn + usdOut, usdIn, usdOut,
        });
      }
    }
  }

  return {
    runs: t.runs,
    byMode: t.byMode,
    facets,
    latestVsAvgPct,
    lifetimeTokens: { in: t.tokens.inSum, out: t.tokens.outSum, total: t.tokens.totalSum },
    pricingAvailable: !pricing?.unavailable && nCost > 0,
    latestTs: latest?.ts ?? null,
    latestId: latest?.id ?? null,
    ringSize: records.length,
    totalsRuns: t.runs.total,
    comparativeLifetime,
    // Legacy summary fields (sidebar removed in SET 19; keep for older clients).
    tokens: {
      lifetimeIn: t.tokens.inSum,
      lifetimeOut: t.tokens.outSum,
      lifetimeTotal: t.tokens.totalSum,
      avgIn: avg(t.tokens.inSum, nTok),
      avgOut: avg(t.tokens.outSum, nTok),
      avgTotal: avg(t.tokens.totalSum, nTok),
      maxIn: t.tokens.inMax,
      maxOut: t.tokens.outMax,
      maxTotal: t.tokens.totalMax,
      minIn: t.tokens.inMin,
      minOut: t.tokens.outMin,
      minTotal: t.tokens.totalMin,
      runsWithTokens: nTok,
    },
    cycles: { avg: avg(t.cycles.sum, nCyc), max: t.cycles.max, lifetime: t.cycles.sum },
    substrateFixesLifetime: t.substrate.fixes || mech,
    mechanicalVsModelRepair: { mechanical: mech, model, ratio: ratioLife },
    templatedCasesLifetime: t.substrate.templated || t.templatedCases,
    cost: {
      lifetimeEstimated: costSum,
      avg: avgCost,
      max: costMax,
      min: costMin,
      runsPriced: nCost,
      pricingAvailable: !pricing?.unavailable,
    },
  };
}

// ── In-memory session state (+ chrome.storage.session mirror) ─────────────────

let sessionKey = null;   // CryptoKey | null — null = vault locked (after ensureSession)
let vault      = null;   // decrypted vault object | null

const LOCK_ALARM = 'vault-autolock';
/** Minutes of idle before lock; 0 = only on browser close. Default 30. */
let idleMinutes = 30;

async function loadPrefs() {
  const { vaultPrefs = {} } = await chrome.storage.sync.get('vaultPrefs');
  idleMinutes = Number.isFinite(vaultPrefs.idleMinutes) ? vaultPrefs.idleMinutes : 30;
}

function resetLockTimer() {
  chrome.alarms.clear(LOCK_ALARM);
  if (idleMinutes > 0) {
    chrome.alarms.create(LOCK_ALARM, { delayInMinutes: idleMinutes });
  }
}

async function persistSessionKey(key) {
  const raw = await crypto.subtle.exportKey('raw', key);
  await chrome.storage.session.set({ [SESSION_KEY_STORE]: Array.from(new Uint8Array(raw)) });
}

async function clearSessionStore() {
  try { await chrome.storage.session.remove(SESSION_KEY_STORE); } catch { /* ignore */ }
}

/**
 * Rehydrate sessionKey + vault after an MV3 service-worker restart.
 * Returns true if unlocked. Only reports locked when storage.session has no key.
 */
async function ensureSession() {
  if (sessionKey && vault) return true;
  let bytes;
  try {
    const r = await chrome.storage.session.get(SESSION_KEY_STORE);
    bytes = r[SESSION_KEY_STORE];
  } catch {
    return false;
  }
  if (!bytes || !bytes.length) return false;
  try {
    sessionKey = await crypto.subtle.importKey(
      'raw',
      new Uint8Array(bytes),
      { name: 'AES-GCM', length: 256 },
      true,
      ['encrypt', 'decrypt'],
    );
    const { vaultBlob } = await loadEncrypted();
    if (!vaultBlob) {
      await clearSessionStore();
      sessionKey = null;
      vault = null;
      return false;
    }
    vault = await decryptVault(sessionKey, vaultBlob);
    return true;
  } catch {
    await clearSessionStore();
    sessionKey = null;
    vault = null;
    return false;
  }
}

chrome.alarms.onAlarm.addListener(a => {
  if (a.name !== LOCK_ALARM) return;
  // Never lock while a job is running — check the offscreen runtime first;
  // an active pipeline defers the idle clock instead of being orphaned.
  void (async () => {
    try {
      const st = await forwardTier1({ action: 'tier1:status' });
      if (st?.running) { resetLockTimer(); return; }
    } catch { /* no offscreen — nothing running */ }
    sessionKey = null;
    vault = null;
    void clearSessionStore();
  })();
});

void (async () => {
  try {
    // Restrict session storage to extension contexts (not content scripts).
    await chrome.storage.session.setAccessLevel({ accessLevel: 'TRUSTED_CONTEXTS' });
  } catch { /* older Chromium — ignore */ }
  await loadPrefs();
  // SET 56a — sweep phantom "running" records against offscreen live truth.
  try {
    const live = await queryTier1Live();
    await sweepStaleRunning(live.liveId);
  } catch { /* storage / offscreen unavailable at boot */ }
})();

async function lockVault() {
  sessionKey = null;
  vault = null;
  chrome.alarms.clear(LOCK_ALARM);
  await clearSessionStore();
}

// ── Vault storage helpers ─────────────────────────────────────────────────────

async function loadEncrypted() {
  const r = await chrome.storage.local.get(['vaultBlob', 'vaultSalt', 'auditLog']);
  return r;
}

async function saveVault() {
  if (!sessionKey || !vault) return;
  const blob = await encryptVault(sessionKey, vault);
  const cur = await chrome.storage.local.get('vaultSalt');
  await chrome.storage.local.set({ vaultBlob: blob, vaultSalt: cur.vaultSalt });
}

async function appendAudit(entry) {
  const { auditLog = [] } = await chrome.storage.local.get('auditLog');
  auditLog.unshift({ ...entry, ts: new Date().toISOString() });
  await chrome.storage.local.set({ auditLog: auditLog.slice(0, 50) });
}

// ── Message handler ───────────────────────────────────────────────────────────

chrome.runtime.onMessageExternal.addListener((msg, sender, sendResponse) => {
  handle(msg, sender).then(sendResponse).catch(e => sendResponse({ error: e.message }));
  return true; // async
});

chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
  handle(msg, null).then(sendResponse).catch(e => sendResponse({ error: e.message }));
  return true;
});

async function handle(msg, sender) {
  // Rehydrate AES session across SW restarts before any vault op.
  // create/unlock set a new key; skip restore so a wrong-password unlock
  // cannot race with a stale session entry.
  if (msg.action !== 'vault:create' && msg.action !== 'vault:unlock') {
    await ensureSession();
  }

  switch (msg.action) {

    // ── Vault lifecycle ──────────────────────────────────────────────────────

    case 'vault:status': {
      const { vaultBlob } = await loadEncrypted();
      return { exists: !!vaultBlob, unlocked: !!(sessionKey && vault) };
    }

    case 'vault:create': {
      const salt = Array.from(crypto.getRandomValues(new Uint8Array(16)));
      const key  = await deriveKey(msg.password, salt);
      const empty = { providers: [], activeProviderId: null };
      const blob  = await encryptVault(key, empty);
      await chrome.storage.local.set({ vaultSalt: salt, vaultBlob: blob });
      sessionKey = key;
      vault = empty;
      await persistSessionKey(key);
      resetLockTimer();
      return { ok: true };
    }

    case 'vault:unlock': {
      const { vaultBlob, vaultSalt } = await loadEncrypted();
      if (!vaultBlob) return { error: 'No vault found' };
      try {
        const key  = await deriveKey(msg.password, vaultSalt);
        const data = await decryptVault(key, vaultBlob);
        sessionKey = key;
        vault = data;
        await persistSessionKey(key);
        resetLockTimer();
        return { ok: true };
      } catch {
        return { error: 'Wrong password' };
      }
    }

    case 'vault:lock': {
      await lockVault();
      return { ok: true };
    }

    case 'vault:lock-view': {
      // Part Q1 — lock on popup view-loss only (not mid-flow).
      await lockVault();
      return { ok: true };
    }

    case 'prefs:updated': {
      await loadPrefs();
      if (sessionKey) resetLockTimer();
      return { ok: true, idleMinutes };
    }

    // ── Provider CRUD ────────────────────────────────────────────────────────

    case 'providers:list': {
      if (!vault) return { error: 'Vault locked' };
      resetLockTimer();
      return { providers: vault.providers.map(p => ({ ...p, apiKey: undefined })),
               activeProviderId: vault.activeProviderId };
    }

    case 'providers:save': {
      if (!vault) return { error: 'Vault locked' };
      resetLockTimer();
      const { provider } = msg;
      const idx = vault.providers.findIndex(p => p.id === provider.id);
      if (idx >= 0) vault.providers[idx] = provider;
      else          vault.providers.push(provider);
      await saveVault();
      return { ok: true };
    }

    case 'providers:delete': {
      if (!vault) return { error: 'Vault locked' };
      vault.providers = vault.providers.filter(p => p.id !== msg.id);
      if (vault.activeProviderId === msg.id) vault.activeProviderId = null;
      await saveVault();
      return { ok: true };
    }

    // ── Activate: push config to local RHA server ────────────────────────────

    case 'providers:verify': {
      // Direct endpoint probe for the web runtime's Test Connection — the
      // provider comes from the form, so no vault read is needed.
      try {
        await verifyProviderDirect(msg.provider || {});
        return { ok: true };
      } catch (e) {
        return { error: e.message };
      }
    }

    case 'providers:activate': {
      if (!vault) return { error: 'Vault locked' };
      resetLockTimer();
      const p = vault.providers.find(pr => pr.id === msg.id);
      if (!p) return { error: 'Provider not found' };

      // Web runtime (the default): there is no server to push config to —
      // rha.software is a static site and jobs run inside this extension
      // (Run tab) against the vault's active provider. But "active" must mean
      // VERIFIED: probe the provider endpoint before claiming anything.
      if ((msg.rhaMode || p.rhaMode || 'web') === 'web') {
        try {
          await verifyProviderDirect(p);
        } catch (e) {
          await appendAudit({ op: 'activate', provider: p.name, type: p.type, mode: 'web', ok: false, error: e.message });
          return { error: `Provider verification failed: ${e.message}` };
        }
        vault.activeProviderId = p.id;
        await saveVault();
        await appendAudit({ op: 'activate', provider: p.name, type: p.type, mode: 'web', ok: true });
        return { ok: true, mode: 'web' };
      }

      const payload = {
        type:          p.type,
        baseURL:       p.baseURL       || '',
        modelOverride: p.modelOverride || '',
        apiKey:        p.apiKey        || '',
        role:          msg.role || 'operator',
        index:         typeof msg.index === 'number' ? msg.index : 0,
      };

      const rhaUrl = msg.rhaUrl || 'http://localhost:4100';
      try {
        const res = await fetch(`${rhaUrl}/api/provider`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(payload),
        });
        if (!res.ok) {
          const err = await res.json().catch(() => ({}));
          throw new Error(err.error || `RHA server ${res.status}`);
        }
        vault.activeProviderId = p.id;
        await saveVault();
        await appendAudit({ op: 'activate', provider: p.name, type: p.type, role: payload.role });
        return { ok: true };
      } catch (e) {
        return { error: e.message };
      }
    }

    // ── LLM proxy (for future client-side use) ───────────────────────────────
    // Web pages send prompts here; extension adds the API key and calls the
    // provider directly. The API key never leaves the extension.

    case 'llm:call': {
      if (!vault) return { error: 'Vault locked' };
      resetLockTimer();
      const active = vault.providers.find(p => p.id === vault.activeProviderId);
      if (!active) return { error: 'No active provider' };

      const started = Date.now();
      let result;
      try {
        result = await proxyLlmCall(active, msg.payload);
        await appendAudit({ op: 'llm', provider: active.name, ms: Date.now() - started, ok: true });
      } catch (e) {
        await appendAudit({ op: 'llm', provider: active.name, ms: Date.now() - started, ok: false, error: e.message });
        return { error: e.message };
      }
      return { ok: true, result };
    }

    // ── Tier 1: full RHA pipeline in the extension, no local server ─────────
    // Corpus (prompts/gates/lessons) is served by rha.software; compute is the
    // user's own LLM via the active vault provider. Artifact = in-memory git
    // repo, exported as a zip. See src/tier1/ in the RHA Wrapper repo.

    // External-safe runtime snapshot for the web app (rha.software /app):
    // lets the site show the extension as the active runtime. Never returns
    // key material.
    case 'tier1:meta': {
      const activeP = vault?.providers.find(pr => pr.id === vault.activeProviderId) ?? null;
      // SET 47d — always report the RUNNING extension's manifest version.
      const vaultVersion = chrome.runtime.getManifest().version;
      return {
        unlocked: !!vault,
        vaultVersion,
        active: activeP ? {
          name: activeP.name,
          type: activeP.type,
          model: activeP.modelOverride || (await resolveActiveModelCached(activeP)) || '',
          baseURL: activeP.type === 'openai-compat' ? (activeP.baseURL || '') : undefined,
          mode: activeP.rhaMode || 'web',
        } : null,
        ...(await forwardTier1({ action: 'tier1:status' })
          .then(s => ({ running: !!s?.running, hasResult: !!s?.hasResult }))
          .catch(() => ({ running: false, hasResult: false }))),
      };
    }

    case 'tier1:run': {
      if (!vault) return { error: 'Vault locked' };
      const active = vault.providers.find(p => p.id === vault.activeProviderId);
      if (!active) return { error: 'No active provider' };
      if (active.type !== 'openai-compat') {
        return { error: `Tier 1 currently runs on OpenAI-compatible providers (active provider is ${active.type})` };
      }
      const prompt = String(msg.payload?.prompt || '').trim();
      if (!prompt) return { error: 'Empty prompt' };
      resetLockTimer();
      // Continuation: subsequent prompts submit INTO a project — resolve the
      // prior run's delivered features as baseline context for the encoder.
      let lineage = {};
      if (msg.payload?.continueFrom) {
        const key = `tier1Run:${msg.payload.continueFrom}`;
        const rec = (await chrome.storage.local.get(key))[key];
        if (!rec) return { error: `Run ${msg.payload.continueFrom} not found — cannot continue it` };
        // SET 37g — previously-dropped cases replan FIRST on continue-project.
        const priorDroppedCaseNames = cladeExtractDroppedCaseNames(rec.result?.problems || []);
        const priorParsePoisonIncidents = Number(rec.result?.metrics?.parsePoisonIncidents || 0);
        lineage = {
          projectId: rec.projectId || rec.id,
          parentId: rec.id,
          priorDroppedCaseNames,
          priorParsePoisonIncidents,
          baseline: {
            prompt: rec.prompt || '',
            features: (rec.result?.spec?.features || []).map(f => ({
              id: f.id, name: f.name, description: f.description,
            })),
          },
        };
      }
      const mode = msg.payload?.mode === 'vanilla' || msg.payload?.mode === 'referee'
        ? msg.payload.mode
        : 'rha';
      await appendAudit({ op: 'tier1', provider: active.name, ok: true });
      return forwardTier1({
        action: 'tier1:run',
        payload: {
          prompt,
          mode,
          corpusBase: msg.payload?.corpusBase,
          ...lineage,
          provider: {
            baseURL: active.baseURL || '',
            apiKey: active.apiKey || '',
            model: active.modelOverride || '',
            resolvedModel: await resolveActiveModelCached(active),
          },
        },
      });
    }

    case 'tier1:status': {
      return forwardTier1({ action: 'tier1:status' });
    }

    // SET 56a — single source of live truth: offscreen in-memory runtime ONLY.
    // Never derive from stored records. No offscreen → not running.
    case 'tier1:live': {
      return queryTier1Live();
    }

    // SET 85 — the observatory corpus lives in the supercache as explicit
    // artifacts: append-only records with subject, claim, evidence and
    // sighting provenance, carried across runs.
    case 'tier1:observations-load': {
      const all = await chrome.storage.local.get(['metaObservations']);
      const rows = Array.isArray(all.metaObservations) ? all.metaObservations : [];
      return { observations: rows.slice(-400) };
    }

    case 'tier1:observations-save': {
      const incoming = Array.isArray(msg.observations) ? msg.observations : [];
      if (!incoming.length) return { ok: true, stored: 0 };
      const all = await chrome.storage.local.get(['metaObservations']);
      const rows = Array.isArray(all.metaObservations) ? all.metaObservations : [];
      const byKey = new Map();
      for (const r of [...rows, ...incoming]) {
        if (!r || !r.kind || !r.subject) continue;
        const key = `${r.kind}\u0000${String(r.subject).toLowerCase()}\u0000${String(r.claim ?? '').toLowerCase().slice(0, 120)}`;
        const prev = byKey.get(key);
        // Append-only merge: sightings accumulate, provenance is preserved.
        byKey.set(key, prev
          ? { ...prev, timesSeen: Math.max(prev.timesSeen || 1, r.timesSeen || 1), lastSeenAt: r.lastSeenAt || prev.lastSeenAt }
          : r);
      }
      const merged = [...byKey.values()].slice(-400);
      await chrome.storage.local.set({ metaObservations: merged });
      return { ok: true, stored: merged.length };
    }

    // SET 64 — metasupercache (offscreen has no chrome.storage; SW owns keys).
    // Load: corpse hashes from ALL retained epochs for the LLM negative cache.
    case 'tier1:metaSb-load': {
      const all = await chrome.storage.local.get(null);
      const hashes = [];
      const seen = new Set();
      for (const k of Object.keys(all)) {
        if (!k.startsWith('metaSb:')) continue;
        const recs = Array.isArray(all[k]) ? all[k] : [];
        for (const r of recs) {
          if (r && typeof r.hash === 'string' && !seen.has(r.hash)) {
            seen.add(r.hash);
            hashes.push(r.hash);
            if (hashes.length >= 4000) break;
          }
        }
      }
      return { corpseHashes: hashes };
    }

    // Append candidate records to the current run's epoch under the caps:
    // 256KB/epoch (trim diffs → evict oldest), single write >512KB refused,
    // 4-epoch ring + 1MB total enforced after every append.
    case 'tier1:metaSb-append': {
      const runId = msg.runId;
      const records = Array.isArray(msg.records) ? msg.records : [];
      if (!runId || !records.length) return { ok: false, error: 'no runId/records' };
      const writeBytes = JSON.stringify(records).length;
      if (writeBytes > META_SB_REFUSE_WRITE_BYTES) {
        return { ok: false, refused: true, bytes: writeBytes };
      }
      const key = `metaSb:${runId}`;
      const stored = await chrome.storage.local.get([key, 'metaSbIndex']);
      let epoch = Array.isArray(stored[key]) ? stored[key] : [];
      for (const rec of records) {
        const bounded = { ...rec };
        if (typeof bounded.diffVsIncumbent === 'string') {
          bounded.diffVsIncumbent = bounded.diffVsIncumbent.slice(0, 1024);
        }
        epoch.push(bounded);
      }
      // Enforce epoch cap: strip diffs oldest-first, then evict oldest records.
      const bytesOf = arr => JSON.stringify(arr).length;
      for (let i = 0; i < epoch.length && bytesOf(epoch) > META_SB_EPOCH_CAP_BYTES; i++) {
        if (epoch[i] && epoch[i].diffVsIncumbent) epoch[i] = { ...epoch[i], diffVsIncumbent: undefined };
      }
      while (bytesOf(epoch) > META_SB_EPOCH_CAP_BYTES && epoch.length > 1) epoch = epoch.slice(1);
      const index = Array.isArray(stored.metaSbIndex) ? stored.metaSbIndex : [];
      const nextIndex = index.includes(runId) ? index : [...index, runId];
      await chrome.storage.local.set({ [key]: epoch, metaSbIndex: nextIndex });
      await auditMetaSbRing();
      return { ok: true, bytes: bytesOf(epoch) };
    }

    // Run persistence lives HERE: offscreen documents have no chrome.storage.
    // The offscreen runtime posts the finished record via tier1:persist.

    // System notifications for pipeline stage switches and user-input moments —
    // fired from the SW (offscreen documents have no chrome.notifications).
    // SET 29c — the catch used to be empty, so a blocked/misconfigured OS
    // notification failed with zero trace. Now the outcome is always returned
    // (and a formatted line included) so the caller can log it into the
    // pipeline event log instead of it silently vanishing.
    case 'tier1:notify': {
      try {
        const id = await chrome.notifications.create('', {
          type: 'basic',
          iconUrl: 'icons/icon128.png',
          title: `RHA — ${msg.title || 'pipeline'}`,
          message: msg.message || '',
          silent: false,
        });
        return { ok: true, id, log: `notify(sw): created id=${id}` };
      } catch (e) {
        const errMsg = e instanceof Error ? e.message : String(e);
        return { ok: false, error: errMsg, log: `notify(sw): FAILED ${errMsg}` };
      }
    }

    case 'tier1:persist': {
      // SET 51 — upsert with ACK + read-back; merge so header-only fallback
      // never wipes a prior checkpoint's events. Clade scan only on finalize.
      const rec = msg.record;
      if (!rec?.id) return { error: 'no record' };
      const key = `tier1Run:${rec.id}`;
      const stored = await chrome.storage.local.get(['tier1Runs', 'tier1StatsTotals', key]);
      const tier1Runs = stored.tier1Runs || [];
      let totals = coerceStatsTotals(stored.tier1StatsTotals);
      const prev = stored[key];
      const prevEvents = Array.isArray(prev?.events) ? prev.events : [];
      const nextEvents = Array.isArray(rec.events) ? rec.events : [];
      const events = nextEvents.length >= prevEvents.length ? nextEvents : prevEvents;
      const isRunning = rec.status === 'running' || !!rec.running;
      const isInterrupted = rec.status === 'interrupted' || rec.fallback === true;
      // Final = terminal disposition (or legacy record with no status field).
      const isFinal = !isRunning && !isInterrupted && (
        rec.finalized === true
        || rec.status === 'complete'
        || rec.status === 'failed'
        || rec.status === 'cancelled'
        || (rec.status == null && (rec.result != null || rec.error != null || Array.isArray(rec.events)))
      );
      // Non-final / interrupted: keep longer event list; running snapshots
      // must carry running:true so install totals skip mid-flight upserts.
      const merged = {
        ...(prev || {}),
        ...rec,
        events,
        running: isRunning,
        finalized: isFinal,
      };
      if (prev) totals = subtractRunFromTotals(totals, prev);
      totals = contributeRunToTotals(totals, merged);
      const entry = {
        id: merged.id, ts: merged.ts, title: merged.title, problems: merged.problems, error: merged.error,
        prompt: merged.prompt, projectId: merged.projectId, parentId: merged.parentId, model: merged.model,
        mode: merged.mode || 'rha',
        costUsd: typeof merged.cost?.usdTotal === 'number' ? merged.cost.usdTotal : null,
        status: merged.status || null,
      };
      const next = [entry, ...tier1Runs.filter(r => r.id !== rec.id)].slice(0, 20);
      await chrome.storage.local.set({
        tier1Runs: next,
        [key]: merged,
        tier1StatsTotals: totals,
      });
      for (const r of tier1Runs) {
        if (!next.some(n => n.id === r.id)) await chrome.storage.local.remove(`tier1Run:${r.id}`);
      }
      // SET 31d — cross-run recurrence scan only after a finalized write.
      if (merged.finalized === true) await scanAndProposeClades();
      // SET 51b — read-back for ACK diff.
      const readback = await chrome.storage.local.get(key);
      const storedRec = readback[key];
      const eventCount = Array.isArray(merged.events) ? merged.events.length : 0;
      const readbackEventCount = Array.isArray(storedRec?.events) ? storedRec.events.length : 0;
      let bytes = 0;
      try { bytes = JSON.stringify(merged).length; } catch { /* ignore */ }
      return {
        ok: true,
        ack: true,
        id: merged.id,
        eventCount,
        readbackEventCount,
        status: merged.status || null,
        checkpointAt: merged.checkpointAt || null,
        bytes,
      };
    }

    case 'pricing:table': {
      return getPricingTable();
    }

    case 'stats:identity': {
      const hash = await ensureInstallHash();
      return { installHash: hash };
    }

    case 'stats:local': {
      const hash = await ensureInstallHash();
      // SET 18g / 19b — backfill from ALL stored tier1Run:* records. Prefer
      // lifetime totals when they exceed the ring (evicted runs).
      // SET 30b — a `statsBaseline` timestamp (stamped by "reset your-install
      // stats") excludes records older than it from BOTH the lifetime totals
      // AND the record backfill, so a reset install reads zero even though
      // run HISTORY (tier1Runs / tier1Run:*) is left untouched.
      const all = await chrome.storage.local.get(null);
      const baseline = typeof all.statsBaseline === 'string' ? all.statsBaseline : null;
      const runKeys = Object.keys(all).filter(k => k.startsWith('tier1Run:'));
      const records = [];
      let fromRecords = emptyStatsTotals();
      for (const k of runKeys) {
        const rec = all[k];
        if (rec && typeof rec === 'object') {
          if (baseline && (!rec.ts || rec.ts < baseline)) continue;
          records.push(rec);
          fromRecords = contributeRunToTotals(fromRecords, rec);
        }
      }
      const lifetime = coerceStatsTotals(all.tier1StatsTotals);
      const totals = (lifetime.runs?.total || 0) > (fromRecords.runs?.total || 0)
        ? lifetime
        : fromRecords;
      const pricing = await getPricingTable();
      const summary = summarizeTotals(totals, records, pricing);
      // SET 64 — metasupercache footprint (metaSb:* epochs, byte total).
      const metaSbKeys = Object.keys(all).filter(k => k.startsWith('metaSb:'));
      let metaSbBytes = 0;
      for (const k of metaSbKeys) {
        try { metaSbBytes += JSON.stringify(all[k]).length; } catch { /* skip */ }
      }
      return {
        installHash: hash,
        ringSize: Math.max(records.length, runKeys.length),
        statsBaseline: baseline,
        metaSbEpochs: metaSbKeys.length,
        metaSbBytes,
        ...summary,
      };
    }

    // SET 30b — reset YOUR-INSTALL aggregate stats (unprotected, local-only).
    // Zeroes the running `tier1StatsTotals` accumulator and stamps
    // `statsBaseline` so future aggregation (stats:local, above) ignores any
    // tier1Run:* record older than the stamp. Run HISTORY (tier1Runs list +
    // tier1Run:* records themselves) is left completely untouched — this only
    // affects the aggregate counters shown in the Admin "Your install" card.
    case 'stats:resetLocal': {
      const ts = new Date().toISOString();
      await chrome.storage.local.set({
        tier1StatsTotals: emptyStatsTotals(),
        statsBaseline: ts,
      });
      await appendAudit({ op: 'stats:resetLocal' });
      return { ok: true, ts };
    }

    // SET 17 — persist clade-promotion proposals (admission is manual / oracle-gated).
    case 'clade-candidates:add': {
      const ok = await persistCladeCandidate(msg.proposal);
      if (ok.error) return ok;
      return { ok: true, count: ok.count };
    }

    case 'tier1:cancel': {
      return forwardTier1({ action: 'tier1:cancel' });
    }
    case 'tier1:resume': {
      return forwardTier1({ action: 'tier1:resume' });
    }

    // ── Lessons substrate: classKey-keyed idioms persisted across runs ──────
    case 'lessons:all': {
      const { tier1Lessons = [] } = await chrome.storage.local.get('tier1Lessons');
      return { lessons: tier1Lessons };
    }

    case 'lessons:add': {
      const l = msg.lesson;
      if (!l?.classKey || !l?.idiom) return { error: 'malformed lesson' };
      const { tier1Lessons = [] } = await chrome.storage.local.get('tier1Lessons');
      const dup = tier1Lessons.some(x => x.classKey === l.classKey && x.idiom === l.idiom);
      if (!dup) {
        tier1Lessons.unshift(l);
        await chrome.storage.local.set({ tier1Lessons: tier1Lessons.slice(0, 200) });
      }
      return { ok: true, count: Math.min(tier1Lessons.length, 200) };
    }

    case 'tier1:runs': {
      // SET 56a — sweep zombies before listing so the viewer never sees phantoms.
      const live = await queryTier1Live();
      await sweepStaleRunning(live.liveId);
      const { tier1Runs = [] } = await chrome.storage.local.get('tier1Runs');
      return { runs: tier1Runs };
    }

    // Read-only: pipeline-log epoch hydration. Storage lives in the SW —
    // do NOT forward to offscreen (hydrate would clobber a live run).
    case 'tier1:record': {
      const runId = msg.runId;
      if (!runId) return { error: 'runId required' };
      const key = `tier1Run:${runId}`;
      const stored = await chrome.storage.local.get(key);
      const record = stored[key];
      if (!record) return { error: `Run ${runId} not found` };
      return {
        id: record.id,
        ts: record.ts,
        model: record.model ?? null,
        events: Array.isArray(record.events) ? record.events : [],
        // SET 33b.2 — "this project" metrics grid needs the full stats-shape
        // fields too (not just events); read-only, same storage read as above.
        mode: record.mode ?? null,
        problems: record.problems ?? null,
        error: record.error ?? null,
        metrics: record.metrics ?? null,
        cost: record.cost ?? null,
        projectId: record.projectId ?? null,
        // SET 51a — viewer honesty for truncated checkpoints.
        status: record.status ?? null,
        checkpointAt: record.checkpointAt ?? null,
        finalized: record.finalized === true,
        running: !!record.running,
      };
    }

    case 'tier1:open': {
      const key = `tier1Run:${msg.runId}`;
      const stored = await chrome.storage.local.get(key);
      const record = stored[key];
      if (!record) return { error: `Run ${msg.runId} not found` };
      return forwardTier1({ action: 'tier1:hydrate', record });
    }

    case 'tier1:export': {
      const out = await forwardTier1({ action: 'tier1:export' });
      if (out?.error) return out;
      await chrome.downloads.download({
        url: `data:application/zip;base64,${out.b64}`,
        filename: out.name,
        saveAs: true,
      });
      return { ok: true, bytes: out.bytes };
    }

    // Raw artifact bytes for programmatic consumers (no download dialog):
    // the caller writes the repository wherever it wants it.
    case 'tier1:export-data': {
      return forwardTier1({ action: 'tier1:export' });
    }

    // Silent export: no dialog — the artifact lands in Downloads/rha-artifacts/
    // immediately. "Download" as a verb the user never has to conjugate.
    case 'tier1:export-silent': {
      const out = await forwardTier1({ action: 'tier1:export' });
      if (out?.error) return out;
      await chrome.downloads.download({
        url: `data:application/zip;base64,${out.b64}`,
        filename: `rha-artifacts/${out.name}`,
        saveAs: false,
        conflictAction: 'overwrite',
      });
      return { ok: true, name: out.name, bytes: out.bytes };
    }

    case 'audit:get': {
      const { auditLog = [] } = await chrome.storage.local.get('auditLog');
      return { log: auditLog };
    }

    case 'ext:meta': {
      return { version: chrome.runtime.getManifest().version, id: chrome.runtime.id };
    }

    // ── Folder sync (write-only into granted directory) ─────────────────────

    case 'deploy:status':
    case 'deploy:ensure':
    case 'deploy:write-file':
    case 'deploy:write-begin':
    case 'deploy:write-chunk':
    case 'deploy:write-end':
      return forwardDeploy(msg);

    default:
      return { error: `Unknown action: ${msg.action}` };
  }
}

const OFFSCREEN_URL = 'offscreen.html';

// SET 56a — must match src/tier1/sweepStaleRunning.ts (SW imports nothing).
const SWEEP_STALE_MSG =
  'interrupted — swept as stale (no live job, checkpoint aged out)';
const SWEEP_AGE_MS = 120_000;

function sweepToMs(v) {
  if (v == null || v === '') return 0;
  if (typeof v === 'number') return Number.isFinite(v) ? v : 0;
  const n = Date.parse(String(v));
  return Number.isFinite(n) ? n : 0;
}

function isZombieRunningRec(rec, liveId, nowMs) {
  const statusUnset = rec.status == null;
  const looksRunning =
    rec.status === 'running'
    || rec.running === true
    || (rec.finalized !== true && statusUnset);
  if (!looksRunning) return false;
  if (liveId != null && rec.id === liveId) return false;
  const ageAnchor = Math.max(sweepToMs(rec.checkpointAt ?? 0), sweepToMs(rec.ts));
  return nowMs - ageAnchor > SWEEP_AGE_MS;
}

/**
 * SET 56a — classify + convert stale non-live "running" records to interrupted.
 * Idempotent. Derives liveId from the caller (offscreen truth), never storage.
 */
async function sweepStaleRunning(liveId) {
  const nowMs = Date.now();
  const nowIso = new Date(nowMs).toISOString();
  const all = await chrome.storage.local.get(null);
  const tier1Runs = Array.isArray(all.tier1Runs) ? all.tier1Runs : [];
  const updates = {};
  let listDirty = false;
  const nextList = tier1Runs.map(entry => {
    const key = `tier1Run:${entry.id}`;
    const full = all[key] || entry;
    if (!isZombieRunningRec(full, liveId, nowMs)) return entry;
    const events = Array.isArray(full.events) ? full.events.slice() : [];
    if (!events.some(e => e && e.message === SWEEP_STALE_MSG)) {
      events.push({
        ts: nowIso,
        phase: 'done',
        featureId: null,
        message: SWEEP_STALE_MSG,
      });
    }
    const swept = {
      ...full,
      status: 'interrupted',
      running: false,
      finalized: true,
      sweptAt: nowIso,
      events,
    };
    updates[key] = swept;
    listDirty = true;
    return {
      ...entry,
      status: 'interrupted',
      problems: entry.problems ?? swept.problems ?? null,
      error: entry.error ?? swept.error ?? null,
    };
  });
  // Also sweep orphan full records not present in the index (rare).
  for (const key of Object.keys(all)) {
    if (!key.startsWith('tier1Run:')) continue;
    if (updates[key]) continue;
    const full = all[key];
    if (!full || !full.id) continue;
    if (!isZombieRunningRec(full, liveId, nowMs)) continue;
    const events = Array.isArray(full.events) ? full.events.slice() : [];
    if (!events.some(e => e && e.message === SWEEP_STALE_MSG)) {
      events.push({
        ts: nowIso,
        phase: 'done',
        featureId: null,
        message: SWEEP_STALE_MSG,
      });
    }
    updates[key] = {
      ...full,
      status: 'interrupted',
      running: false,
      finalized: true,
      sweptAt: nowIso,
      events,
    };
  }
  if (listDirty) updates.tier1Runs = nextList;
  if (Object.keys(updates).length) await chrome.storage.local.set(updates);
  // SET 64 — metasupercache boot audit rides the stale-sweep pass.
  try { await auditMetaSbRing(); } catch { /* audit must never break the sweep */ }
}

// SET 64 — metasupercache caps (mirror of src/tier1/metaSb.ts; SW imports nothing).
const META_SB_EPOCH_CAP_BYTES = 256 * 1024;
const META_SB_RING = 4;
const META_SB_TOTAL_CAP_BYTES = 1024 * 1024;
const META_SB_REFUSE_WRITE_BYTES = META_SB_EPOCH_CAP_BYTES * 2;

/**
 * SET 64 — enforce the 4-epoch ring + 1MB total across metaSb:* keys.
 * Oldest-first eviction by index order; orphan keys (not in the index) are
 * adopted into the index front so they age out first. Idempotent.
 */
async function auditMetaSbRing() {
  const all = await chrome.storage.local.get(null);
  const keys = Object.keys(all).filter(k => k.startsWith('metaSb:'));
  if (!keys.length) return;
  const index = Array.isArray(all.metaSbIndex) ? all.metaSbIndex.filter(id => keys.includes(`metaSb:${id}`)) : [];
  const orphans = keys.map(k => k.slice('metaSb:'.length)).filter(id => !index.includes(id));
  let ordered = [...orphans, ...index];
  const bytesFor = id => {
    try { return JSON.stringify(all[`metaSb:${id}`]).length; } catch { return 0; }
  };
  const evict = [];
  while (ordered.length > META_SB_RING) evict.push(ordered.shift());
  while (ordered.length > 1 && ordered.reduce((a, id) => a + bytesFor(id), 0) > META_SB_TOTAL_CAP_BYTES) {
    evict.push(ordered.shift());
  }
  if (evict.length) await chrome.storage.local.remove(evict.map(id => `metaSb:${id}`));
  const currentIndex = Array.isArray(all.metaSbIndex) ? all.metaSbIndex : [];
  if (evict.length || currentIndex.length !== ordered.length) {
    await chrome.storage.local.set({ metaSbIndex: ordered });
  }
}

/** SET 56a — live truth from offscreen only; never creates the document. */
async function queryTier1Live() {
  try {
    if (typeof chrome.runtime.getContexts === 'function') {
      const ctxs = await chrome.runtime.getContexts({ contextTypes: ['OFFSCREEN_DOCUMENT'] });
      if (!ctxs.length) return { liveId: null, running: false };
    }
    const st = await chrome.runtime.sendMessage({
      action: 'tier1:status',
      target: 'offscreen-tier1',
    });
    const running = !!st?.running;
    return {
      liveId: running && st?.id ? String(st.id) : null,
      running,
    };
  } catch {
    return { liveId: null, running: false };
  }
}

async function ensureOffscreen() {
  try {
    await chrome.offscreen.createDocument({
      url: OFFSCREEN_URL,
      reasons: ['BLOBS'],
      justification: 'Write deployable artifact files into the user-granted local folder via the File System Access API.',
    });
  } catch (e) {
    const msg = e instanceof Error ? e.message : String(e);
    if (!/already exists|Only a single offscreen/i.test(msg)) throw e;
  }
}

async function forwardDeploy(msg) {
  await ensureOffscreen();
  return chrome.runtime.sendMessage({ ...msg, target: 'offscreen-deploy' });
}

// Follow-the-loaded-model: resolve what the endpoint would ACTUALLY run
// right now (LM Studio's loaded model), cached briefly for the meta poll.
let modelCache = { key: '', value: '', ts: 0 };
async function resolveActiveModelCached(p) {
  if (p.type !== 'openai-compat') return p.modelOverride || '';
  if (p.modelOverride) return p.modelOverride; // explicit pin wins
  const base = (p.baseURL || 'http://localhost:1234/v1').replace(/\/$/, '');
  if (modelCache.key === base && Date.now() - modelCache.ts < 10_000) return modelCache.value;
  try {
    const res = await fetch(`${base}/models`, p.apiKey ? { headers: { Authorization: `Bearer ${p.apiKey}` } } : {});
    if (!res.ok) return '';
    const j = await res.json();
    const id = j?.data?.[0]?.id || '';
    modelCache = { key: base, value: id, ts: Date.now() };
    return id;
  } catch { return ''; }
}

async function forwardTier1(msg) {
  await ensureOffscreen();
  return chrome.runtime.sendMessage({ ...msg, target: 'offscreen-tier1' });
}

// ── Direct provider verification (web runtime) ───────────────────────────────
// Mirrors the local server's verifyOpenAiCompat/verifyClaude: an "active"
// provider is one that answered, not one that was merely saved.

async function verifyProviderDirect(p) {
  if (p.type === 'mock') return;
  if (p.type === 'openai-compat') {
    const base = (p.baseURL || 'http://localhost:1234/v1').replace(/\/$/, '');
    const headers = p.apiKey ? { Authorization: `Bearer ${p.apiKey}` } : {};
    let res;
    try {
      res = await fetch(`${base}/models`, { headers });
    } catch (e) {
      throw new Error(`cannot reach ${base} (${e.message}) — is the server running?`);
    }
    if (res.status === 401 || res.status === 403) throw new Error(`endpoint rejected the API key (HTTP ${res.status})`);
    if (!res.ok) throw new Error(`endpoint answered HTTP ${res.status}`);
    return;
  }
  if (p.type === 'claude') {
    if (!p.apiKey) throw new Error('no API key stored');
    let res;
    try {
      res = await fetch('https://api.anthropic.com/v1/models?limit=1', {
        headers: {
          'x-api-key': p.apiKey,
          'anthropic-version': '2023-06-01',
          'anthropic-dangerous-direct-browser-access': 'true',
        },
      });
    } catch (e) {
      throw new Error(`cannot reach api.anthropic.com (${e.message})`);
    }
    if (res.status === 401 || res.status === 403) throw new Error('Anthropic rejected the API key');
    if (!res.ok) throw new Error(`Anthropic answered HTTP ${res.status}`);
    return;
  }
  throw new Error(`unknown provider type: ${p.type}`);
}

// ── LLM proxy implementation ─────────────────────────────────────────────────

async function proxyLlmCall(provider, payload) {
  if (provider.type === 'mock') {
    return { choices: [{ message: { content: '{"files":[]}' } }] };
  }

  if (provider.type === 'openai-compat') {
    const base = (provider.baseURL || 'http://localhost:1234/v1').replace(/\/$/, '');
    const headers = { 'Content-Type': 'application/json' };
    if (provider.apiKey) headers['Authorization'] = `Bearer ${provider.apiKey}`;
    const res = await fetch(`${base}/chat/completions`, {
      method: 'POST', headers, body: JSON.stringify(payload),
    });
    if (!res.ok) throw new Error(`Provider ${res.status}`);
    return res.json();
  }

  if (provider.type === 'claude') {
    if (!provider.apiKey) throw new Error('No API key set for Claude provider');
    const res = await fetch('https://api.anthropic.com/v1/messages', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'x-api-key': provider.apiKey,
        'anthropic-version': '2023-06-01',
      },
      body: JSON.stringify(payload),
    });
    if (!res.ok) throw new Error(`Anthropic ${res.status}`);
    return res.json();
  }

  throw new Error(`Unknown provider type: ${provider.type}`);
}
