diff --git a/server/lib/README.md b/server/lib/README.md index 4516759b07..6536a8c8ab 100644 --- a/server/lib/README.md +++ b/server/lib/README.md @@ -177,6 +177,7 @@ The barrel `server/lib/index.js` is a machine-checkable enumeration of every pub | `harnessOutput.js` | Parsers for what a coding-agent HARNESS prints about itself: `parseHarnessVersion(stdout)` (the one semver run in a `--version` banner, `null` when unparseable), `compareHarnessVersions(a, b)` (the null-guarding wrapper around `versionUtils.js#compareSemver` — `null` when either side is unparseable, so a version that did not parse never reads as "out of date"), `parseHarnessModels(harnessId, stdout)` + `HARNESS_MODEL_PARSER_IDS` (OpenCode's `provider/model` lines and Grok's bulleted list are parsed here; Antigravity and Cursor DELEGATE to `antigravity.js#parseAntigravityModelList` / `aiToolkit/internal/cursor.js#parseCursorModelList`, which the provider-card refresh has used for far longer), `MAX_MODELS`, and `parseNpmLatestVersion`. Pure: the service layer runs the child and hands the captured stdout here, so the vendor output shapes are pinned by table-driven tests instead of by running six real binaries in CI. Model ids come back in the exact spelling `--model` takes — namespaces kept where the vendor keeps them. Consumed by `services/providerRuntimeInstaller.js` and `services/harnesses.js`. | | `providerGateways.js` | `PROVIDER_GATEWAYS` — one row per hosted OpenAI-compatible gateway an OpenCode CLI/TUI wrapper can front-end (`orcarouter`, `openrouter`), plus `PROVIDER_GATEWAY_IDS`, `gatewayById`, `isGatewayNamespace(ns)` and `gatewayForProvider(config)` → row or null. Each row's `id` is simultaneously the OpenCode provider namespace, the `gatewayBacked` marker value, and the id of the sibling `api` record that owns the key — so the sibling lookup is `providers[gateway.id]` and an OrcaRouter key can never satisfy an OpenRouter wrapper. Replaces the `orcarouterBacked` boolean + literal `'orcarouter'` that had been hand-copied across ~15 server and client files (namespace resolution, the OpenCode config builder, both zod schemas, the model-fetcher table, the sibling-key attach, the prerequisite check, and the two "not a local runtime" carve-outs in `cliChildEnv.js`/`localProviderRuntime.js`). Reads the legacy per-gateway boolean FOREVER, so stored records are never rewritten. Distinct from a local runtime (`ollamaBacked`, `vllmBacked`, …): remote, always authenticating, and no thinking toggle. Deliberately mirrored in `aiToolkit/internal/gateways.js` (the vendored toolkit may not import out) and `client/src/utils/providers.js` (the browser cannot import server code) — `providerGateways.parity.test.js` fails when the first two drift. Dependency-light: imports nothing. | | `providerTranscriptUsage.js` | Parsers for the session files the coding CLIs write to disk (0 tokens to read) — `parseClaudeTranscript` (`~/.claude/projects//*.jsonl`), `parseCodexRollout` (`~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl`), `parseGrokTurns`/`parseGrokChatHistory`/`decodeGrokSessionDir` (`~/.grok/sessions///`), `parseAgyTranscript`/`parseAgyHistory` (`~/.gemini/antigravity-cli/`), `claudeProjectSlug`, `totalTranscriptTokens`. Each de-duplicates a format hazard that otherwise inflates counts badly: Claude repeats one response across several lines sharing a `message.id`, Codex's `total_token_usage` is cumulative and repeated, grok's `turn_completed.usage` has shipped in both per-prompt and cumulative shapes (detected and delta'd, never summed raw) while its `_meta.totalTokens` is context occupancy and never billed. Antigravity writes no token fields at all, so its parser returns chars for the caller to estimate from. Each parser returns per-model buckets (`byModel`) plus the message keys it counted (`countedKeys`), and accepts an `exclude` set — that is what stops two overlapping PortOS runs from both billing the same messages. Tolerant of truncated (mid-write) files; consumed by `services/usageReconciler.js`. | +| `opencodeCatalogCache.js` | Primes the on-disk catalog `opencode models` prints from — `primeOpencodeCatalogCache()` fetches OpenCode's `api.json` with Node's fetch and atomically writes `$XDG_CACHE_HOME/opencode/models.json` (`~/.cache` when unset). OpenCode refreshes that file from a forked task whose failures it swallows (`opencode models --refresh` still prints `Models cache refreshed`) and its HTTP client has no Happy Eyeballs, so a host advertising an unreachable IPv6 default route freezes the catalog indefinitely while other machines on the same account list newer models. Refuses to fetch or write when `OPENCODE_MODELS_PATH` / a custom `OPENCODE_MODELS_URL` / `OPENCODE_DISABLE_MODELS_FETCH` means PortOS cannot be sure which file OpenCode reads, when the file is under five minutes old, or when the body did not parse as a catalog — a stale list beats an empty picker. Never throws; the caller probes either way. | | `opencodeConfig.js` | OpenCode config builder — `buildOpencodeEnvVars(provider, model)` builds dynamic `OPENCODE_CONFIG_CONTENT` declaring model ids under the namespace the provider's marker selects: a local runtime (`ollama` / `mtplx` / `llama` / `vllm` / `sglang`, bare ids) or a hosted gateway from `providerGateways.js` (`vendor/model` ids kept whole). Fixes --model rejection. Also attaches the key for a key-bearing namespace, and pins `small_model` to the run model for a gateway so OpenCode's own side calls (titles, summarization) can't land on its built-in default — a billed model the operator never chose. | | `localProviderRuntime.js` | Which LOCAL daemon a provider talks to, and where — `LOCAL_RUNTIMES` (llama.cpp / Ollama / LM Studio / MTPLX / vLLM: label, binary, canonical base URL read from `opencodeConfig.js` rather than re-typed, manage/docs links, model-download hint), `localBackendForProvider` + `localEndpointPort` + `isLocalInstanceHost` (moved here from `services/localModelHealing.js`, which re-exports them, so the healing path and the readiness checklist classify a provider identically — loopback/bind-all only, so a LAN/Tailscale peer on port 11434 is NOT claimed as a local daemon), `localRuntimeKind(provider)` (the `*Backed` markers first, then that classifier; `orcarouter` excluded as a remote API), `localRuntimeForProvider(provider)` → the row with the endpoint the provider ITSELF configures (`OPENCODE_CONFIG_CONTENT`'s `baseURL`, `ANTHROPIC_BASE_URL`, or `endpoint`), then the `OLLAMA_URL`/`OLLAMA_HOST`/`LM_STUDIO_URL` override the backend managers read, then the canonical default — and `null` when that resolved endpoint fails `isLocalInstanceEndpoint` (an API provider on another machine has no local daemon to check, whatever its name says) — plus `normalizeOpenAiBaseUrl`. Pure; the probing half is `services/providerReadiness.js`. Optional `setupStateDetail` overrides `providerReadiness`'s per-state prose for a runtime whose local setup is not a model cache (vLLM's is a compose project); `standbyWhenStopped` marks an installed runtime such as llama.cpp whose stopped state is intentional standby rather than incomplete setup. | | `managedDaemon.js` | Shared mechanism for the local daemons PortOS runs as optional PM2 processes (`services/llamaServerManager.js` → `portos-llama-server`, `services/mtplxServerManager.js` → `portos-mtplx`, `services/slotstreamServerManager.js` → `portos-slotstream`). Owns their PM2 process names — `LLAMA_APP`, `MTPLX_APP`, `SLOTSTREAM_APP`, and the `isModelServerProcess(name)` predicate over them — so a caller like the CoS health monitor can recognize a model server without importing a manager; the managers re-export those names. `createDaemonWatcher({...})` supplies the common PM2 launch-line re-adoption, endpoint probe, status skeleton, bounded log view, and port-release wait while managers retain daemon-specific parsing and lifecycle policy. `createDaemonLogBuffer({maxLines?})` is the bounded timestamped ring buffer of what PortOS logged around a launch, plus `withPm2Logs(output)` → that buffer followed by anything `pm2 logs` has which it doesn't already hold, deduped and re-capped (PM2's lines are a VIEW, never folded into the buffer — PM2 owns them and re-reads them every status call). `pm2ArgValue(args, flag)` reads one value back out of a PM2 process's recorded argv so a manager can recover a still-online daemon's launch config after a PortOS restart; `null` means the flag was absent, which a relaunch must leave off rather than defaulting. Also the shared **idle reaper**, for a daemon that cannot release its weights any other way: `registerIdleDaemon({name, getIdleMs, stop})` (seeds `lastUsedAt` to NOW, so a hand-started daemon gets a full window), `markDaemonUsed(name)` — call on real traffic, NEVER on a status poll — `daemonLastUsedAt(name)`, `idleWindowMs(minutes)` (minutes → ms; `0` = never, `null` = not configured, kept distinct), `reapIdleDaemons(now?)` → the names stopped, and `startIdleReaper({intervalMs?})` / `stopIdleReaper()` (ONE interval for all registrants, `unref`'d, idempotent). `mtplxServerManager` and `slotstreamServerManager` register: llama.cpp releases its checkpoint in place via `--sleep-idle-seconds` and must NOT be stopped for it. Deliberately mechanism only — what a launch line means and when a daemon may start is exactly what differs between the two. | diff --git a/server/lib/index.js b/server/lib/index.js index 6e9a43f3f2..f44be2b575 100644 --- a/server/lib/index.js +++ b/server/lib/index.js @@ -184,6 +184,7 @@ export * from './quotaBurnValidation.js'; export * from './quotaReset.js'; export * from './quotaWindows.js'; export * from './recurrenceValidation.js'; +export * from './opencodeCatalogCache.js'; export * from './opencodeConfig.js'; export * from './localProviderRuntime.js'; export * from './mtplxModels.js'; diff --git a/server/lib/opencodeCatalogCache.js b/server/lib/opencodeCatalogCache.js new file mode 100644 index 0000000000..9fb148aa8e --- /dev/null +++ b/server/lib/opencodeCatalogCache.js @@ -0,0 +1,128 @@ +/** + * Primes OpenCode's on-disk model catalog, so a "Refresh models" click reports + * what OpenCode actually offers today rather than what it offered the last time + * its own background fetch happened to succeed. + * + * **The failure this exists for.** `opencode models` does NOT go to the network: + * it reads `$XDG_CACHE_HOME/opencode/models.json` (falling back to + * `~/.cache/opencode/models.json`) and prints the models it can resolve out of + * that file. OpenCode keeps that file current from a task forked at startup + * that re-fetches when the file is older than five minutes — but the fetch is + * wrapped in `ignore`, so when it fails, nothing surfaces: `opencode models + * --refresh` still prints `Models cache refreshed` and still lists the stale + * catalog. On a host where that fetch keeps failing, the file freezes at + * whatever day it last worked and every consumer — OpenCode's own TUI, and the + * PortOS Harnesses page reading through it — silently shows a weeks-old model + * list while a second machine on the same account shows the current one. + * + * That fetch fails for an ordinary reason: OpenCode's HTTP client connects + * without Happy Eyeballs, so a host advertising an IPv6 default route it cannot + * actually reach (a VPN interface installing one, with no global v6 address) + * fails instantly with "Unable to connect" while `curl` and Node — both of + * which fall back to IPv4 — fetch the same URL fine. PortOS is Node, so + * fetching the catalog HERE and handing OpenCode the file it wanted is enough + * to unstick it, for the Harnesses page and for the vendor's own TUI alike. + * + * **This writes another tool's cache file, so it is narrow on purpose.** It + * refuses whenever it cannot be certain which file OpenCode would read + * (`OPENCODE_MODELS_PATH` pins a different one; a custom `OPENCODE_MODELS_URL` + * moves the cache to a name derived from a hash of that URL) or whenever the + * user has opted out of catalog fetching (`OPENCODE_DISABLE_MODELS_FETCH`), and + * it only ever replaces the file with a payload that parsed as a real catalog. + * A refusal is not an error: the caller probes anyway and gets today's stale + * answer, exactly as before this module existed. + * + * No AI provider call happens here — this is a vendor catalog endpoint, the + * same class of read as `npm view`, and it runs only from an explicit click. + */ + +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { stat } from 'node:fs/promises'; + +import { atomicWrite } from './fileUtils.js'; + +/** Where OpenCode fetches its catalog from, when `OPENCODE_MODELS_URL` says nothing else. */ +const OPENCODE_CATALOG_URL = 'https://models.opencode.ai'; + +/** + * How old the cache may be before it is worth re-fetching — OpenCode's own + * staleness window. Matching it keeps a double-click from pulling several MB + * twice while still meaning "fresh" for anyone who clicked the button. + */ +const CATALOG_MAX_AGE_MS = 5 * 60 * 1000; + +/** A multi-megabyte JSON body over a slow link must not hold the click open. */ +const CATALOG_FETCH_TIMEOUT_MS = 20_000; + +/** + * The catalog file `opencode models` will read, or `null` when PortOS cannot be + * sure which file that is (see the module note). `null` is a REFUSAL, not a + * failure — the caller carries on and probes the harness regardless. + */ +const catalogCachePath = (env) => { + // The user pinned an explicit catalog file; it is theirs, not a cache. + if (env.OPENCODE_MODELS_PATH) return null; + // Catalog fetching is switched off — priming it would be exactly the network + // read that setting exists to prevent. + if (env.OPENCODE_DISABLE_MODELS_FETCH) return null; + // A custom endpoint moves the cache to `models-.json`, and the + // hash is OpenCode's internal one. Guessing the filename would leave a file + // it never reads. + if (env.OPENCODE_MODELS_URL && env.OPENCODE_MODELS_URL !== OPENCODE_CATALOG_URL) return null; + const home = homedir(); + const cacheRoot = env.XDG_CACHE_HOME || (home ? join(home, '.cache') : null); + return cacheRoot ? join(cacheRoot, 'opencode', 'models.json') : null; +}; + +/** + * Is this text a catalog, rather than an error page or a truncated body? + * + * The point is the REFUSAL: overwriting a working catalog with a gateway error + * page would take the user from a stale model list to an empty one. A catalog is + * a JSON object keyed by provider id, each entry carrying a `models` object. + * Async so a malformed body answers `false` instead of throwing. + */ +const isCatalogPayload = async (text) => { + const parsed = await Promise.resolve().then(() => JSON.parse(text)).catch(() => null); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return false; + return Object.values(parsed).some((entry) => entry && typeof entry === 'object' && typeof entry.models === 'object'); +}; + +/** + * Fetch OpenCode's model catalog and write it where `opencode models` reads it, + * unless it is already fresh or this host is one of the refusal cases. + * + * Never throws: every outcome is a reason string, because the caller's job (probe + * the harness) is still worth doing when priming was skipped or failed. + * + * @param {object} [deps] + * @param {Record} [deps.env] + * @param {typeof fetch} [deps.fetchImpl] + * @returns {Promise<{primed: boolean, reason: string, path?: string}>} + */ +export async function primeOpencodeCatalogCache({ env = process.env, fetchImpl = fetch } = {}) { + const path = catalogCachePath(env); + if (!path) return { primed: false, reason: 'this install reads its catalog from somewhere PortOS should not write' }; + + // A missing file is not an error — OpenCode falls back to the catalog built + // into the binary — but it IS a reason to fetch, so absence and staleness + // take the same branch. + const mtimeMs = await stat(path).then((s) => s.mtimeMs, () => null); + if (mtimeMs !== null && Date.now() - mtimeMs < CATALOG_MAX_AGE_MS) { + return { primed: false, reason: 'catalog is already fresh', path }; + } + + const url = `${OPENCODE_CATALOG_URL}/api.json`; + const text = await fetchImpl(url, { signal: AbortSignal.timeout(CATALOG_FETCH_TIMEOUT_MS) }) + .then((res) => (res.ok ? res.text() : null)) + .catch(() => null); + if (text === null) return { primed: false, reason: `could not reach ${url}`, path }; + // Parse failures land here too — a body that is not JSON is not a catalog. + if (!(await isCatalogPayload(text))) return { primed: false, reason: `${url} did not return a model catalog`, path }; + + const written = await atomicWrite(path, text).then(() => true, () => false); + return written + ? { primed: true, reason: `wrote ${Buffer.byteLength(text)} bytes`, path } + : { primed: false, reason: `could not write ${path}`, path }; +} diff --git a/server/lib/opencodeCatalogCache.test.js b/server/lib/opencodeCatalogCache.test.js new file mode 100644 index 0000000000..a11c63bcdd --- /dev/null +++ b/server/lib/opencodeCatalogCache.test.js @@ -0,0 +1,98 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtemp, readFile, utimes } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { primeOpencodeCatalogCache } from './opencodeCatalogCache.js'; + +const CATALOG = JSON.stringify({ + opencode: { id: 'opencode', name: 'OpenCode Zen', models: { 'muse-spark-1.3-contributor-free': { id: 'muse-spark-1.3-contributor-free' } } }, +}); + +const respondWith = (body, ok = true) => async () => ({ ok, text: async () => body }); + +let cacheHome; +let env; +let cachePath; + +/** Seed a catalog on disk and age it past the staleness window. */ +const seedStaleCatalog = async (body = CATALOG) => { + await primeOpencodeCatalogCache({ env, fetchImpl: respondWith(body) }); + const longAgo = new Date(Date.now() - 60 * 60 * 1000); + await utimes(cachePath, longAgo, longAgo); +}; + +beforeEach(async () => { + cacheHome = await mkdtemp(join(tmpdir(), 'opencode-catalog-')); + env = { XDG_CACHE_HOME: cacheHome }; + cachePath = join(cacheHome, 'opencode', 'models.json'); +}); + +describe('primeOpencodeCatalogCache', () => { + // The path is the whole point: `opencode models` prints from this exact file, + // so a catalog written anywhere else is a catalog the harness never reads. + it('writes the fetched catalog where the harness will read it', async () => { + const fetchImpl = vi.fn(respondWith(CATALOG)); + + const result = await primeOpencodeCatalogCache({ env, fetchImpl }); + + expect(result.primed).toBe(true); + expect(fetchImpl.mock.calls[0][0]).toBe('https://models.opencode.ai/api.json'); + await expect(readFile(cachePath, 'utf8')).resolves.toBe(CATALOG); + }); + + // OpenCode's own staleness window. Without it, a double-click pulls several + // megabytes twice for an answer that cannot have changed. + it('leaves a cache younger than the max age alone', async () => { + await primeOpencodeCatalogCache({ env, fetchImpl: respondWith(CATALOG) }); + const fetchImpl = vi.fn(respondWith(CATALOG)); + + const result = await primeOpencodeCatalogCache({ env, fetchImpl }); + + expect(result.primed).toBe(false); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('re-fetches once the cache has aged past the max age', async () => { + await seedStaleCatalog('{"old":{"models":{}}}'); + + const result = await primeOpencodeCatalogCache({ env, fetchImpl: respondWith(CATALOG) }); + + expect(result.primed).toBe(true); + await expect(readFile(cachePath, 'utf8')).resolves.toBe(CATALOG); + }); + + // The failure that matters: a stale-but-real catalog is a far better answer + // than an empty picker, so nothing but a parsed catalog may replace it. + it.each([ + ['an unreachable endpoint', async () => { throw new Error('Unable to connect'); }], + ['a non-2xx response', respondWith('nope', false)], + ['a gateway error page', respondWith('502 Bad Gateway')], + ['a truncated body', respondWith('{"opencode":{"models":')], + ['valid JSON that is not a catalog', respondWith('{}')], + ])('keeps the existing catalog on %s', async (_label, fetchImpl) => { + await seedStaleCatalog(); + + const result = await primeOpencodeCatalogCache({ env, fetchImpl }); + + expect(result.primed).toBe(false); + await expect(readFile(cachePath, 'utf8')).resolves.toBe(CATALOG); + }); + + // Each of these means PortOS cannot be certain which file OpenCode reads (or + // that it should read one at all) — writing anyway would leave a file OpenCode + // ignores, or override an explicit opt-out. + it.each([ + ['OPENCODE_MODELS_PATH pins another file', { OPENCODE_MODELS_PATH: '/somewhere/models.json' }], + ['OPENCODE_DISABLE_MODELS_FETCH opts out', { OPENCODE_DISABLE_MODELS_FETCH: '1' }], + ['OPENCODE_MODELS_URL names a mirror', { OPENCODE_MODELS_URL: 'https://mirror.example.com' }], + ])('neither fetches nor writes when %s', async (_label, overrides) => { + const fetchImpl = vi.fn(respondWith(CATALOG)); + + const result = await primeOpencodeCatalogCache({ env: { ...env, ...overrides }, fetchImpl }); + + expect(result.primed).toBe(false); + expect(fetchImpl).not.toHaveBeenCalled(); + await expect(readFile(cachePath, 'utf8')).rejects.toThrow(); + }); +}); diff --git a/server/services/harnesses.js b/server/services/harnesses.js index 00359c4e28..36cc97c1c3 100644 --- a/server/services/harnesses.js +++ b/server/services/harnesses.js @@ -33,6 +33,7 @@ import { prepareCliSpawn } from '../lib/bufferedSpawn.js'; import { commandOutput } from '../lib/commandExists.js'; import { compareHarnessVersions, parseHarnessModels, parseNpmLatestVersion } from '../lib/harnessOutput.js'; import { findCommandOnPath } from '../lib/processEnv.js'; +import { primeOpencodeCatalogCache } from '../lib/opencodeCatalogCache.js'; import { getOpencodeLocalProviderNamespace, isConfiguredDefaultModel } from '../lib/providerModels.js'; import { providerRuntimeKey } from '../lib/providerPrerequisites.js'; import { createStaleWhileRevalidate } from '../lib/staleWhileRevalidate.js'; @@ -269,6 +270,17 @@ export async function refreshHarnessModels(id, { run = commandOutput, ...probeDe return { ok: false, reason: `${runtime.label} is not installed on this host.`, models: [], updated: [] }; } + // `opencode models` prints from an on-disk catalog OpenCode refreshes on its + // own — silently, and not at all on a host where its fetch fails (see + // `lib/opencodeCatalogCache.js`). Without this the button faithfully re-reads + // a catalog frozen weeks ago and reports success, while the same account on + // another machine lists models this one has never heard of. Best-effort by + // design: a refusal or a failed fetch leaves the probe below unchanged. + if (runtime.id === 'opencode') { + const catalog = await primeOpencodeCatalogCache(); + console.log(`📚 ${runtime.label} catalog: ${catalog.primed ? 'refreshed' : 'left alone'} — ${catalog.reason}`); + } + // Resolve and `prepareCliSpawn` exactly as the version probe does. An // npm-installed harness is a `.cmd` shim on Windows, which `execFile` under // `shell: false` refuses outright — the probe would answer nothing and the diff --git a/server/services/harnesses.test.js b/server/services/harnesses.test.js index 16963614aa..78d73d8702 100644 --- a/server/services/harnesses.test.js +++ b/server/services/harnesses.test.js @@ -4,6 +4,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const npmGlobalBin = vi.hoisted(() => ({ adoptNpmGlobalBinDir: vi.fn(async () => null) })); vi.mock('../lib/npmGlobalBin.js', () => npmGlobalBin); +// `opencode models` reads a cache file this module primes over the network. +// Unmocked, the suite would fetch a multi-megabyte catalog and overwrite the +// developer's real `~/.cache/opencode/models.json`. +const opencodeCatalog = vi.hoisted(() => ({ + primeOpencodeCatalogCache: vi.fn(async () => ({ primed: true, reason: 'stubbed' })), +})); +vi.mock('../lib/opencodeCatalogCache.js', () => opencodeCatalog); + const providerService = vi.hoisted(() => ({ // `listProviders()` resolves the records as an ARRAY — the envelope // (`{ activeProvider, providers: [...] }`) is `getAllProviders`'s shape, and @@ -46,6 +54,7 @@ beforeEach(() => { __resetLatestVersionCache(); providerService.listProviders.mockResolvedValue(Object.values(providers)); providerService.updateProvider.mockClear(); + opencodeCatalog.primeOpencodeCatalogCache.mockClear(); }); // `usesHarnessCatalog` keys on the ABSENCE of a backend marker, so the class it @@ -197,6 +206,26 @@ describe('refreshHarnessModels', () => { expect(run).toHaveBeenCalledWith('/example/opencode', ['models'], expect.anything()); }); + // `opencode models` prints from an on-disk catalog OpenCode refreshes on its + // own, silently and — on a host whose IPv6 default route goes nowhere — not at + // all. Without this step the button re-reads a catalog frozen weeks ago and + // still reports success, so a model another machine already lists never + // appears here. + it('primes the OpenCode catalog before probing, and only for OpenCode', async () => { + const run = vi.fn(async (command, args) => (args[0] === 'models' ? OPENCODE_MODELS : '1.18.27')); + + await refreshHarnessModels('opencode', { run, ...found }); + expect(opencodeCatalog.primeOpencodeCatalogCache).toHaveBeenCalledTimes(1); + + // Every other harness enumerates its models live; there is no file to prime, + // and reaching for OpenCode's would be writing a cache nothing here reads. + await refreshHarnessModels('grok', { + run: async (command, args) => (args[0] === 'models' ? 'Available models:\n - grok-4.5\n' : 'grok 1.0.13'), + ...found, + }); + expect(opencodeCatalog.primeOpencodeCatalogCache).toHaveBeenCalledTimes(1); + }); + it('writes the harness catalog only to providers that draw from it', async () => { const run = vi.fn(async (command, args) => (args[0] === 'models' ? OPENCODE_MODELS : '1.18.27'));