From 811f908918b31c5e225dcb03c41da6c36434414d Mon Sep 17 00:00:00 2001 From: K2 Date: Thu, 23 Jul 2026 11:11:51 +0100 Subject: [PATCH 1/5] fix: allow extension to load when offline The model fetch at startup throws and blocks pi from starting when there's no network. Catch the error and register the provider with an empty model list instead. --- CHANGELOG.md | 6 ++++++ index.ts | 9 +++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ff5c35..3d5207f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased +- Allow pi to start when the model list fetch fails due to no network connectivity. Command Code models are fetched live when online; if the provider starts offline, run `/reload` once connected to refresh the model catalog. + +### Contributors + +- @k3-2o — reported that the model-list fetch blocked pi startup when offline. + ## 0.4.2 - 2026-07-05 - Fix Oh My Pi extension validation by avoiding the missing `calculateCost` export from OMP's legacy `pi-ai` shim. diff --git a/index.ts b/index.ts index b7fb6d9..5fa47e1 100644 --- a/index.ts +++ b/index.ts @@ -17,7 +17,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent" import { COMMAND_CODE_CLI_VERSION, createStreamCommandCode, DEFAULT_API_BASE } from "./src/core.ts" import { calculateCommandCodeCost } from "./src/cost.ts" -import { DEFAULT_MODELS_URL, fetchCommandCodeModels } from "./src/models.ts" +import { DEFAULT_MODELS_URL, fetchCommandCodeModels, type CommandCodeModel } from "./src/models.ts" import { getApiKey, login, refreshToken } from "./src/oauth.ts" const API_BASE = process.env.COMMANDCODE_API_BASE ?? DEFAULT_API_BASE @@ -79,7 +79,12 @@ const streamCommandCode = createStreamCommandCode({ // --------------------------------------------------------------------------- export default async function (pi: ExtensionAPI) { - const models = await fetchCommandCodeModels({ url: MODELS_URL }) + let models: readonly CommandCodeModel[] + try { + models = await fetchCommandCodeModels({ url: MODELS_URL }) + } catch { + models = [] + } pi.registerProvider("commandcode", { name: "Command Code", From 070ef3c07a7cdfe7ca0a9bf5faedde19a2619c67 Mon Sep 17 00:00:00 2001 From: Patrick Wozniak Date: Sun, 2 Aug 2026 01:16:39 +0200 Subject: [PATCH 2/5] fix(models): cache catalog for offline startup Persist the last valid Command Code model catalog and use it when live model discovery fails. Keep first-time offline startup non-fatal, surface clear warnings, and cover cached model selection with unit and pi integration regression tests. --- CHANGELOG.md | 2 +- README.md | 4 +- index.ts | 15 +-- src/models.ts | 137 +++++++++++++++++++++++++-- tests/test-models.ts | 202 +++++++++++++++++++++++++++++++++++----- tests/test-pi-local.mjs | 97 +++++++++++-------- 6 files changed, 381 insertions(+), 76 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d5207f..2abfd55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unreleased -- Allow pi to start when the model list fetch fails due to no network connectivity. Command Code models are fetched live when online; if the provider starts offline, run `/reload` once connected to refresh the model catalog. +- Allow pi to start when model discovery is unavailable. The provider now caches the last successfully fetched model catalog so previously discovered Command Code models remain selectable offline; a first offline start without a cache keeps Command Code unavailable until `/reload` succeeds. ### Contributors diff --git a/README.md b/README.md index a07c288..f1073c9 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,9 @@ On startup, the provider fetches: https://api.commandcode.ai/provider/v1/models ``` -For tests or local mocks, override it with `COMMANDCODE_MODELS_URL`. +The last successfully fetched catalog is cached at `~/.commandcode/pi-models.json`. If model discovery is temporarily unavailable, the provider uses this cached catalog so previously discovered Command Code models remain selectable. On a first offline start without a cache, pi still loads, but Command Code models remain unavailable until the connection is restored and `/reload` succeeds. + +For tests or local mocks, override the endpoint with `COMMANDCODE_MODELS_URL` and the cache location with `COMMANDCODE_MODELS_CACHE`. ## Pricing diff --git a/index.ts b/index.ts index 5fa47e1..0ebe19c 100644 --- a/index.ts +++ b/index.ts @@ -17,11 +17,12 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent" import { COMMAND_CODE_CLI_VERSION, createStreamCommandCode, DEFAULT_API_BASE } from "./src/core.ts" import { calculateCommandCodeCost } from "./src/cost.ts" -import { DEFAULT_MODELS_URL, fetchCommandCodeModels, type CommandCodeModel } from "./src/models.ts" +import { DEFAULT_MODELS_URL, loadCommandCodeModels } from "./src/models.ts" import { getApiKey, login, refreshToken } from "./src/oauth.ts" const API_BASE = process.env.COMMANDCODE_API_BASE ?? DEFAULT_API_BASE const MODELS_URL = process.env.COMMANDCODE_MODELS_URL ?? DEFAULT_MODELS_URL +const MODELS_CACHE_PATH = process.env.COMMANDCODE_MODELS_CACHE type CommandCodeModelCost = { input: number @@ -79,12 +80,12 @@ const streamCommandCode = createStreamCommandCode({ // --------------------------------------------------------------------------- export default async function (pi: ExtensionAPI) { - let models: readonly CommandCodeModel[] - try { - models = await fetchCommandCodeModels({ url: MODELS_URL }) - } catch { - models = [] - } + const { models, warning } = await loadCommandCodeModels({ + url: MODELS_URL, + cachePath: MODELS_CACHE_PATH, + }) + + if (warning) console.warn(`[commandcode] ${warning}`) pi.registerProvider("commandcode", { name: "Command Code", diff --git a/src/models.ts b/src/models.ts index c4be26f..237b003 100644 --- a/src/models.ts +++ b/src/models.ts @@ -1,6 +1,11 @@ +import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises" +import { homedir } from "node:os" +import { dirname, join } from "node:path" + export const DEFAULT_MODELS_URL = "https://api.commandcode.ai/provider/v1/models" const DEFAULT_MAX_OUTPUT_TOKENS = 65_536 +const MODEL_CACHE_VERSION = 1 interface ApiModel { id: string @@ -21,19 +26,39 @@ interface FetchCommandCodeModelsOptions { fetchImpl?: typeof fetch } +interface LoadCommandCodeModelsOptions extends FetchCommandCodeModelsOptions { + cachePath?: string +} + +export interface LoadCommandCodeModelsResult { + models: readonly CommandCodeModel[] + source: "live" | "cache" | "empty" + warning?: string +} + function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null + return typeof value === "object" && value !== null && !Array.isArray(value) } function stringField(record: Record, key: string): string { const value = record[key] - if (typeof value !== "string") throw new Error(`Expected ${key} to be a string`) + if (typeof value !== "string" || value.length === 0) { + throw new Error(`Expected ${key} to be a non-empty string`) + } + return value +} + +function booleanField(record: Record, key: string): boolean { + const value = record[key] + if (typeof value !== "boolean") throw new Error(`Expected ${key} to be a boolean`) return value } -function numberField(record: Record, key: string): number { +function positiveNumberField(record: Record, key: string): number { const value = record[key] - if (typeof value !== "number") throw new Error(`Expected ${key} to be a number`) + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { + throw new Error(`Expected ${key} to be a positive number`) + } return value } @@ -43,10 +68,35 @@ function parseApiModel(value: unknown): ApiModel { return { id: stringField(value, "id"), name: stringField(value, "name"), - contextLength: numberField(value, "context_length"), + contextLength: positiveNumberField(value, "context_length"), + } +} + +function parseCachedModel(value: unknown): CommandCodeModel { + if (!isRecord(value)) throw new Error("Expected cached model entry to be an object") + + return { + id: stringField(value, "id"), + name: stringField(value, "name"), + reasoning: booleanField(value, "reasoning"), + contextWindow: positiveNumberField(value, "contextWindow"), + maxTokens: positiveNumberField(value, "maxTokens"), } } +function requireModels(models: readonly CommandCodeModel[]): readonly CommandCodeModel[] { + if (models.length === 0) throw new Error("Command Code returned an empty model catalog") + return models +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +export function defaultCommandCodeModelsCachePath(): string { + return join(homedir(), ".commandcode", "pi-models.json") +} + export function commandCodeModelsFromApiResponse(value: unknown): readonly CommandCodeModel[] { if (!isRecord(value)) throw new Error("Expected models response to be an object") if (value.object !== "list") throw new Error("Expected models response object to be 'list'") @@ -63,6 +113,16 @@ export function commandCodeModelsFromApiResponse(value: unknown): readonly Comma })) } +export function commandCodeModelsFromCache(value: unknown): readonly CommandCodeModel[] { + if (!isRecord(value)) throw new Error("Expected model cache to be an object") + if (value.version !== MODEL_CACHE_VERSION) { + throw new Error(`Expected model cache version ${MODEL_CACHE_VERSION}`) + } + if (!Array.isArray(value.models)) throw new Error("Expected cached models to be an array") + + return requireModels(value.models.map(parseCachedModel)) +} + export async function fetchCommandCodeModels( options: FetchCommandCodeModelsOptions = {}, ): Promise { @@ -81,5 +141,70 @@ export async function fetchCommandCodeModels( } const body: unknown = await response.json() - return commandCodeModelsFromApiResponse(body) + return requireModels(commandCodeModelsFromApiResponse(body)) +} + +async function readCommandCodeModelsCache(cachePath: string): Promise { + const contents = await readFile(cachePath, "utf-8") + const parsed: unknown = JSON.parse(contents) + return commandCodeModelsFromCache(parsed) +} + +async function writeCommandCodeModelsCache( + cachePath: string, + models: readonly CommandCodeModel[], +): Promise { + await mkdir(dirname(cachePath), { recursive: true }) + const temporaryPath = `${cachePath}.${process.pid}.tmp` + + try { + await writeFile( + temporaryPath, + `${JSON.stringify({ version: MODEL_CACHE_VERSION, models }, null, 2)}\n`, + { encoding: "utf-8", mode: 0o600 }, + ) + await rename(temporaryPath, cachePath) + } finally { + try { + await rm(temporaryPath, { force: true }) + } catch { + // Best-effort cleanup must not hide the original cache write error. + } + } +} + +export async function loadCommandCodeModels( + options: LoadCommandCodeModelsOptions = {}, +): Promise { + const cachePath = options.cachePath ?? defaultCommandCodeModelsCachePath() + + try { + const models = await fetchCommandCodeModels(options) + + try { + await writeCommandCodeModelsCache(cachePath, models) + return { models, source: "live" } + } catch (error) { + return { + models, + source: "live", + warning: `Loaded the live Command Code model catalog but could not update ${cachePath}: ${errorMessage(error)}`, + } + } + } catch (liveError) { + try { + const models = await readCommandCodeModelsCache(cachePath) + return { + models, + source: "cache", + warning: `Could not refresh the Command Code model catalog (${errorMessage(liveError)}). Using the cached catalog from ${cachePath}.`, + } + } catch (cacheError) { + return { + models: [], + source: "empty", + warning: `Could not refresh the Command Code model catalog (${errorMessage(liveError)}), and no valid cached catalog is available at ${cachePath} (${errorMessage(cacheError)}). Command Code models will remain unavailable until /reload succeeds.`, + } + } + } } diff --git a/tests/test-models.ts b/tests/test-models.ts index 15ee3fd..aed6dfe 100644 --- a/tests/test-models.ts +++ b/tests/test-models.ts @@ -1,36 +1,190 @@ import assert from "node:assert/strict" +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" import { describe, it } from "node:test" -import { commandCodeModelsFromApiResponse } from "../src/models.ts" +import { + commandCodeModelsFromApiResponse, + commandCodeModelsFromCache, + loadCommandCodeModels, + type CommandCodeModel, +} from "../src/models.ts" + +const API_RESPONSE = { + object: "list", + data: [ + { + id: "Qwen/Qwen3.7-Max", + object: "model", + created: 1779824324, + owned_by: "command-code", + name: "Qwen 3.7 Max", + context_length: 1_000_000, + }, + ], +} + +const EXPECTED_MODELS: readonly CommandCodeModel[] = [ + { + id: "Qwen/Qwen3.7-Max", + name: "Qwen 3.7 Max (CC)", + reasoning: true, + contextWindow: 1_000_000, + maxTokens: 65_536, + }, +] + +function successfulFetch(): typeof fetch { + return () => + Promise.resolve( + new Response(JSON.stringify(API_RESPONSE), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ) +} + +function failingFetch(message = "offline"): typeof fetch { + return () => Promise.reject(new TypeError(message)) +} + +async function withTemporaryCache( + run: (paths: { directory: string; cachePath: string }) => Promise, +): Promise { + const directory = await mkdtemp(join(tmpdir(), "pi-commandcode-models-")) + try { + await run({ directory, cachePath: join(directory, "models.json") }) + } finally { + await rm(directory, { recursive: true, force: true }) + } +} describe("commandCodeModelsFromApiResponse()", () => { it("converts the Provider API model list to pi models", () => { - const models = commandCodeModelsFromApiResponse({ - object: "list", - data: [ - { - id: "Qwen/Qwen3.7-Max", - object: "model", - created: 1779824324, - owned_by: "command-code", - name: "Qwen 3.7 Max", - context_length: 1_000_000, - }, - ], - }) - - assert.deepEqual(models, [ - { - id: "Qwen/Qwen3.7-Max", - name: "Qwen 3.7 Max (CC)", - reasoning: true, - contextWindow: 1_000_000, - maxTokens: 65_536, - }, - ]) + assert.deepEqual(commandCodeModelsFromApiResponse(API_RESPONSE), EXPECTED_MODELS) }) it("rejects unexpected API shapes", () => { assert.throws(() => commandCodeModelsFromApiResponse({ object: "list", data: [{}] })) }) }) + +describe("commandCodeModelsFromCache()", () => { + it("accepts the current cache format", () => { + assert.deepEqual( + commandCodeModelsFromCache({ version: 1, models: EXPECTED_MODELS }), + EXPECTED_MODELS, + ) + }) + + it("rejects empty, invalid, and unsupported caches", () => { + assert.throws(() => commandCodeModelsFromCache({ version: 1, models: [] })) + assert.throws(() => commandCodeModelsFromCache({ version: 2, models: EXPECTED_MODELS })) + assert.throws(() => + commandCodeModelsFromCache({ + version: 1, + models: [{ ...EXPECTED_MODELS[0], contextWindow: -1 }], + }), + ) + }) +}) + +describe("loadCommandCodeModels()", () => { + it("returns live models and writes a validated cache", async () => { + await withTemporaryCache(async ({ cachePath }) => { + const result = await loadCommandCodeModels({ + cachePath, + fetchImpl: successfulFetch(), + }) + + assert.deepEqual(result, { models: EXPECTED_MODELS, source: "live" }) + assert.deepEqual( + commandCodeModelsFromCache(JSON.parse(await readFile(cachePath, "utf-8"))), + EXPECTED_MODELS, + ) + }) + }) + + it("uses the last valid catalog when the refresh fails", async () => { + await withTemporaryCache(async ({ cachePath }) => { + await loadCommandCodeModels({ cachePath, fetchImpl: successfulFetch() }) + + const result = await loadCommandCodeModels({ + cachePath, + fetchImpl: failingFetch(), + }) + + assert.deepEqual(result.models, EXPECTED_MODELS) + assert.equal(result.source, "cache") + assert.match(result.warning ?? "", /offline/) + assert.match(result.warning ?? "", /Using the cached catalog/) + }) + }) + + it("starts with an empty catalog when offline without a valid cache", async () => { + await withTemporaryCache(async ({ cachePath }) => { + const result = await loadCommandCodeModels({ + cachePath, + fetchImpl: failingFetch(), + }) + + assert.deepEqual(result.models, []) + assert.equal(result.source, "empty") + assert.match(result.warning ?? "", /no valid cached catalog/) + assert.match(result.warning ?? "", /until \/reload succeeds/) + }) + }) + + it("ignores a corrupt cache after a failed refresh", async () => { + await withTemporaryCache(async ({ cachePath }) => { + await writeFile(cachePath, "not json", "utf-8") + + const result = await loadCommandCodeModels({ + cachePath, + fetchImpl: failingFetch(), + }) + + assert.deepEqual(result.models, []) + assert.equal(result.source, "empty") + assert.match(result.warning ?? "", /Unexpected token|JSON/) + }) + }) + + it("keeps live models usable when the cache cannot be written", async () => { + await withTemporaryCache(async ({ directory }) => { + const unwritableCachePath = join(directory, "cache-directory") + await mkdir(unwritableCachePath) + + const result = await loadCommandCodeModels({ + cachePath: unwritableCachePath, + fetchImpl: successfulFetch(), + }) + + assert.deepEqual(result.models, EXPECTED_MODELS) + assert.equal(result.source, "live") + assert.match(result.warning ?? "", /could not update/) + }) + }) + + it("falls back to cache for HTTP and response parsing failures", async () => { + await withTemporaryCache(async ({ cachePath }) => { + await loadCommandCodeModels({ cachePath, fetchImpl: successfulFetch() }) + + for (const fetchImpl of [ + (() => Promise.resolve(new Response("boom", { status: 500 }))) as typeof fetch, + (() => + Promise.resolve( + new Response("not json", { + status: 200, + headers: { "content-type": "application/json" }, + }), + )) as typeof fetch, + ]) { + const result = await loadCommandCodeModels({ cachePath, fetchImpl }) + assert.deepEqual(result.models, EXPECTED_MODELS) + assert.equal(result.source, "cache") + } + }) + }) +}) diff --git a/tests/test-pi-local.mjs b/tests/test-pi-local.mjs index 617025a..ffe1687 100644 --- a/tests/test-pi-local.mjs +++ b/tests/test-pi-local.mjs @@ -6,24 +6,16 @@ import assert from "node:assert/strict" import { spawn, spawnSync } from "node:child_process" -import { - accessSync, - constants, - existsSync, - mkdirSync, - mkdtempSync, - rmSync, - writeFileSync, -} from "node:fs" +import { accessSync, constants, mkdtempSync, rmSync } from "node:fs" import { createServer } from "node:http" -import { homedir, tmpdir } from "node:os" +import { tmpdir } from "node:os" import { delimiter, dirname, join, resolve } from "node:path" import { fileURLToPath } from "node:url" const __dirname = dirname(fileURLToPath(import.meta.url)) const PROJECT_DIR = resolve(__dirname, "..") const EXT_PATH = resolve(PROJECT_DIR, "index.ts") -const TEST_MODEL = "deepseek/deepseek-v4-flash" +const TEST_MODEL = "cc-offline-cache-model" function findPiBinary() { if (process.env.PI_BIN) return process.env.PI_BIN @@ -77,7 +69,7 @@ const server = createServer((req, res) => { context_length: 1_000_000, }, { - id: "Qwen/Qwen3.7-Max", + id: "cc-second-model", object: "model", created: 1779824324, owned_by: "command-code", @@ -132,32 +124,15 @@ const address = server.address() const port = typeof address === "object" && address ? address.port : 0 const apiBase = `http://127.0.0.1:${port}` -function hasLivePiAuth() { - return ( - !!process.env.COMMANDCODE_API_KEY || - existsSync(join(homedir(), ".commandcode", "auth.json")) || - existsSync(join(homedir(), ".omp", "agent", "auth.json")) || - existsSync(join(homedir(), ".pi", "agent", "auth.json")) - ) -} - -let tempHome +const tempHome = mkdtempSync(join(tmpdir(), "pi-cc-home-")) const env = { ...process.env, + HOME: tempHome, + USERPROFILE: tempHome, COMMANDCODE_API_BASE: apiBase, + COMMANDCODE_API_KEY: "mock-key", COMMANDCODE_MODELS_URL: `${apiBase}/provider/v1/models`, -} - -if (hasLivePiAuth()) { - console.log("[pi-local] using live pi auth") -} else { - console.log("[pi-local] live pi auth not found; using mock auth fallback") - tempHome = mkdtempSync(join(tmpdir(), "pi-cc-home-")) - mkdirSync(join(tempHome, ".commandcode"), { recursive: true }) - writeFileSync(join(tempHome, ".commandcode", "auth.json"), JSON.stringify({ apiKey: "mock-key" })) - env.HOME = tempHome - env.USERPROFILE = tempHome - env.COMMANDCODE_API_KEY = "mock-key" + COMMANDCODE_MODELS_CACHE: join(tempHome, "commandcode-models.json"), } function runPi(args, timeoutMs = 30_000) { @@ -291,16 +266,64 @@ async function runRpcQuery(timeoutMs = 30_000) { } try { + console.log("[pi-local] first offline start without a cache") + const onlineModelsUrl = env.COMMANDCODE_MODELS_URL + env.COMMANDCODE_MODELS_URL = "http://127.0.0.1:1/provider/v1/models" + rmSync(env.COMMANDCODE_MODELS_CACHE, { force: true }) + const firstOfflineList = await runPi( + ["--no-extensions", "-e", EXT_PATH, "--list-models", "commandcode"], + 20_000, + ) + assert.equal(firstOfflineList.code, 0, firstOfflineList.stderr) + assert.doesNotMatch(firstOfflineList.stderr, /Failed to load extension/) + assert.match(firstOfflineList.stdout || firstOfflineList.stderr, /No models matching/) + assert.match(firstOfflineList.stderr, /no valid cached catalog/) + env.COMMANDCODE_MODELS_URL = onlineModelsUrl + console.log("[pi-local] list models through real extension") modelListRequestCount = 0 const list = await runPi(["--no-extensions", "-e", EXT_PATH, "--list-models"], 20_000) assert.equal(list.code, 0, list.stderr) const listOutput = list.stdout || list.stderr assert.match(listOutput, /commandcode/) - assert.match(listOutput, /deepseek\/deepseek-v4-flash/) - assert.match(listOutput, /Qwen\/Qwen3\.7-Max/) + assert.match(listOutput, /cc-offline-cache-model/) + assert.match(listOutput, /cc-second-model/) assert.equal(modelListRequestCount, 1) + console.log("[pi-local] list cached models while model discovery is offline") + env.COMMANDCODE_MODELS_URL = "http://127.0.0.1:1/provider/v1/models" + const offlineList = await runPi( + ["--no-extensions", "-e", EXT_PATH, "--list-models", "commandcode"], + 20_000, + ) + assert.equal(offlineList.code, 0, offlineList.stderr) + const offlineListOutput = offlineList.stdout || offlineList.stderr + assert.match(offlineListOutput, /cc-offline-cache-model/) + assert.match(offlineListOutput, /cc-second-model/) + assert.match(offlineList.stderr, /Using the cached catalog/) + + console.log("[pi-local] use a cached model while model discovery is offline") + requestCount = 0 + const offlinePrint = await runPi( + [ + "--no-extensions", + "-e", + EXT_PATH, + "-p", + "say mock token", + "--provider", + "commandcode", + "--model", + TEST_MODEL, + ], + 30_000, + ) + assert.equal(offlinePrint.code, 0, offlinePrint.stderr) + assert.match(offlinePrint.stdout, /mock-pi-ok/) + assert.match(offlinePrint.stderr, /Using the cached catalog/) + assert.equal(requestCount, 1) + env.COMMANDCODE_MODELS_URL = onlineModelsUrl + console.log("[pi-local] print mode through real extension and mock API") requestCount = 0 const print = await runPi( @@ -347,5 +370,5 @@ try { console.log("[pi-local] PASS") } finally { await new Promise((resolve) => server.close(resolve)) - if (tempHome) rmSync(tempHome, { recursive: true, force: true }) + rmSync(tempHome, { recursive: true, force: true }) } From b1a60da6b40be0db27b9702a37cf888a0b5034e5 Mon Sep 17 00:00:00 2001 From: Patrick Wozniak Date: Sun, 2 Aug 2026 01:37:34 +0200 Subject: [PATCH 3/5] fix(models): store cache in agent directory Resolve the Command Code model cache through the host's getAgentDir helper so pi, OMP, and PI_CODING_AGENT_DIR use their own agent state directory instead of the official Command Code client directory. --- README.md | 4 ++-- index.ts | 6 ++++-- src/models.ts | 13 ++++--------- tests/test-omp-compat.mjs | 3 +++ tests/test-pi-local.mjs | 6 ++++-- 5 files changed, 17 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index f1073c9..865c6f5 100644 --- a/README.md +++ b/README.md @@ -134,9 +134,9 @@ On startup, the provider fetches: https://api.commandcode.ai/provider/v1/models ``` -The last successfully fetched catalog is cached at `~/.commandcode/pi-models.json`. If model discovery is temporarily unavailable, the provider uses this cached catalog so previously discovered Command Code models remain selectable. On a first offline start without a cache, pi still loads, but Command Code models remain unavailable until the connection is restored and `/reload` succeeds. +The last successfully fetched catalog is cached at `/commandcode-models.json` (`~/.pi/agent/commandcode-models.json` by default). The agent directory follows pi's `PI_CODING_AGENT_DIR` setting, so compatible hosts such as OMP keep the cache in their own agent directory. If model discovery is temporarily unavailable, the provider uses this cached catalog so previously discovered Command Code models remain selectable. On a first offline start without a cache, pi still loads, but Command Code models remain unavailable until the connection is restored and `/reload` succeeds. -For tests or local mocks, override the endpoint with `COMMANDCODE_MODELS_URL` and the cache location with `COMMANDCODE_MODELS_CACHE`. +For tests or local mocks, override the endpoint with `COMMANDCODE_MODELS_URL` and the cache file with `COMMANDCODE_MODELS_CACHE`. ## Pricing diff --git a/index.ts b/index.ts index 0ebe19c..4b67b15 100644 --- a/index.ts +++ b/index.ts @@ -13,7 +13,8 @@ */ import { AssistantMessageEventStream } from "@earendil-works/pi-ai" -import type { ExtensionAPI } from "@earendil-works/pi-coding-agent" +import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent" +import { join } from "node:path" import { COMMAND_CODE_CLI_VERSION, createStreamCommandCode, DEFAULT_API_BASE } from "./src/core.ts" import { calculateCommandCodeCost } from "./src/cost.ts" @@ -22,7 +23,8 @@ import { getApiKey, login, refreshToken } from "./src/oauth.ts" const API_BASE = process.env.COMMANDCODE_API_BASE ?? DEFAULT_API_BASE const MODELS_URL = process.env.COMMANDCODE_MODELS_URL ?? DEFAULT_MODELS_URL -const MODELS_CACHE_PATH = process.env.COMMANDCODE_MODELS_CACHE +const MODELS_CACHE_PATH = + process.env.COMMANDCODE_MODELS_CACHE ?? join(getAgentDir(), "commandcode-models.json") type CommandCodeModelCost = { input: number diff --git a/src/models.ts b/src/models.ts index 237b003..604cf7c 100644 --- a/src/models.ts +++ b/src/models.ts @@ -1,6 +1,5 @@ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises" -import { homedir } from "node:os" -import { dirname, join } from "node:path" +import { dirname } from "node:path" export const DEFAULT_MODELS_URL = "https://api.commandcode.ai/provider/v1/models" @@ -27,7 +26,7 @@ interface FetchCommandCodeModelsOptions { } interface LoadCommandCodeModelsOptions extends FetchCommandCodeModelsOptions { - cachePath?: string + cachePath: string } export interface LoadCommandCodeModelsResult { @@ -93,10 +92,6 @@ function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error) } -export function defaultCommandCodeModelsCachePath(): string { - return join(homedir(), ".commandcode", "pi-models.json") -} - export function commandCodeModelsFromApiResponse(value: unknown): readonly CommandCodeModel[] { if (!isRecord(value)) throw new Error("Expected models response to be an object") if (value.object !== "list") throw new Error("Expected models response object to be 'list'") @@ -174,9 +169,9 @@ async function writeCommandCodeModelsCache( } export async function loadCommandCodeModels( - options: LoadCommandCodeModelsOptions = {}, + options: LoadCommandCodeModelsOptions, ): Promise { - const cachePath = options.cachePath ?? defaultCommandCodeModelsCachePath() + const cachePath = options.cachePath try { const models = await fetchCommandCodeModels(options) diff --git a/tests/test-omp-compat.mjs b/tests/test-omp-compat.mjs index b591675..af7c2bd 100644 --- a/tests/test-omp-compat.mjs +++ b/tests/test-omp-compat.mjs @@ -166,6 +166,9 @@ try { assert.match(listOutput, /commandcode/) assert.match(listOutput, /deepseek\/deepseek-v4-flash/) assert.equal(modelListRequestCount, 1) + assert.doesNotThrow(() => + accessSync(join(tempHome, ".omp", "agent", "commandcode-models.json"), constants.R_OK), + ) assert.doesNotMatch(result.stdout + result.stderr, /Failed to load extension/) console.log("[omp-compat] print mode through real extension and mock API") diff --git a/tests/test-pi-local.mjs b/tests/test-pi-local.mjs index ffe1687..18a8dd5 100644 --- a/tests/test-pi-local.mjs +++ b/tests/test-pi-local.mjs @@ -129,10 +129,10 @@ const env = { ...process.env, HOME: tempHome, USERPROFILE: tempHome, + PI_CODING_AGENT_DIR: join(tempHome, "custom-pi-agent"), COMMANDCODE_API_BASE: apiBase, COMMANDCODE_API_KEY: "mock-key", COMMANDCODE_MODELS_URL: `${apiBase}/provider/v1/models`, - COMMANDCODE_MODELS_CACHE: join(tempHome, "commandcode-models.json"), } function runPi(args, timeoutMs = 30_000) { @@ -269,7 +269,8 @@ try { console.log("[pi-local] first offline start without a cache") const onlineModelsUrl = env.COMMANDCODE_MODELS_URL env.COMMANDCODE_MODELS_URL = "http://127.0.0.1:1/provider/v1/models" - rmSync(env.COMMANDCODE_MODELS_CACHE, { force: true }) + const modelsCachePath = join(env.PI_CODING_AGENT_DIR, "commandcode-models.json") + rmSync(modelsCachePath, { force: true }) const firstOfflineList = await runPi( ["--no-extensions", "-e", EXT_PATH, "--list-models", "commandcode"], 20_000, @@ -289,6 +290,7 @@ try { assert.match(listOutput, /cc-offline-cache-model/) assert.match(listOutput, /cc-second-model/) assert.equal(modelListRequestCount, 1) + assert.doesNotThrow(() => accessSync(modelsCachePath, constants.R_OK)) console.log("[pi-local] list cached models while model discovery is offline") env.COMMANDCODE_MODELS_URL = "http://127.0.0.1:1/provider/v1/models" From 634beb8378ae163440940edc5722e5507c271e51 Mon Sep 17 00:00:00 2001 From: Patrick Wozniak Date: Sun, 2 Aug 2026 10:31:24 +0200 Subject: [PATCH 4/5] fix(deps): bump protobufjs to 7.6.5 Resolve GHSA-j3f2-48v5-ccww so npm audit --audit-level=moderate passes in CI. --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 84c3731..a1b853e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1646,9 +1646,9 @@ } }, "node_modules/protobufjs": { - "version": "7.6.4", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.4.tgz", - "integrity": "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==", + "version": "7.6.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz", + "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { From e20953d8909afcaec0f7a0ce6180fc4abf13ac98 Mon Sep 17 00:00:00 2001 From: Patrick Wozniak Date: Sun, 2 Aug 2026 10:42:31 +0200 Subject: [PATCH 5/5] test(models): cover recovery after empty offline start Assert the empty catalog path recovers live models and writes a cache once discovery is available again, including the real pi extension entrypoint used by /reload. --- tests/test-models.ts | 23 +++++++++++++++++++++++ tests/test-pi-local.mjs | 18 ++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/tests/test-models.ts b/tests/test-models.ts index aed6dfe..8bbad9f 100644 --- a/tests/test-models.ts +++ b/tests/test-models.ts @@ -136,6 +136,29 @@ describe("loadCommandCodeModels()", () => { }) }) + it("recovers live models after an empty offline start", async () => { + await withTemporaryCache(async ({ cachePath }) => { + const empty = await loadCommandCodeModels({ + cachePath, + fetchImpl: failingFetch(), + }) + + assert.equal(empty.source, "empty") + assert.deepEqual(empty.models, []) + + const recovered = await loadCommandCodeModels({ + cachePath, + fetchImpl: successfulFetch(), + }) + + assert.deepEqual(recovered, { models: EXPECTED_MODELS, source: "live" }) + assert.deepEqual( + commandCodeModelsFromCache(JSON.parse(await readFile(cachePath, "utf-8"))), + EXPECTED_MODELS, + ) + }) + }) + it("ignores a corrupt cache after a failed refresh", async () => { await withTemporaryCache(async ({ cachePath }) => { await writeFile(cachePath, "not json", "utf-8") diff --git a/tests/test-pi-local.mjs b/tests/test-pi-local.mjs index 18a8dd5..8423158 100644 --- a/tests/test-pi-local.mjs +++ b/tests/test-pi-local.mjs @@ -279,7 +279,25 @@ try { assert.doesNotMatch(firstOfflineList.stderr, /Failed to load extension/) assert.match(firstOfflineList.stdout || firstOfflineList.stderr, /No models matching/) assert.match(firstOfflineList.stderr, /no valid cached catalog/) + assert.match(firstOfflineList.stderr, /until \/reload succeeds/) + assert.throws(() => accessSync(modelsCachePath, constants.R_OK), /ENOENT|no such file/i) + + // A fresh process re-runs the extension entrypoint, which is the same path /reload uses. + console.log("[pi-local] recover models after empty offline start") env.COMMANDCODE_MODELS_URL = onlineModelsUrl + modelListRequestCount = 0 + const recoveryList = await runPi( + ["--no-extensions", "-e", EXT_PATH, "--list-models", "commandcode"], + 20_000, + ) + assert.equal(recoveryList.code, 0, recoveryList.stderr) + const recoveryOutput = recoveryList.stdout || recoveryList.stderr + assert.match(recoveryOutput, /cc-offline-cache-model/) + assert.match(recoveryOutput, /cc-second-model/) + assert.doesNotMatch(recoveryList.stderr, /no valid cached catalog/) + assert.doesNotMatch(recoveryList.stderr, /Failed to load extension/) + assert.equal(modelListRequestCount, 1) + assert.doesNotThrow(() => accessSync(modelsCachePath, constants.R_OK)) console.log("[pi-local] list models through real extension") modelListRequestCount = 0