Ship Paseo-equivalent provider snapshot, AgentComposerBar, ACP dispatch rewrite with streaming/persist, permission prompts, and agent commands. Follow-up: pane-scoped chat resolution, CoderMessageList tool timeline, WS user-delta replace, and inference orphan tool_call stripping. Archive openspec v2-2; update CHANGELOG and CURRENT. Co-authored-by: Cursor <cursoragent@cursor.com>
267 lines
8.0 KiB
TypeScript
267 lines
8.0 KiB
TypeScript
/**
|
|
* Provider snapshot cache — cold ACP probe per provider + static manifest merge.
|
|
*/
|
|
import { homedir } from 'node:os';
|
|
import { exec as execCb } from 'node:child_process';
|
|
import { promisify } from 'node:util';
|
|
import type { FastifyBaseLogger } from 'fastify';
|
|
import type { Sql } from '../db.js';
|
|
import type { Config } from '../config.js';
|
|
import { PROVIDERS, type ProviderDef } from './provider-registry.js';
|
|
import {
|
|
getManifestDefaultModeId,
|
|
getManifestModes,
|
|
PROVIDER_MANIFEST,
|
|
} from './provider-manifest.js';
|
|
import { probeAcpProvider } from './acp-probe.js';
|
|
import { parseCursorAgentModelsOutput } from './cursor-models.js';
|
|
import type { ProviderModel, ProviderSnapshotEntry } from './provider-types.js';
|
|
import { getManifestCommands, mergeCommands } from './provider-commands.js';
|
|
import { readQwenSettingsModels } from './qwen-settings.js';
|
|
|
|
const exec = promisify(execCb);
|
|
|
|
interface AgentRow {
|
|
name: string;
|
|
install_path: string | null;
|
|
supports_acp: boolean;
|
|
models: ProviderModel[] | null;
|
|
label: string | null;
|
|
transport: string | null;
|
|
}
|
|
|
|
async function fetchLlamaSwapModels(config: Config): Promise<ProviderModel[]> {
|
|
try {
|
|
const res = await fetch(`${config.LLAMA_SWAP_URL}/v1/models`);
|
|
if (!res.ok) return [];
|
|
const parsed = (await res.json()) as { data?: Array<{ id: string }> };
|
|
return (parsed.data ?? []).map((m) => ({ id: m.id, label: m.id }));
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
async function fetchCursorModelsCli(installPath: string): Promise<ProviderModel[]> {
|
|
try {
|
|
const { stdout } = await exec(`"${installPath}" models`, { timeout: 15_000, maxBuffer: 1024 * 1024 });
|
|
return parseCursorAgentModelsOutput(stdout);
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/** Prefix llama-swap model ids so they don't collide with provider-native models. */
|
|
export function prefixLlamaSwapModels(models: ProviderModel[]): ProviderModel[] {
|
|
return models.map((m) => ({
|
|
...m,
|
|
id: m.id.startsWith('llama-swap/') ? m.id : `llama-swap/${m.id}`,
|
|
}));
|
|
}
|
|
|
|
function attachClaudeThinking(models: ProviderModel[]): ProviderModel[] {
|
|
const thinking = PROVIDER_MANIFEST.claude?.thinkingOptions;
|
|
if (!thinking?.length) return models;
|
|
return models.map((m) => ({
|
|
...m,
|
|
thinkingOptions: thinking,
|
|
defaultThinkingOptionId: 'medium',
|
|
}));
|
|
}
|
|
|
|
export function mergeModels(...lists: ProviderModel[][]): ProviderModel[] {
|
|
const seen = new Set<string>();
|
|
const out: ProviderModel[] = [];
|
|
for (const list of lists) {
|
|
for (const m of list) {
|
|
if (seen.has(m.id)) continue;
|
|
seen.add(m.id);
|
|
out.push(m);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
async function buildProviderEntry(
|
|
provider: ProviderDef,
|
|
agentRow: AgentRow | undefined,
|
|
llamaModels: ProviderModel[],
|
|
cwd: string,
|
|
): Promise<ProviderSnapshotEntry | null> {
|
|
const isNative = provider.name === 'boocode';
|
|
const installed = isNative || !!agentRow;
|
|
if (!installed) return null;
|
|
|
|
let transport = provider.transport;
|
|
if (agentRow && provider.transport === 'acp' && !agentRow.supports_acp) {
|
|
transport = 'pty';
|
|
}
|
|
|
|
const fallbackModes = getManifestModes(provider.name);
|
|
const defaultModeId = getManifestDefaultModeId(provider.name);
|
|
|
|
if (isNative) {
|
|
return {
|
|
name: provider.name,
|
|
label: provider.label,
|
|
transport,
|
|
status: 'ready',
|
|
installed: true,
|
|
models: llamaModels,
|
|
modes: [],
|
|
defaultModeId: null,
|
|
commands: getManifestCommands(provider.name),
|
|
};
|
|
}
|
|
|
|
let models: ProviderModel[] = [];
|
|
if (provider.modelSource === 'llama-swap' && provider.mergeLlamaSwap) {
|
|
models = llamaModels;
|
|
} else if (agentRow?.models?.length) {
|
|
models = agentRow.models;
|
|
} else if (provider.staticModels) {
|
|
models = provider.staticModels.map((m) => ({ id: m.id, label: m.label }));
|
|
}
|
|
|
|
if (provider.name === 'claude') {
|
|
models = attachClaudeThinking(models);
|
|
return {
|
|
name: provider.name,
|
|
label: agentRow?.label ?? provider.label,
|
|
transport,
|
|
status: 'ready',
|
|
installed: true,
|
|
models,
|
|
modes: fallbackModes,
|
|
defaultModeId,
|
|
commands: getManifestCommands(provider.name),
|
|
};
|
|
}
|
|
|
|
if (transport === 'acp' && agentRow?.install_path && agentRow.supports_acp) {
|
|
const probe = await probeAcpProvider(provider.name, agentRow.install_path, cwd);
|
|
if (probe.models.length > 0) {
|
|
models = probe.models;
|
|
} else if (provider.name === 'cursor' && agentRow.install_path) {
|
|
models = await fetchCursorModelsCli(agentRow.install_path);
|
|
} else if (provider.modelSource === 'llama-swap') {
|
|
models = llamaModels;
|
|
}
|
|
|
|
if (provider.name === 'qwen') {
|
|
const settingsModels = await readQwenSettingsModels();
|
|
models = mergeModels(models, settingsModels);
|
|
}
|
|
|
|
if (provider.mergeLlamaSwap && provider.modelSource !== 'llama-swap') {
|
|
const nativeModels = probe.models.length > 0 ? probe.models : models;
|
|
models = mergeModels(nativeModels, prefixLlamaSwapModels(llamaModels));
|
|
}
|
|
|
|
return {
|
|
name: provider.name,
|
|
label: agentRow.label ?? provider.label,
|
|
transport,
|
|
status: probe.ok ? 'ready' : 'error',
|
|
installed: true,
|
|
models,
|
|
modes: probe.modes.length > 0 ? probe.modes : fallbackModes,
|
|
defaultModeId: probe.defaultModeId ?? defaultModeId,
|
|
commands: mergeCommands(getManifestCommands(provider.name), probe.commands),
|
|
error: probe.error,
|
|
};
|
|
}
|
|
|
|
// PTY-only providers (qwen fallback when ACP unavailable)
|
|
if (provider.name === 'qwen') {
|
|
if (models.length === 0) {
|
|
models = await readQwenSettingsModels();
|
|
}
|
|
}
|
|
|
|
return {
|
|
name: provider.name,
|
|
label: agentRow?.label ?? provider.label,
|
|
transport,
|
|
status: 'ready',
|
|
installed: true,
|
|
models,
|
|
modes: fallbackModes,
|
|
defaultModeId,
|
|
commands: getManifestCommands(provider.name),
|
|
};
|
|
}
|
|
|
|
const snapshotCache = new Map<string, { at: number; entries: ProviderSnapshotEntry[] }>();
|
|
const snapshotInflight = new Map<string, Promise<ProviderSnapshotEntry[]>>();
|
|
const CACHE_TTL_MS = 5 * 60_000;
|
|
|
|
export async function getProviderSnapshot(
|
|
sql: Sql,
|
|
config: Config,
|
|
cwd?: string,
|
|
force = false,
|
|
): Promise<ProviderSnapshotEntry[]> {
|
|
const resolvedCwd = cwd?.trim() || homedir();
|
|
const cacheKey = resolvedCwd;
|
|
const cached = snapshotCache.get(cacheKey);
|
|
if (!force && cached && Date.now() - cached.at < CACHE_TTL_MS) {
|
|
return cached.entries;
|
|
}
|
|
|
|
const inflight = snapshotInflight.get(cacheKey);
|
|
if (!force && inflight) {
|
|
return inflight;
|
|
}
|
|
|
|
const build = async (): Promise<ProviderSnapshotEntry[]> => {
|
|
const llamaModels = await fetchLlamaSwapModels(config);
|
|
const agents = await sql<AgentRow[]>`
|
|
SELECT name, install_path, supports_acp, models, label, transport FROM available_agents
|
|
`;
|
|
const agentMap = new Map(agents.map((a) => [a.name, a]));
|
|
|
|
const built = await Promise.all(
|
|
PROVIDERS.map((provider) =>
|
|
buildProviderEntry(provider, agentMap.get(provider.name), llamaModels, resolvedCwd),
|
|
),
|
|
);
|
|
const entries = built.filter((entry): entry is ProviderSnapshotEntry => entry !== null);
|
|
|
|
snapshotCache.set(cacheKey, { at: Date.now(), entries });
|
|
return entries;
|
|
};
|
|
|
|
const promise = build().finally(() => {
|
|
snapshotInflight.delete(cacheKey);
|
|
});
|
|
snapshotInflight.set(cacheKey, promise);
|
|
return promise;
|
|
}
|
|
|
|
export function clearProviderSnapshotCache(): void {
|
|
snapshotCache.clear();
|
|
snapshotInflight.clear();
|
|
}
|
|
|
|
/** Persist probed model lists back to available_agents for fast legacy reads. */
|
|
export async function persistProbedModels(
|
|
sql: Sql,
|
|
entries: ProviderSnapshotEntry[],
|
|
log: FastifyBaseLogger,
|
|
): Promise<void> {
|
|
let count = 0;
|
|
for (const entry of entries) {
|
|
if (entry.name === 'boocode' || entry.models.length === 0) continue;
|
|
const flatModels = entry.models.map(({ id, label }) => ({ id, label }));
|
|
await sql`
|
|
UPDATE available_agents
|
|
SET models = ${sql.json(flatModels as never)}, last_probed_at = clock_timestamp()
|
|
WHERE name = ${entry.name}
|
|
`;
|
|
count++;
|
|
}
|
|
if (count > 0) {
|
|
log.info({ count }, 'provider-snapshot: persisted models to available_agents');
|
|
}
|
|
}
|