diff --git a/src/resources/extensions/sf/auto-start.js b/src/resources/extensions/sf/auto-start.js index c55f58996..2f80b83e3 100644 --- a/src/resources/extensions/sf/auto-start.js +++ b/src/resources/extensions/sf/auto-start.js @@ -103,6 +103,7 @@ import { } from "./uok/unit-runtime.js"; import { safeSetWidget } from "./widget-safe.js"; import { logError, logWarning } from "./workflow-logger.js"; +import { initNotificationStore } from "./notification-store.js"; import { captureIntegrationBranch, @@ -344,6 +345,10 @@ export async function bootstrapAutoSession( lockBase, buildResolver, } = deps; + // Defensive: ensure notification store is initialized before any bootstrap + // notify calls. session_start also initializes it, but this closes the gap + // in case the bootstrap path runs before the lifecycle hook fires. + initNotificationStore(base); let lockResult = acquireSessionLock(base, { sessionId: ctx.sessionManager?.getSessionId?.(), sessionFile: ctx.sessionManager?.getSessionFile?.(), diff --git a/src/resources/extensions/sf/model-catalog-cache.js b/src/resources/extensions/sf/model-catalog-cache.js index e90c29014..db24baf3e 100644 --- a/src/resources/extensions/sf/model-catalog-cache.js +++ b/src/resources/extensions/sf/model-catalog-cache.js @@ -6,11 +6,17 @@ * with a 6-hour TTL so the model picker and preference validation see fresh * model IDs without blocking the event loop. * + * For providers that the SDK's built-in discovery adapter registry does NOT + * cover with supportsDiscovery=true (opencode, opencode-go, kimi-coding, xiaomi), + * results are ALSO written to ~/.sf/agent/discovery-cache.json so that + * `sf --list-models --discover` includes them in the discovery output. + * * Provider configuration (base URL, auth format, model filter patterns) comes * from provider-catalog-config.js — not hardcoded here. */ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; import { join } from "node:path"; +import { sfHome } from "./sf-home.js"; import { sfRuntimeRoot } from "./paths.js"; import { DISCOVERABLE_PROVIDER_IDS, @@ -20,6 +26,24 @@ import { const CATALOG_TTL_MS = 6 * 60 * 60 * 1000; +/** + * Providers whose live model lists are already handled by the SDK's native + * discoverModels() adapter (supportsDiscovery=true in model-discovery.js). + * For these, the SF layer does NOT need to write to the SDK discovery cache — + * --discover will populate it via the SDK adapter path. + * All other DISCOVERABLE_PROVIDER_IDS are "SF-managed" and need SF to write + * their results into discovery-cache.json so --list-models --discover sees them. + */ +const SDK_NATIVE_DISCOVERY_PROVIDERS = new Set([ + "ollama-cloud", + "openrouter", + "zai", + "minimax", + "mistral", +]); + +const SDK_DISCOVERY_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour — matches DISCOVERY_TTLS defaults + function cacheDirPath(basePath) { return join(sfRuntimeRoot(basePath), "model-catalog"); } @@ -58,6 +82,56 @@ function writeCacheEntry(basePath, providerId, modelIds) { } } +/** + * Write fetched model IDs for an SF-managed provider into the SDK discovery + * cache (~/.sf/agent/discovery-cache.json) so that `--list-models --discover` + * includes them. Only called for providers not covered by the SDK's native + * discovery adapters (i.e. not in SDK_NATIVE_DISCOVERY_PROVIDERS). + * + * Model objects are minimal: {id, name, provider, api, baseUrl} — enough for + * the model registry to register and display them. + */ +function writeSdkDiscoveryCacheEntry(providerId, modelIds) { + try { + const cfg = getProviderCatalogConfig(providerId); + if (!cfg) return; + const cachePath = join(sfHome(), "agent", "discovery-cache.json"); + let cache = { version: 1, entries: {} }; + if (existsSync(cachePath)) { + try { + cache = JSON.parse(readFileSync(cachePath, "utf-8")); + if (cache.version !== 1 || typeof cache.entries !== "object") { + cache = { version: 1, entries: {} }; + } + } catch { + cache = { version: 1, entries: {} }; + } + } + const api = + cfg.type === "anthropic" ? "anthropic-messages" : "openai-completions"; + const models = modelIds.map((id) => ({ + id, + name: id, + provider: providerId, + api, + baseUrl: cfg.baseUrl, + input: ["text"], + })); + cache.entries[providerId] = { + models, + fetchedAt: Date.now(), + ttlMs: SDK_DISCOVERY_CACHE_TTL_MS, + }; + const dir = join(sfHome(), "agent"); + mkdirSync(dir, { recursive: true }); + const tmp = cachePath + ".tmp"; + writeFileSync(tmp, JSON.stringify(cache, null, 2), "utf-8"); + renameSync(tmp, cachePath); + } catch { + // Best-effort — never fail the caller. + } +} + function buildAuthHeaders(cfg, apiKey) { switch (cfg.auth.type) { case "bearer": @@ -109,6 +183,10 @@ function applyModelFilter(providerId, modelIds) { /** * Fetch the live model list for one provider and update the disk cache. * Returns the filtered model ID array on success, null on any error. + * + * For SF-managed providers (not in SDK_NATIVE_DISCOVERY_PROVIDERS), also + * writes results into the SDK discovery cache so --list-models --discover + * includes them. */ export async function refreshProviderCatalog(basePath, providerId, apiKey) { const cfg = getProviderCatalogConfig(providerId); @@ -125,6 +203,13 @@ export async function refreshProviderCatalog(basePath, providerId, apiKey) { if (!raw) return null; const modelIds = applyModelFilter(providerId, raw); writeCacheEntry(basePath, providerId, modelIds); + // Also populate the SDK discovery cache for providers that the SDK's + // built-in discovery adapters do not cover (opencode, opencode-go, + // kimi-coding, xiaomi, etc.). Without this, --list-models --discover + // silently omits them because their SDK adapter has supportsDiscovery=false. + if (!SDK_NATIVE_DISCOVERY_PROVIDERS.has(providerId)) { + writeSdkDiscoveryCacheEntry(providerId, modelIds); + } return modelIds; } catch { return null; @@ -144,9 +229,18 @@ export function scheduleModelCatalogRefresh(basePath, auth) { const apiKey = creds.find((c) => c.type === "api_key" && c.key)?.key; if (!apiKey) continue; if (readCachedModelIds(basePath, providerId) !== null) continue; - await refreshProviderCatalog(basePath, providerId, apiKey); - } catch { - // Per-provider failures are silently swallowed. + const result = await refreshProviderCatalog(basePath, providerId, apiKey); + if (result === null) { + // Surface per-provider fetch failures so they don't silently disappear. + process.stderr.write( + `[model-catalog-cache] refresh failed for provider: ${providerId}\n`, + ); + } + } catch (err) { + // Per-provider failures must not crash the refresh loop, but should be visible. + process.stderr.write( + `[model-catalog-cache] unexpected error for provider ${providerId}: ${err?.message ?? err}\n`, + ); } } });