diff --git a/docs/providers.md b/docs/providers.md index 18db4eb7e..90edbde87 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -56,15 +56,15 @@ Daemon bootstrap reconciles that ledger in the background, without blocking star ## Provider Snapshot Refresh Contract -The daemon keeps provider snapshots per resolved working directory. Missing or blank cwd resolves to the user's home directory. Workspace selectors and old model/mode list requests should pass the cwd that will launch the provider so providers with project-specific models or modes are probed in the right context. Settings/provider management intentionally uses the home-directory snapshot. +The daemon keeps provider snapshots per resolved working directory, with a separate semantic global scope for settings/provider management and requests that do not carry a cwd. Provider catalog probes receive a discriminated `FetchCatalogOptions`: `{ scope: "global", force }` for global catalog refreshes, or `{ scope: "workspace", cwd, force }` for project-scoped refreshes. Providers decide what global means for their runtime; do not infer global by comparing a cwd to the user's home directory. Snapshot reads may probe providers only while the requested cwd scope is cold. Once an entry is warm, its `ready`, `error`, or `unavailable` state stays cached until an explicit refresh. Do not add TTL revalidation, focus-triggered refreshes, selector-open refreshes, or config-reload refreshes. Selector-open refetches may read an already-loading or stale React Query, but they must not force provider probing on their own. -Settings refresh is the user-facing "forget stale provider knowledge everywhere" action. A settings refresh clears provider snapshot caches and in-flight loads across all cwd scopes, then immediately refreshes only the home-directory snapshot with `force: true`. Workspace snapshots are re-probed lazily on the next scoped read; do not fan out a settings refresh across every known workspace. +Settings refresh is the user-facing "forget stale provider knowledge everywhere" action. A settings refresh clears provider snapshot caches and in-flight loads across all cwd scopes, then immediately refreshes only the global snapshot with `force: true`. Workspace snapshots are re-probed lazily on the next scoped read; do not fan out a settings refresh across every known workspace. Registry/config replacement may update visible metadata such as label, description, default mode, enabled state, and provider membership, but it must not spawn provider processes. If a provider needs to be re-probed after a config change, route that through the explicit settings refresh path. -Boundary tests should assert observable behavior: cold reads may call provider availability/model/mode discovery for that cwd; warm reads and registry replacement must not; explicit workspace refreshes affect only one cwd; settings refresh wipes all scopes but immediately refreshes only home. +Boundary tests should assert observable behavior: cold reads may call provider availability/model/mode discovery for that scope; warm reads and registry replacement must not; explicit workspace refreshes affect only one cwd; settings refresh wipes all scopes but immediately refreshes only global. --- diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 7d6b6016b..720edbfcb 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -3702,7 +3702,11 @@ export class AgentManager { return undefined; } try { - const catalog = await client.fetchCatalog({ cwd: config.cwd, force: false }); + const catalog = await client.fetchCatalog({ + scope: "workspace", + cwd: config.cwd, + force: false, + }); return (catalog.models.find((model) => model.isDefault) ?? catalog.models[0])?.id; } catch { // Provider may not support model listing — leave model undefined. diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 6c7f84fac..56c437ef0 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -643,11 +643,18 @@ export interface AgentSession { } | null; } -export interface FetchCatalogOptions { - cwd: string; - force: boolean; - timeoutMs?: number; -} +export type FetchCatalogOptions = + | { + scope: "global"; + force: boolean; + timeoutMs?: number; + } + | { + scope: "workspace"; + cwd: string; + force: boolean; + timeoutMs?: number; + }; export interface ProviderCatalog { models: AgentModelDefinition[]; diff --git a/packages/server/src/server/agent/provider-registry.test.ts b/packages/server/src/server/agent/provider-registry.test.ts index ba4153861..27e62a1a7 100644 --- a/packages/server/src/server/agent/provider-registry.test.ts +++ b/packages/server/src/server/agent/provider-registry.test.ts @@ -810,6 +810,7 @@ describe("model merging", () => { }); const { models } = await registry.codex.fetchCatalog({ + scope: "workspace", cwd: "/tmp/registry-models", force: false, }); @@ -845,6 +846,7 @@ describe("model merging", () => { }); const { models } = await registry.codex.fetchCatalog({ + scope: "workspace", cwd: "/tmp/registry-models", force: false, }); @@ -883,6 +885,7 @@ describe("model merging", () => { }); const { models } = await registry.codex.fetchCatalog({ + scope: "workspace", cwd: "/tmp/registry-models", force: false, }); @@ -917,6 +920,7 @@ describe("model merging", () => { }); const { models } = await registry.codex.fetchCatalog({ + scope: "workspace", cwd: "/tmp/registry-models", force: false, }); @@ -959,6 +963,7 @@ describe("model merging", () => { }); const { models } = await registry.claude.fetchCatalog({ + scope: "workspace", cwd: "/tmp/registry-models", force: false, }); @@ -1009,6 +1014,7 @@ describe("model merging", () => { }); const { models } = await registry.claude.fetchCatalog({ + scope: "workspace", cwd: "/tmp/registry-models", force: false, }); @@ -1056,6 +1062,7 @@ describe("model merging", () => { }); const { models } = await registry.codex.fetchCatalog({ + scope: "workspace", cwd: "/tmp/registry-models", force: false, }); @@ -1095,6 +1102,7 @@ describe("model merging", () => { }); const { models } = await registry.claude.fetchCatalog({ + scope: "workspace", cwd: "/tmp/registry-models", force: false, }); @@ -1147,6 +1155,7 @@ describe("model merging", () => { }); const { models } = await registry.claude.fetchCatalog({ + scope: "workspace", cwd: "/tmp/registry-models", force: false, }); @@ -1185,6 +1194,7 @@ describe("model merging", () => { const registry = buildProviderRegistry(logger); const { models } = await registry.claude.fetchCatalog({ + scope: "workspace", cwd: "/tmp/registry-models", force: false, }); @@ -1225,6 +1235,7 @@ describe("model merging", () => { const client = registry.codex.createClient(logger); const catalog = await client.fetchCatalog({ + scope: "workspace", cwd: "/tmp/registry-models", force: false, }); @@ -1259,6 +1270,7 @@ describe("model merging", () => { const client = registry.claude.createClient(logger); const catalog = await client.fetchCatalog({ + scope: "workspace", cwd: "/tmp/registry-models", force: false, }); @@ -1287,6 +1299,7 @@ describe("model merging", () => { }); const { models } = await registry.claude.fetchCatalog({ + scope: "workspace", cwd: "/tmp/registry-models", force: false, }); @@ -1304,6 +1317,7 @@ describe("fetchCatalog", () => { const registry = buildProviderRegistry(logger); const catalog = await registry.codex.fetchCatalog({ + scope: "workspace", cwd: "/tmp/catalog", force: false, }); @@ -1327,6 +1341,7 @@ describe("fetchCatalog", () => { }); const catalog = await registry.codex.fetchCatalog({ + scope: "workspace", cwd: "/tmp/catalog", force: false, }); @@ -1345,6 +1360,7 @@ describe("fetchCatalog", () => { }); const catalog = await registry.codex.fetchCatalog({ + scope: "workspace", cwd: "/tmp/catalog", force: false, }); diff --git a/packages/server/src/server/agent/provider-snapshot-manager.test.ts b/packages/server/src/server/agent/provider-snapshot-manager.test.ts index 3c9c6c0d2..3223de6c3 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.test.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.test.ts @@ -1,3 +1,4 @@ +import { homedir } from "node:os"; import { resolve } from "node:path"; import { describe, expect, test, vi } from "vitest"; @@ -11,7 +12,11 @@ import type { ResolveAgentCreateConfigInput, } from "./agent-sdk-types.js"; import type { ManagedAgent } from "./agent-manager.js"; -import { ProviderSnapshotManager, resolveSnapshotCwd } from "./provider-snapshot-manager.js"; +import { + GLOBAL_PROVIDER_SNAPSHOT_KEY, + ProviderSnapshotManager, + resolveSnapshotCwd, +} from "./provider-snapshot-manager.js"; import { OpenCodeAgentClient } from "./providers/opencode-agent.js"; const TEST_CAPABILITIES = { @@ -488,6 +493,7 @@ describe("ProviderSnapshotManager public surface", () => { try { const result = await manager.getProviderDiagnostic("codex"); expect(fetchCatalog).toHaveBeenCalledTimes(1); + expect(fetchCatalog.mock.calls[0]?.[0]).toMatchObject({ scope: "global", force: true }); expect(result.diagnostic).toContain("Models: 1"); expect(result.diagnostic).toContain("Status: Ready"); } finally { @@ -1063,6 +1069,60 @@ describe("ProviderSnapshotManager lifecycle", () => { }); describe("ProviderSnapshotManager cwd routing", () => { + test("settings refresh passes the semantic global scope to providers", async () => { + const fetchCatalog = vi.fn(async () => ({ + models: [] as AgentModelDefinition[], + modes: [] as AgentMode[], + })); + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + extraClients: { + codex: createExtraClient("codex", { + isAvailable: vi.fn(async () => true), + fetchCatalog, + }), + }, + }); + try { + await manager.refreshSettingsSnapshot({ providers: ["codex"] }); + + expect(fetchCatalog.mock.calls[0]?.[0]).toMatchObject({ scope: "global", force: true }); + } finally { + manager.destroy(); + } + }); + + test("global snapshot does not satisfy an explicit home workspace read", async () => { + const fetchCatalog = vi.fn(async () => ({ + models: [] as AgentModelDefinition[], + modes: [] as AgentMode[], + })); + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + extraClients: { + codex: createExtraClient("codex", { + isAvailable: vi.fn(async () => true), + fetchCatalog, + }), + }, + }); + try { + await manager.refreshSettingsSnapshot({ providers: ["codex"] }); + await manager.listProviders({ cwd: homedir(), providers: ["codex"], wait: true }); + + expect(fetchCatalog.mock.calls.map((call) => call[0])).toEqual([ + expect.objectContaining({ scope: "global", force: true }), + expect.objectContaining({ + scope: "workspace", + cwd: resolveSnapshotCwd(homedir()), + force: false, + }), + ]); + } finally { + manager.destroy(); + } + }); + test("different cwd keys produce independent snapshots", () => { const manager = new ProviderSnapshotManager({ logger: createTestLogger(), @@ -1086,7 +1146,7 @@ describe("ProviderSnapshotManager cwd routing", () => { } }); - test("getSnapshot called with no cwd resolves to the home snapshot key", () => { + test("getSnapshot called with no cwd resolves to the global snapshot key", () => { const manager = new ProviderSnapshotManager({ logger: createTestLogger(), providerOverrides: { @@ -1103,13 +1163,7 @@ describe("ProviderSnapshotManager cwd routing", () => { manager.getSnapshot(); manager.applyMutableProviderConfig({}); const cwds = listener.mock.calls.map((call) => call[1]); - // applyMutableProviderConfig emits change for each primed cwd; the home - // snapshot must be present. - expect(cwds.length).toBeGreaterThanOrEqual(1); - for (const cwd of cwds) { - expect(typeof cwd).toBe("string"); - expect(cwd.length).toBeGreaterThan(0); - } + expect(cwds).toContain(GLOBAL_PROVIDER_SNAPSHOT_KEY); } finally { manager.destroy(); } diff --git a/packages/server/src/server/agent/provider-snapshot-manager.ts b/packages/server/src/server/agent/provider-snapshot-manager.ts index d996dff5b..d6d270320 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.ts @@ -12,6 +12,7 @@ import type { AgentMode, AgentModelDefinition, AgentProvider, + FetchCatalogOptions, ProviderSnapshotEntry, } from "./agent-sdk-types.js"; import type { ManagedAgent } from "./agent-manager.js"; @@ -36,6 +37,7 @@ import type { MutableDaemonConfig } from "../daemon-config-store.js"; const DEFAULT_REFRESH_TIMEOUT_MS = 60_000; const DEFAULT_DIAGNOSTIC_TIMEOUT_MS = 120_000; const REFRESH_TIMEOUT_ENV_VAR = "PASEO_PROVIDER_REFRESH_TIMEOUT_MS"; +export const GLOBAL_PROVIDER_SNAPSHOT_KEY = "paseo:global"; // Provider refresh probes can be slow on cold starts (e.g. Copilot's first // `copilot --acp` invocation, OpenCode workspace probes with many MCP servers). @@ -81,6 +83,11 @@ interface ProviderSnapshotRefreshOptions { providers?: AgentProvider[]; } +interface ProviderSnapshotWarmUpOptions { + cwd?: string | null; + providers?: AgentProvider[]; +} + interface ProviderSnapshotReadOptions { cwd?: string | null; providers?: AgentProvider[]; @@ -126,14 +133,22 @@ export interface AgentManagerProviderState { } interface ProviderLoadOptions { - cwd: string; + snapshotCwd: string; providers: AgentProvider[]; + catalogScope: ProviderCatalogScope; force: boolean; } interface ProviderLoad { promise: Promise; } +type ProviderCatalogScope = { scope: "global" } | { scope: "workspace"; cwd: string }; + +interface ProviderSnapshotTarget { + snapshotCwd: string; + catalogScope: ProviderCatalogScope; +} + export class ProviderSnapshotManager { private readonly snapshots = new Map>(); private readonly providerLoads = new Map>(); @@ -171,37 +186,36 @@ export class ProviderSnapshotManager { } getSnapshot(cwd?: string): ProviderSnapshotEntry[] { - const resolvedCwd = resolveSnapshotCwd(cwd); - const providersToWarm = this.resolveProvidersToWarm(resolvedCwd); - if (providersToWarm.length > 0) { - void this.warmUp(resolvedCwd, providersToWarm); - } - return entriesToArray(this.getOrCreateSnapshot(resolvedCwd)); + const target = resolveProviderSnapshotTarget(cwd); + return this.getSnapshotForTarget(target); } async refreshSnapshotForCwd(options: ProviderSnapshotRefreshOptions): Promise { const snapshotCwd = resolveSnapshotCwd(options.cwd); + const target = createWorkspaceSnapshotTarget(snapshotCwd); const providers = this.resolveRefreshProviders(options.providers); this.resetSnapshotToLoading(snapshotCwd, providers, { preserveExisting: false }); this.emitChange(snapshotCwd); - await this.refreshProviders(snapshotCwd, providers ?? this.getProviderIds()); + await this.refreshProviders(target, providers ?? this.getProviderIds()); } async refreshSettingsSnapshot( options: Omit = {}, ): Promise { - const homeCwd = resolveSnapshotCwd(); + const target = createGlobalSnapshotTarget(); + const homeCwd = target.snapshotCwd; const providers = this.resolveRefreshProviders(options.providers); const providersToRefresh = providers ?? this.getProviderIds(); this.clearCachedProviders(providers); this.resetSnapshotToLoading(homeCwd, providers, { preserveExisting: false }); this.emitChange(homeCwd); - await this.refreshProviders(homeCwd, providersToRefresh); + await this.refreshProviders(target, providersToRefresh); } - async warmUpSnapshotForCwd(options: ProviderSnapshotRefreshOptions): Promise { - const snapshotCwd = resolveSnapshotCwd(options.cwd); + async warmUpSnapshotForCwd(options: ProviderSnapshotWarmUpOptions): Promise { + const target = resolveProviderSnapshotTarget(options.cwd); + const snapshotCwd = target.snapshotCwd; const providers = this.resolveRefreshProviders(options.providers); if (options.providers && providers?.length === 0) { return; @@ -211,7 +225,7 @@ export class ProviderSnapshotManager { if (providersToWarm.length === 0) { return; } - await this.warmUp(snapshotCwd, providersToWarm); + await this.warmUp(target, providersToWarm); } async refresh(options: ProviderSnapshotRefreshOptions): Promise { @@ -261,12 +275,12 @@ export class ProviderSnapshotManager { } async listProviders(input: ProviderSnapshotReadOptions = {}): Promise { - const cwd = resolveSnapshotCwd(input.cwd); + const target = resolveProviderSnapshotTarget(input.cwd); if (input.wait) { - await this.warmUpSnapshotForCwd({ cwd, providers: input.providers }); + await this.warmUpSnapshotForCwd({ cwd: input.cwd, providers: input.providers }); } const providerFilter = input.providers ? new Set(input.providers) : null; - const entries = this.getSnapshot(cwd); + const entries = this.getSnapshotForTarget(target); return providerFilter ? entries.filter((entry) => providerFilter.has(entry.provider)) : entries; } @@ -442,6 +456,14 @@ export class ProviderSnapshotManager { }; } + private getSnapshotForTarget(target: ProviderSnapshotTarget): ProviderSnapshotEntry[] { + const providersToWarm = this.resolveProvidersToWarm(target.snapshotCwd); + if (providersToWarm.length > 0) { + void this.warmUp(target, providersToWarm); + } + return entriesToArray(this.getOrCreateSnapshot(target.snapshotCwd)); + } + private async getReadyProvider( input: ProviderSnapshotProviderOptions, ): Promise { @@ -471,9 +493,11 @@ export class ProviderSnapshotManager { definition: ProviderDefinition, ): Promise { try { - const cwd = resolveSnapshotCwd(); - await this.refreshSnapshotForCwd({ cwd, providers: [provider] }); - return await this.getProvider({ cwd, provider, wait: false }); + const target = createGlobalSnapshotTarget(); + this.resetSnapshotToLoading(target.snapshotCwd, [provider], { preserveExisting: false }); + this.emitChange(target.snapshotCwd); + await this.refreshProviders(target, [provider]); + return await this.getProvider({ provider, wait: false }); } catch (error) { return { provider, @@ -561,18 +585,27 @@ export class ProviderSnapshotManager { return entries; } - private async warmUp(cwd: string, providers?: AgentProvider[]): Promise { + private async warmUp(target: ProviderSnapshotTarget, providers?: AgentProvider[]): Promise { const providersToRefresh = providers ?? this.getProviderIds(); await this.loadProviders({ - cwd, + snapshotCwd: target.snapshotCwd, + catalogScope: target.catalogScope, providers: providersToRefresh, force: false, }); } - private async refreshProviders(cwd: string, providers: AgentProvider[]): Promise { - await this.loadProviders({ cwd, providers, force: true }); + private async refreshProviders( + target: ProviderSnapshotTarget, + providers: AgentProvider[], + ): Promise { + await this.loadProviders({ + snapshotCwd: target.snapshotCwd, + catalogScope: target.catalogScope, + providers, + force: true, + }); } private resolveProvidersToWarm(cwd: string, providers?: AgentProvider[]): AgentProvider[] { @@ -644,11 +677,11 @@ export class ProviderSnapshotManager { return Promise.resolve(); } - const existingLoad = this.getProviderLoad(options.cwd, options.provider); + const existingLoad = this.getProviderLoad(options.snapshotCwd, options.provider); if (existingLoad && !options.force) { return existingLoad.promise; } - const existingEntry = this.snapshots.get(options.cwd)?.get(options.provider); + const existingEntry = this.snapshots.get(options.snapshotCwd)?.get(options.provider); if (existingEntry && existingEntry.status !== "loading" && !options.force) { return Promise.resolve(); } @@ -656,11 +689,12 @@ export class ProviderSnapshotManager { const load: ProviderLoad = { promise: Promise.resolve(), }; - this.setProviderLoad(options.cwd, options.provider, load); + this.setProviderLoad(options.snapshotCwd, options.provider, load); load.promise = Promise.resolve() .then(() => this.refreshProvider({ - cwd: options.cwd, + snapshotCwd: options.snapshotCwd, + catalogScope: options.catalogScope, provider: options.provider, definition, load, @@ -668,26 +702,27 @@ export class ProviderSnapshotManager { }), ) .finally(() => { - const providerLoads = this.providerLoads.get(options.cwd); + const providerLoads = this.providerLoads.get(options.snapshotCwd); if (providerLoads?.get(options.provider) === load) { providerLoads.delete(options.provider); } if (providerLoads?.size === 0) { - this.providerLoads.delete(options.cwd); + this.providerLoads.delete(options.snapshotCwd); } }); return load.promise; } private async refreshProvider(options: { - cwd: string; + snapshotCwd: string; + catalogScope: ProviderCatalogScope; provider: AgentProvider; definition: ProviderDefinition; load: ProviderLoad; force: boolean; }): Promise { - const { cwd, provider, definition, load, force } = options; - const snapshot = this.getOrCreateSnapshot(options.cwd); + const { snapshotCwd, catalogScope, provider, definition, load, force } = options; + const snapshot = this.getOrCreateSnapshot(snapshotCwd); const base = { provider, label: definition.label, @@ -695,11 +730,11 @@ export class ProviderSnapshotManager { defaultModeId: definition.defaultModeId, }; const setEntry = (entry: ProviderSnapshotEntry) => { - if (!this.isCurrentProviderLoad(cwd, provider, load)) { + if (!this.isCurrentProviderLoad(snapshotCwd, provider, load)) { return false; } snapshot.set(provider, entry); - this.emitChange(cwd); + this.emitChange(snapshotCwd); return true; }; @@ -720,8 +755,9 @@ export class ProviderSnapshotManager { return; } + const catalogOptions = createFetchCatalogOptions(catalogScope, force); const catalog = await withTimeout( - definition.fetchCatalog({ cwd, force, timeoutMs: this.refreshTimeoutMs }, client), + definition.fetchCatalog({ ...catalogOptions, timeoutMs: this.refreshTimeoutMs }, client), this.refreshTimeoutMs, `Timed out refreshing ${definition.label} after ${this.refreshTimeoutMs}ms`, ); @@ -742,7 +778,10 @@ export class ProviderSnapshotManager { error: toErrorMessage(error), }); if (emitted) { - this.logger.warn({ err: error, provider, cwd }, "Failed to refresh provider snapshot"); + this.logger.warn( + { err: error, provider, cwd: snapshotCwd }, + "Failed to refresh provider snapshot", + ); } } } @@ -856,6 +895,42 @@ export function resolveSnapshotCwd(cwd?: string | null): string { return resolved; } +function resolveProviderSnapshotTarget(cwd?: string | null): ProviderSnapshotTarget { + const trimmed = cwd?.trim(); + if (!trimmed) { + return createGlobalSnapshotTarget(); + } + return createWorkspaceSnapshotTarget(resolveSnapshotCwd(trimmed)); +} + +function createGlobalSnapshotTarget(): ProviderSnapshotTarget { + return { + snapshotCwd: GLOBAL_PROVIDER_SNAPSHOT_KEY, + catalogScope: { scope: "global" }, + }; +} + +function createWorkspaceSnapshotTarget(cwd: string): ProviderSnapshotTarget { + const snapshotCwd = resolveSnapshotCwd(cwd); + return { + snapshotCwd, + catalogScope: { scope: "workspace", cwd: snapshotCwd }, + }; +} + +function createFetchCatalogOptions( + scope: ProviderCatalogScope, + force: boolean, +): FetchCatalogOptions { + return scope.scope === "global" + ? { scope: "global", force } + : { scope: "workspace", cwd: scope.cwd, force }; +} + +export function isGlobalProviderSnapshotKey(cwd: string): boolean { + return cwd === GLOBAL_PROVIDER_SNAPSHOT_KEY; +} + function entriesToArray( entries: Map, ): ProviderSnapshotEntry[] { diff --git a/packages/server/src/server/agent/providers/acp-agent.test.ts b/packages/server/src/server/agent/providers/acp-agent.test.ts index da74bcaa5..ca2df6c1e 100644 --- a/packages/server/src/server/agent/providers/acp-agent.test.ts +++ b/packages/server/src/server/agent/providers/acp-agent.test.ts @@ -1379,7 +1379,9 @@ describe("ACPAgentClient modelTransformer", () => { modelTransformer: transformPiModels, }); - await expect(client.fetchCatalog({ cwd: "/tmp/acp-models", force: false })).resolves.toEqual({ + await expect( + client.fetchCatalog({ scope: "workspace", cwd: "/tmp/acp-models", force: false }), + ).resolves.toEqual({ models: [ { provider: "pi", @@ -1481,7 +1483,9 @@ describe("ACPAgentClient sessionResponseTransformer", () => { }), }); - await expect(client.fetchCatalog({ cwd: "/tmp/acp-modes", force: false })).resolves.toEqual({ + await expect( + client.fetchCatalog({ scope: "workspace", cwd: "/tmp/acp-modes", force: false }), + ).resolves.toEqual({ models: [], modes: [ { @@ -1517,7 +1521,7 @@ describe("ACPAgentClient fetchCatalog", () => { defaultModes: [], }); - await client.fetchCatalog({ cwd: "/tmp/acp-catalog-cwd", force: false }); + await client.fetchCatalog({ scope: "workspace", cwd: "/tmp/acp-catalog-cwd", force: false }); expect(newSession).toHaveBeenCalledWith({ cwd: "/tmp/acp-catalog-cwd", @@ -1563,7 +1567,9 @@ describe("ACPAgentClient fetchCatalog", () => { defaultModes: [], }); - await expect(client.fetchCatalog({ cwd: "/tmp/acp-modes", force: false })).resolves.toEqual({ + await expect( + client.fetchCatalog({ scope: "workspace", cwd: "/tmp/acp-modes", force: false }), + ).resolves.toEqual({ models: [], modes: [], }); @@ -2443,7 +2449,7 @@ describe("ACPAgentClient probe cleanup", () => { terminateProcess: terminator.terminate, }); - await client.fetchCatalog({ cwd: "/tmp/acp-models", force: false }); + await client.fetchCatalog({ scope: "workspace", cwd: "/tmp/acp-models", force: false }); expect(terminator.terminated).toContain(child); expect(child.stdin.destroyed).toBe(true); diff --git a/packages/server/src/server/agent/providers/acp-agent.ts b/packages/server/src/server/agent/providers/acp-agent.ts index 7024b70d9..00016a61a 100644 --- a/packages/server/src/server/agent/providers/acp-agent.ts +++ b/packages/server/src/server/agent/providers/acp-agent.ts @@ -799,7 +799,7 @@ export class ACPAgentClient implements AgentClient { } async fetchCatalog(options: FetchCatalogOptions): Promise { - const { cwd } = options; + const cwd = options.scope === "global" ? homedir() : options.cwd; const timeoutMs = options.timeoutMs ?? ACP_CATALOG_TIMEOUT_MS; let probe: UninitializedACPProcess | null = null; try { diff --git a/packages/server/src/server/agent/providers/claude/agent.test.ts b/packages/server/src/server/agent/providers/claude/agent.test.ts index 9c05414dc..9000fbc93 100644 --- a/packages/server/src/server/agent/providers/claude/agent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.test.ts @@ -406,7 +406,11 @@ describe("ClaudeAgentClient.fetchCatalog", () => { resolveBinary: async () => "/test/claude/bin", configDir: emptyConfigDir, }); - const { models } = await client.fetchCatalog({ cwd: "/tmp/claude-models", force: false }); + const { models } = await client.fetchCatalog({ + scope: "workspace", + cwd: "/tmp/claude-models", + force: false, + }); expect(models.map((m) => m.id)).toEqual([ "claude-fable-5", @@ -441,7 +445,11 @@ describe("ClaudeAgentClient.fetchCatalog", () => { resolveBinary: async () => "/test/claude/bin", configDir: emptyConfigDir, }); - const { models } = await client.fetchCatalog({ cwd: "/tmp/claude-models", force: false }); + const { models } = await client.fetchCatalog({ + scope: "workspace", + cwd: "/tmp/claude-models", + force: false, + }); const getThinkingIds = (modelId: string) => { return models.find((model) => model.id === modelId)?.thinkingOptions?.map(({ id }) => id); }; diff --git a/packages/server/src/server/agent/providers/claude/models.test.ts b/packages/server/src/server/agent/providers/claude/models.test.ts index 05468a306..3ed41aa04 100644 --- a/packages/server/src/server/agent/providers/claude/models.test.ts +++ b/packages/server/src/server/agent/providers/claude/models.test.ts @@ -78,7 +78,11 @@ describe("ClaudeAgentClient.fetchCatalog", () => { vi.stubEnv("CLAUDE_CONFIG_DIR", configDir); const client = new ClaudeAgentClient({ logger: createTestLogger() }); - const { models } = await client.fetchCatalog({ cwd: os.tmpdir(), force: true }); + const { models } = await client.fetchCatalog({ + scope: "workspace", + cwd: os.tmpdir(), + force: true, + }); expect(models).toEqual([ ...getClaudeModels(), @@ -127,7 +131,11 @@ describe("ClaudeAgentClient.fetchCatalog", () => { vi.stubEnv("CLAUDE_CONFIG_DIR", configDir); const client = new ClaudeAgentClient({ logger: createTestLogger() }); - const { models } = await client.fetchCatalog({ cwd: os.tmpdir(), force: true }); + const { models } = await client.fetchCatalog({ + scope: "workspace", + cwd: os.tmpdir(), + force: true, + }); expect(models).toEqual(getClaudeModels()); }); @@ -137,7 +145,11 @@ describe("ClaudeAgentClient.fetchCatalog", () => { vi.stubEnv("CLAUDE_CONFIG_DIR", configDir); const client = new ClaudeAgentClient({ logger: createTestLogger() }); - const { models } = await client.fetchCatalog({ cwd: os.tmpdir(), force: true }); + const { models } = await client.fetchCatalog({ + scope: "workspace", + cwd: os.tmpdir(), + force: true, + }); expect(models).toEqual(getClaudeModels()); }); @@ -153,7 +165,11 @@ describe("ClaudeAgentClient.fetchCatalog", () => { vi.stubEnv("CLAUDE_CONFIG_DIR", configDir); const client = new ClaudeAgentClient({ logger: createTestLogger() }); - const { models } = await client.fetchCatalog({ cwd: os.tmpdir(), force: true }); + const { models } = await client.fetchCatalog({ + scope: "workspace", + cwd: os.tmpdir(), + force: true, + }); expect(models).toEqual(getClaudeModels()); }); @@ -169,7 +185,11 @@ describe("ClaudeAgentClient.fetchCatalog", () => { vi.stubEnv("CLAUDE_CONFIG_DIR", configDir); const client = new ClaudeAgentClient({ logger: createTestLogger() }); - const { models } = await client.fetchCatalog({ cwd: os.tmpdir(), force: true }); + const { models } = await client.fetchCatalog({ + scope: "workspace", + cwd: os.tmpdir(), + force: true, + }); expect(models.map((model) => model.id)).toEqual([ ...getClaudeModels().map((model) => model.id), diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.real.e2e.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.real.e2e.test.ts index 0dcf7e7d4..798cfffcf 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.real.e2e.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.real.e2e.test.ts @@ -26,7 +26,7 @@ describe("Codex app-server provider (real)", () => { test("lists models and runs a simple prompt", async () => { const client = createRealProviderClient("codex", createTestLogger()); const cwd = mkdtempSync(path.join(os.tmpdir(), "codex-app-server-e2e-")); - const { models } = await client.fetchCatalog({ cwd, force: false }); + const { models } = await client.fetchCatalog({ scope: "workspace", cwd, force: false }); expect(models.length).toBeGreaterThan(0); const session = await client.createSession({ diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.spawn-error.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.spawn-error.test.ts index 865c6c65b..bd1e0580f 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.spawn-error.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.spawn-error.test.ts @@ -22,7 +22,7 @@ describe("CodexAppServerAgentClient spawn error handling", () => { try { await expect( - client.fetchCatalog({ cwd: "/tmp/codex-models", force: false }), + client.fetchCatalog({ scope: "workspace", cwd: "/tmp/codex-models", force: false }), ).rejects.toThrow(); // Drain microtask queue to ensure no deferred uncaught errors await new Promise((resolve) => setTimeout(resolve, 100)); diff --git a/packages/server/src/server/agent/providers/cursor-acp-agent.test.ts b/packages/server/src/server/agent/providers/cursor-acp-agent.test.ts index c745bc52a..c6bea204d 100644 --- a/packages/server/src/server/agent/providers/cursor-acp-agent.test.ts +++ b/packages/server/src/server/agent/providers/cursor-acp-agent.test.ts @@ -45,7 +45,9 @@ describe("CursorACPAgentClient model discovery", () => { configOptions: [], }); - await expect(client.fetchCatalog({ cwd: "/tmp/cursor", force: false })).resolves.toEqual({ + await expect( + client.fetchCatalog({ scope: "workspace", cwd: "/tmp/cursor", force: false }), + ).resolves.toEqual({ models: [ { provider: "acp", @@ -68,7 +70,9 @@ describe("CursorACPAgentClient model discovery", () => { configOptions: [], }); - await expect(client.fetchCatalog({ cwd: "/tmp/cursor", force: false })).resolves.toEqual({ + await expect( + client.fetchCatalog({ scope: "workspace", cwd: "/tmp/cursor", force: false }), + ).resolves.toEqual({ models: [], modes: [], }); diff --git a/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts b/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts index ce0d81a6b..93444d2f9 100644 --- a/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts +++ b/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts @@ -33,7 +33,11 @@ describe("MockLoadTestAgentClient", () => { test("default model is a five minute foreground stream with token-rate intervals", async () => { const client = new MockLoadTestAgentClient(); - const { models } = await client.fetchCatalog({ cwd: "/tmp/mock-models", force: false }); + const { models } = await client.fetchCatalog({ + scope: "workspace", + cwd: "/tmp/mock-models", + force: false, + }); expect(models[0]).toMatchObject({ id: MOCK_LOAD_TEST_DEFAULT_MODEL_ID, diff --git a/packages/server/src/server/agent/providers/opencode-agent.full-access.test.ts b/packages/server/src/server/agent/providers/opencode-agent.full-access.test.ts index dd4998bcc..0605ad02d 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.full-access.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.full-access.test.ts @@ -81,7 +81,11 @@ describe("OpenCode auto_accept feature", () => { serverManager: runtime, createClient: runtime.createClient, }); - const { modes } = await client.fetchCatalog({ cwd: "/tmp/project", force: false }); + const { modes } = await client.fetchCatalog({ + scope: "workspace", + cwd: "/tmp/project", + force: false, + }); expect(modes.map((mode) => mode.id)).toEqual(["build", "paseo-custom"]); }); @@ -93,7 +97,11 @@ describe("OpenCode auto_accept feature", () => { serverManager: runtime, createClient: runtime.createClient, }); - const { modes } = await client.fetchCatalog({ cwd: "/tmp/project", force: false }); + const { modes } = await client.fetchCatalog({ + scope: "workspace", + cwd: "/tmp/project", + force: false, + }); expect(modes.map((mode) => mode.id)).toEqual(["build", "plan"]); }); diff --git a/packages/server/src/server/agent/providers/opencode-agent.list-models-timeout.test.ts b/packages/server/src/server/agent/providers/opencode-agent.list-models-timeout.test.ts index 032e7299a..ffa85ce38 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.list-models-timeout.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.list-models-timeout.test.ts @@ -44,7 +44,11 @@ test("allows a slow provider.list call to succeed instead of failing after 10 se serverManager: runtime, createClient: runtime.createClient, }); - const modelsPromise = client.fetchCatalog({ cwd: "/tmp/opencode-models", force: false }); + const modelsPromise = client.fetchCatalog({ + scope: "workspace", + cwd: "/tmp/opencode-models", + force: false, + }); await vi.advanceTimersByTimeAsync(15_000); @@ -76,7 +80,7 @@ test("uses a new server for explicit catalog refresh", async () => { createClient: runtime.createClient, }); - await client.fetchCatalog({ cwd: "/tmp/opencode-models", force: true }); + await client.fetchCatalog({ scope: "workspace", cwd: "/tmp/opencode-models", force: true }); expect(runtime.acquisitions).toEqual([{ kind: "new", releaseCount: 1 }]); }); @@ -110,7 +114,11 @@ test("includes models from api-source providers not in connected", async () => { serverManager: runtime, createClient: runtime.createClient, }); - const { models } = await client.fetchCatalog({ cwd: "/tmp/opencode-models", force: false }); + const { models } = await client.fetchCatalog({ + scope: "workspace", + cwd: "/tmp/opencode-models", + force: false, + }); expect(models).toMatchObject([ { @@ -146,9 +154,9 @@ test("throws when no providers are accessible (neither connected nor api-source) createClient: runtime.createClient, }); - await expect(client.fetchCatalog({ cwd: "/tmp/opencode-models", force: false })).rejects.toThrow( - "OpenCode has no connected providers", - ); + await expect( + client.fetchCatalog({ scope: "workspace", cwd: "/tmp/opencode-models", force: false }), + ).rejects.toThrow("OpenCode has no connected providers"); }); test("does not throw when only api-source providers are present with no connected providers", async () => { @@ -177,7 +185,7 @@ test("does not throw when only api-source providers are present with no connecte }); await expect( - client.fetchCatalog({ cwd: "/tmp/opencode-models", force: false }), + client.fetchCatalog({ scope: "workspace", cwd: "/tmp/opencode-models", force: false }), ).resolves.toMatchObject({ models: [ { diff --git a/packages/server/src/server/agent/providers/opencode-agent.test.ts b/packages/server/src/server/agent/providers/opencode-agent.test.ts index c3999eed6..139a63637 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test, vi } from "vitest"; -import { mkdtempSync, realpathSync, rmSync } from "node:fs"; +import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -305,12 +305,14 @@ describe("OpenCodeAgentClient adapter smoke tests", () => { ], }; runtime.enqueueClient(openCodeClient); + const paseoHome = tmpCwd(); + const opencodeHome = path.join(paseoHome, "opencode-home"); const client = new OpenCodeAgentClient(logger, undefined, { serverManager: runtime, createClient: runtime.createClient, + resolveHomeDir: () => opencodeHome, }); - const cwd = os.homedir(); - const catalog = await client.fetchCatalog({ cwd, force: false }); + const catalog = await client.fetchCatalog({ scope: "global", force: false }); expect(Array.isArray(catalog.models)).toBe(true); expect(catalog.models).toHaveLength(1); @@ -339,9 +341,46 @@ describe("OpenCodeAgentClient adapter smoke tests", () => { contextWindowMaxTokens: 200_000, }, }); - expect(openCodeClient.calls.providerList).toEqual([{ directory: cwd }]); + expect(openCodeClient.calls.providerList).toEqual([{ directory: opencodeHome }]); + rmSync(paseoHome, { recursive: true, force: true }); }, 60_000); + test("fetchCatalog releases the acquired server when opencode-home cannot be created", async () => { + const runtime = new TestOpenCodeHarness(); + const paseoHome = tmpCwd(); + const opencodeHome = path.join(paseoHome, "opencode-home"); + writeFileSync(opencodeHome, "not a directory"); + const client = new OpenCodeAgentClient(logger, undefined, { + serverManager: runtime, + createClient: runtime.createClient, + resolveHomeDir: () => opencodeHome, + }); + + await expect(client.fetchCatalog({ scope: "global", force: false })).rejects.toThrow(); + + expect(runtime.acquisitions).toEqual([{ kind: "current", releaseCount: 1 }]); + expect(runtime.clientCreations).toEqual([]); + rmSync(paseoHome, { recursive: true, force: true }); + }); + + test("fetchCatalog releases the acquired server when opencode-home cannot be resolved", async () => { + const runtime = new TestOpenCodeHarness(); + const client = new OpenCodeAgentClient(logger, undefined, { + serverManager: runtime, + createClient: runtime.createClient, + resolveHomeDir: () => { + throw new Error("cannot resolve opencode-home"); + }, + }); + + await expect(client.fetchCatalog({ scope: "global", force: false })).rejects.toThrow( + "cannot resolve opencode-home", + ); + + expect(runtime.acquisitions).toEqual([{ kind: "current", releaseCount: 1 }]); + expect(runtime.clientCreations).toEqual([]); + }); + test("limits concurrent OpenCode metadata requests across clients", async () => { const runtime = new TestOpenCodeHarness(); let activeProviderListCalls = 0; @@ -382,7 +421,11 @@ describe("OpenCodeAgentClient adapter smoke tests", () => { }); await Promise.all( Array.from({ length: 12 }, (_, index) => - client.fetchCatalog({ cwd: path.join(os.tmpdir(), `opencode-cwd-${index}`), force: false }), + client.fetchCatalog({ + scope: "workspace", + cwd: path.join(os.tmpdir(), `opencode-cwd-${index}`), + force: false, + }), ), ); diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index e9e919591..0cd170764 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -11,6 +11,7 @@ import { type Session as OpenCodeSession, type TextPartInput as OpenCodeTextPartInput, } from "@opencode-ai/sdk/v2/client"; +import fs from "node:fs/promises"; import { createPathEquivalenceMatcher } from "../../../utils/path.js"; import pLimit from "p-limit"; import type { Logger } from "pino"; @@ -70,6 +71,7 @@ import { OpenCodeServerManager, type OpenCodeServerManagerLike, } from "./opencode/server-manager.js"; +import { resolveOpenCodeHomeDir } from "./opencode/paths.js"; import { formatProviderDiagnostic, formatProviderDiagnosticError, @@ -1206,6 +1208,7 @@ export const __openCodeInternals = { resolveOpenCodeSelectedModelContextWindow, isSelectableOpenCodeAgent, mapOpenCodeAgentToMode, + resolveOpenCodeHomeDir, get OpenCodeAgentSession() { return OpenCodeAgentSession; }, @@ -1214,6 +1217,7 @@ export const __openCodeInternals = { interface OpenCodeAgentClientDeps { serverManager?: OpenCodeServerManagerLike; createClient?: OpenCodeClientFactory; + resolveHomeDir?: () => string; managedProcesses?: ManagedProcessRegistry; } @@ -1231,6 +1235,7 @@ export class OpenCodeAgentClient implements AgentClient { private readonly serverManager: OpenCodeServerManagerLike; private readonly createOpenCodeClient: OpenCodeClientFactory; + private readonly resolveHomeDir: () => string; private readonly logger: Logger; private readonly runtimeSettings?: ProviderRuntimeSettings; private readonly modelContextWindows = new Map(); @@ -1246,8 +1251,10 @@ export class OpenCodeAgentClient implements AgentClient { deps.serverManager ?? OpenCodeServerManager.getInstance(this.logger, runtimeSettings, { managedProcesses: deps.managedProcesses, + resolveHomeDir: deps.resolveHomeDir, }); this.createOpenCodeClient = deps.createClient ?? createSdkOpenCodeClient; + this.resolveHomeDir = deps.resolveHomeDir ?? resolveOpenCodeHomeDir; } async createSession( @@ -1348,10 +1355,22 @@ export class OpenCodeAgentClient implements AgentClient { ? await this.serverManager.acquireNew() : await this.serverManager.acquireCurrent(); const { url } = acquisition.server; - const directory = options.cwd; - const client = this.createOpenCodeClient({ baseUrl: url, directory }); + const isGlobalCatalog = options.scope === "global"; try { + // OpenCode treats the catalog directory as a workspace. The global catalog + // is not a project, so use the neutral OpenCode home instead of user home. + const directory = isGlobalCatalog ? this.resolveHomeDir() : options.cwd; + + if (isGlobalCatalog) { + await fs.mkdir(directory, { recursive: true }); + this.logger.debug( + { directory }, + "opencode catalog refresh: using opencode-home for global provider catalog", + ); + } + + const client = this.createOpenCodeClient({ baseUrl: url, directory }); const [models, modes] = await Promise.all([ this.fetchModelsFromClient(client, directory), this.fetchModesFromClient(client, directory), diff --git a/packages/server/src/server/agent/providers/opencode-server-manager.test.ts b/packages/server/src/server/agent/providers/opencode-server-manager.test.ts index a8cd8abcf..027ef949c 100644 --- a/packages/server/src/server/agent/providers/opencode-server-manager.test.ts +++ b/packages/server/src/server/agent/providers/opencode-server-manager.test.ts @@ -1,5 +1,8 @@ import type { ChildProcess } from "node:child_process"; import { EventEmitter } from "node:events"; +import { mkdtempSync, rmSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { afterEach, describe, expect, test, vi } from "vitest"; import { createTestLogger } from "../../../test-utils/test-logger.js"; @@ -208,15 +211,39 @@ describe("OpenCodeServerManager managed process ledger", () => { expect(runtime.terminatedPorts).toEqual([4602]); expect(await runtime.managedProcesses.list()).toEqual([]); }); + + test("starts helper server from opencode-home", async () => { + const tempDir = mkdtempSync(path.join(os.tmpdir(), "opencode-server-home-")); + const opencodeHomeDir = path.join(tempDir, "opencode-home"); + try { + const { manager, runtime } = createTestManager([4603], { opencodeHomeDir }); + + const acquisition = await manager.acquireCurrent(); + + expect(runtime.spawnCalls).toEqual([ + expect.objectContaining({ + command: "opencode", + args: ["serve", "--port", "4603"], + options: expect.objectContaining({ cwd: opencodeHomeDir }), + }), + ]); + + acquisition.release(); + await manager.shutdown(); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); }); function createTestManager( ports: number[], - options: { autoAnnounce?: boolean } = {}, + options: { autoAnnounce?: boolean; opencodeHomeDir?: string } = {}, ): { manager: OpenCodeServerManager; runtime: FakeOpenCodeServerRuntime; } { + const { opencodeHomeDir } = options; const runtime = new FakeOpenCodeServerRuntime(ports, { autoAnnounce: options.autoAnnounce ?? true, }); @@ -226,6 +253,7 @@ function createTestManager( managedProcesses: runtime.managedProcesses, portAllocator: runtime.allocatePort, resolveCommandPrefix: runtime.resolveCommandPrefix, + ...(opencodeHomeDir ? { resolveHomeDir: () => opencodeHomeDir } : {}), spawnServerProcess: runtime.spawnServerProcess, terminateProcess: runtime.terminateProcess, }), @@ -236,6 +264,11 @@ function createTestManager( class FakeOpenCodeServerRuntime { readonly managedProcesses = new FakeManagedProcesses(); readonly terminatedPorts: number[] = []; + readonly spawnCalls: Array<{ + command: string; + args: string[]; + options: Parameters[2]; + }> = []; private readonly ports: number[]; private readonly autoAnnounce: boolean; private readonly processesByChild = new Map(); @@ -263,7 +296,8 @@ class FakeOpenCodeServerRuntime { args: [], }); - readonly spawnServerProcess: OpenCodeServerProcessSpawner = (command, args) => { + readonly spawnServerProcess: OpenCodeServerProcessSpawner = (command, args, options) => { + this.spawnCalls.push({ command, args, options }); const port = Number(args.at(-1)); const process = new FakeOpenCodeProcess({ port, pid: 10_000 + port }); this.processesByChild.set(process.child, process); diff --git a/packages/server/src/server/agent/providers/opencode/paths.ts b/packages/server/src/server/agent/providers/opencode/paths.ts new file mode 100644 index 000000000..b8e65c539 --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/paths.ts @@ -0,0 +1,9 @@ +import path from "node:path"; + +import { resolvePaseoHome } from "../../../paseo-home.js"; + +const OPENCODE_HOME_DIRNAME = "opencode-home"; + +export function resolveOpenCodeHomeDir(env: NodeJS.ProcessEnv = process.env): string { + return path.join(resolvePaseoHome(env), OPENCODE_HOME_DIRNAME); +} diff --git a/packages/server/src/server/agent/providers/opencode/server-manager.ts b/packages/server/src/server/agent/providers/opencode/server-manager.ts index fe4a14fc7..634cc55c6 100644 --- a/packages/server/src/server/agent/providers/opencode/server-manager.ts +++ b/packages/server/src/server/agent/providers/opencode/server-manager.ts @@ -1,6 +1,6 @@ import type { ChildProcess } from "node:child_process"; +import { mkdirSync } from "node:fs"; import net from "node:net"; -import os from "node:os"; import type { Logger } from "pino"; import { findExecutable } from "../../../../executable-resolution/executable-resolution.js"; @@ -12,6 +12,7 @@ import { resolveProviderCommandPrefix, type ProviderRuntimeSettings, } from "../../provider-launch-config.js"; +import { resolveOpenCodeHomeDir } from "./paths.js"; const OPENCODE_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_MS = 5_000; const OPENCODE_SERVER_FORCE_SHUTDOWN_TIMEOUT_MS = 1_000; @@ -55,6 +56,7 @@ export interface OpenCodeServerManagerOptions { terminateProcess?: ProcessTerminator; portAllocator?: OpenCodePortAllocator; resolveCommandPrefix?: OpenCodeCommandPrefixResolver; + resolveHomeDir?: () => string; spawnServerProcess?: OpenCodeServerProcessSpawner; } @@ -72,6 +74,7 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike { private readonly terminateProcess: ProcessTerminator; private readonly portAllocator: OpenCodePortAllocator; private readonly resolveCommandPrefix: OpenCodeCommandPrefixResolver; + private readonly resolveHomeDir: () => string; private readonly spawnServerProcess: OpenCodeServerProcessSpawner; constructor(options: OpenCodeServerManagerOptions) { @@ -84,6 +87,7 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike { this.resolveCommandPrefix = options.resolveCommandPrefix ?? (() => resolveProviderCommandPrefix(this.runtimeSettings?.command, resolveOpenCodeBinary)); + this.resolveHomeDir = options.resolveHomeDir ?? resolveOpenCodeHomeDir; this.spawnServerProcess = options.spawnServerProcess ?? spawnProcess; } @@ -249,7 +253,11 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike { const url = `http://127.0.0.1:${port}`; const launchPrefix = await this.resolveCommandPrefix(); const serverArgs = [...launchPrefix.args, "serve", "--port", String(port)]; - const serverCwd = os.homedir(); + // Use a neutral OpenCode home as the server cwd. Launching from the user's + // home directory causes OpenCode to treat it as the default workspace and + // index the entire home tree. + const serverCwd = this.resolveHomeDir(); + mkdirSync(serverCwd, { recursive: true }); const serverProcess = this.spawnServerProcess(launchPrefix.command, serverArgs, { cwd: serverCwd, diff --git a/packages/server/src/server/agent/providers/pi/agent.test.ts b/packages/server/src/server/agent/providers/pi/agent.test.ts index e24bf2901..5b6450b2e 100644 --- a/packages/server/src/server/agent/providers/pi/agent.test.ts +++ b/packages/server/src/server/agent/providers/pi/agent.test.ts @@ -861,7 +861,11 @@ describe("PiRpcAgentClient", () => { test("discovers models from a short-lived Pi session in the requested cwd", async () => { const pi = new FakePi(); const client = createClient(pi); - const catalogPromise = client.fetchCatalog({ cwd: "/workspace/with-extension", force: false }); + const catalogPromise = client.fetchCatalog({ + scope: "workspace", + cwd: "/workspace/with-extension", + force: false, + }); pi.latestSession().models = [ { provider: "openrouter", diff --git a/packages/server/src/server/agent/providers/pi/agent.ts b/packages/server/src/server/agent/providers/pi/agent.ts index 381c49fb3..cd4fbddc8 100644 --- a/packages/server/src/server/agent/providers/pi/agent.ts +++ b/packages/server/src/server/agent/providers/pi/agent.ts @@ -1971,7 +1971,9 @@ export class PiRpcAgentClient implements AgentClient { } async fetchCatalog(options: FetchCatalogOptions): Promise { - const runtimeSession = await this.runtime.startSession({ cwd: options.cwd }); + const runtimeSession = await this.runtime.startSession({ + cwd: options.scope === "global" ? homedir() : options.cwd, + }); try { const models = transformPiModels( (await runtimeSession.getAvailableModels(PI_CATALOG_REQUEST_TIMEOUT_MS)).map(mapPiModel), diff --git a/packages/server/src/server/agent/tools/paseo-tools.ts b/packages/server/src/server/agent/tools/paseo-tools.ts index a7dc5da67..498d8ada3 100644 --- a/packages/server/src/server/agent/tools/paseo-tools.ts +++ b/packages/server/src/server/agent/tools/paseo-tools.ts @@ -40,7 +40,7 @@ import { type ScheduleCadence, type UpdateScheduleInput, } from "@getpaseo/protocol/schedule/types"; -import { resolveSnapshotCwd, type ProviderSnapshotManager } from "../provider-snapshot-manager.js"; +import type { ProviderSnapshotManager } from "../provider-snapshot-manager.js"; import { AgentModelSchema, AgentProviderEnum, @@ -2319,7 +2319,6 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase }, async ({ provider }) => { const models = await providerSnapshotManager.listModels({ - cwd: resolveSnapshotCwd(), provider, wait: true, }); diff --git a/packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts index 17aeee8e3..949f976bb 100644 --- a/packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts @@ -656,7 +656,7 @@ test( const client = createPiClient(); const cwd = tmpCwd("pi-list-models-"); try { - const { models } = await client.fetchCatalog({ cwd, force: false }); + const { models } = await client.fetchCatalog({ scope: "workspace", cwd, force: false }); expect(models.length).toBeGreaterThan(0); for (const model of models) { diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index 7a1410921..d85ce7f2b 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -1,6 +1,6 @@ import { execSync } from "child_process"; import { mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "fs"; -import { homedir, tmpdir } from "os"; +import { tmpdir } from "os"; import { join, resolve as resolvePath } from "path"; import pino from "pino"; import { afterEach, describe, expect, test, vi } from "vitest"; @@ -1212,7 +1212,7 @@ describe("session provider refresh cwd routing", () => { expect(getSnapshot).toHaveBeenCalledWith(workspaceCwd); }); - test("normalizes legacy model and mode list requests without cwd to home", async () => { + test("preserves legacy model and mode list requests without cwd as global", async () => { const messages: unknown[] = []; const { manager: providerSnapshotManager, @@ -1239,9 +1239,9 @@ describe("session provider refresh cwd routing", () => { requestId: "modes-home", }); - expect(getSnapshot).toHaveBeenCalledWith(homedir()); + expect(getSnapshot).toHaveBeenCalledWith(undefined); expect(warmUpSnapshotForCwd).toHaveBeenCalledWith({ - cwd: homedir(), + cwd: undefined, providers: ["codex"], }); }); @@ -1343,7 +1343,7 @@ describe("session provider refresh cwd routing", () => { }); expect(warmUpSnapshotForCwd).toHaveBeenCalledWith({ - cwd: homedir(), + cwd: undefined, providers: ["codex"], }); warmupDeferred.resolve(); diff --git a/packages/server/src/server/session/provider/provider-catalog-session.test.ts b/packages/server/src/server/session/provider/provider-catalog-session.test.ts index 2d0d9040d..d2e5b7b7d 100644 --- a/packages/server/src/server/session/provider/provider-catalog-session.test.ts +++ b/packages/server/src/server/session/provider/provider-catalog-session.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import pino from "pino"; import { ProviderCatalogSession, @@ -8,7 +8,7 @@ import { createStub } from "../../test-utils/class-mocks.js"; import { findByType } from "../../test-utils/session-stubs.js"; import type { SessionOutboundMessage } from "../../messages.js"; import { - resolveSnapshotCwd, + GLOBAL_PROVIDER_SNAPSHOT_KEY, type ProviderSnapshotManager, } from "../../agent/provider-snapshot-manager.js"; import type { ProviderSnapshotEntry } from "../../agent/agent-sdk-types.js"; @@ -66,7 +66,10 @@ function makeSubsystem(options: MakeOptions = {}) { providerUsageService: createStub(options.usage ?? {}), logger: pino({ level: "silent" }), }); - function pushSnapshotChange(entries: ProviderSnapshotEntry[], cwd = resolveSnapshotCwd()): void { + function pushSnapshotChange( + entries: ProviderSnapshotEntry[], + cwd = GLOBAL_PROVIDER_SNAPSHOT_KEY, + ): void { if (!changeHandler) throw new Error("start() must run before a snapshot change"); changeHandler(entries, cwd); } @@ -142,6 +145,26 @@ describe("ProviderCatalogSession", () => { expect(res?.payload.error).toBe("Provider codex is disabled"); }); + it("preserves missing cwd as the semantic global snapshot for model list reads", async () => { + const getSnapshot = vi.fn(() => [{ provider: "codex", status: "loading", enabled: true }]); + const warmUpSnapshotForCwd = vi.fn(async () => {}); + const { subsystem } = makeSubsystem({ + snapshot: { getSnapshot, warmUpSnapshotForCwd }, + }); + + await subsystem.handleListProviderModelsRequest({ + type: "list_provider_models_request", + provider: "codex", + requestId: "m-global", + }); + + expect(getSnapshot).toHaveBeenCalledWith(undefined); + expect(warmUpSnapshotForCwd).toHaveBeenCalledWith({ + cwd: undefined, + providers: ["codex"], + }); + }); + it("surfaces a usage-list failure as an rpc_error envelope", async () => { const { subsystem, emitted } = makeSubsystem({ usage: { diff --git a/packages/server/src/server/session/provider/provider-catalog-session.ts b/packages/server/src/server/session/provider/provider-catalog-session.ts index fcb4561f1..dbcf94075 100644 --- a/packages/server/src/server/session/provider/provider-catalog-session.ts +++ b/packages/server/src/server/session/provider/provider-catalog-session.ts @@ -2,7 +2,7 @@ import type pino from "pino"; import { getErrorMessage } from "@getpaseo/protocol/error-utils"; import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js"; import { - resolveSnapshotCwd, + isGlobalProviderSnapshotKey, type ProviderSnapshotManager, } from "../../agent/provider-snapshot-manager.js"; import type { @@ -77,7 +77,7 @@ export class ProviderCatalogSession { const visibleEntries = entries.filter((entry) => this.host.isProviderVisibleToClient(entry.provider), ); - const snapshotCwd = cwd === resolveSnapshotCwd() ? undefined : cwd; + const snapshotCwd = isGlobalProviderSnapshotKey(cwd) ? undefined : cwd; this.host.emit({ type: "providers_snapshot_update", payload: { @@ -141,7 +141,7 @@ export class ProviderCatalogSession { async handleListProviderModelsRequest( msg: Extract, ): Promise { - const cwd = resolveSnapshotCwd(msg.cwd ? expandTilde(msg.cwd) : undefined); + const cwd = resolveCatalogRequestCwd(msg.cwd); const fetchedAt = new Date().toISOString(); const entry = await this.getProviderSnapshotEntryForRead(cwd, msg.provider); @@ -198,7 +198,7 @@ export class ProviderCatalogSession { msg: Extract, ): Promise { const fetchedAt = new Date().toISOString(); - const cwd = resolveSnapshotCwd(msg.cwd ? expandTilde(msg.cwd) : undefined); + const cwd = resolveCatalogRequestCwd(msg.cwd); const entry = await this.getProviderSnapshotEntryForRead(cwd, msg.provider); if (!entry) { @@ -250,7 +250,7 @@ export class ProviderCatalogSession { } private async getProviderSnapshotEntryForRead( - cwd: string, + cwd: string | undefined, provider: AgentProvider, ): Promise { const manager = this.providerSnapshotManager; @@ -452,3 +452,8 @@ export class ProviderCatalogSession { } } } + +function resolveCatalogRequestCwd(cwd?: string | null): string | undefined { + const trimmed = cwd?.trim(); + return trimmed ? expandTilde(trimmed) : undefined; +}