fix(opencode): prevent indexing the entire home directory (#1704)

* fix(opencode): prevent indexing the entire home directory

Paseo launches opencode serve with cwd=os.homedir() and refreshes the
global provider snapshot with directory=/Users/admin. OpenCode treats
that as a workspace and starts location services + bigram indexing for
the entire home tree, causing ~466% CPU and ~4GB RAM usage.

- Use a neutral scratch directory as the opencode serve cwd.
- Use a separate scratch directory for global provider catalog refresh
  so model/mode discovery no longer triggers home directory indexing.

Fixes high CPU/RAM when Paseo starts opencode with no explicit project.

* fix(opencode): use realpath-aware matcher for home detection in catalog refresh

Switch the home-directory check in fetchCatalog from a string-based
path.resolve() comparison to createRealpathAwarePathMatcher, so we
catch macOS /private/var/... aliases, symlinks, trailing separators,
and Windows casing — consistent with the rest of opencode-agent.ts.

Also:
- Hoist the matcher to module scope so each fetchCatalog call doesn't
  rebuild it (the matcher runs realpathSync twice on construction).
- Log a debug line when we rewrite the cwd to the scratch path, so
  it's easy to diagnose missing per-directory config in catalog scope.
- Update opencode-agent.test.ts to expect the scratch directory when
  cwd === os.homedir(), with a comment pointing to the rationale.

* fix(opencode): isolate helper server home

* fix(opencode): pass semantic global catalog scope

* fix(opencode): release catalog acquisition on home resolution failure

---------

Co-authored-by: rex-chang <rex-chang@users.noreply.github.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
rex
2026-06-26 21:55:34 +08:00
committed by GitHub
co-authored by rex-chang Mohamed Boudra
parent 8228bafc5d
commit 5ef118ea33
28 changed files with 470 additions and 110 deletions
+3 -3
View File
@@ -56,15 +56,15 @@ Daemon bootstrap reconciles that ledger in the background, without blocking star
## Provider Snapshot Refresh Contract ## 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. 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. 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.
--- ---
@@ -3702,7 +3702,11 @@ export class AgentManager {
return undefined; return undefined;
} }
try { 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; return (catalog.models.find((model) => model.isDefault) ?? catalog.models[0])?.id;
} catch { } catch {
// Provider may not support model listing — leave model undefined. // Provider may not support model listing — leave model undefined.
@@ -643,11 +643,18 @@ export interface AgentSession {
} | null; } | null;
} }
export interface FetchCatalogOptions { export type FetchCatalogOptions =
cwd: string; | {
force: boolean; scope: "global";
timeoutMs?: number; force: boolean;
} timeoutMs?: number;
}
| {
scope: "workspace";
cwd: string;
force: boolean;
timeoutMs?: number;
};
export interface ProviderCatalog { export interface ProviderCatalog {
models: AgentModelDefinition[]; models: AgentModelDefinition[];
@@ -810,6 +810,7 @@ describe("model merging", () => {
}); });
const { models } = await registry.codex.fetchCatalog({ const { models } = await registry.codex.fetchCatalog({
scope: "workspace",
cwd: "/tmp/registry-models", cwd: "/tmp/registry-models",
force: false, force: false,
}); });
@@ -845,6 +846,7 @@ describe("model merging", () => {
}); });
const { models } = await registry.codex.fetchCatalog({ const { models } = await registry.codex.fetchCatalog({
scope: "workspace",
cwd: "/tmp/registry-models", cwd: "/tmp/registry-models",
force: false, force: false,
}); });
@@ -883,6 +885,7 @@ describe("model merging", () => {
}); });
const { models } = await registry.codex.fetchCatalog({ const { models } = await registry.codex.fetchCatalog({
scope: "workspace",
cwd: "/tmp/registry-models", cwd: "/tmp/registry-models",
force: false, force: false,
}); });
@@ -917,6 +920,7 @@ describe("model merging", () => {
}); });
const { models } = await registry.codex.fetchCatalog({ const { models } = await registry.codex.fetchCatalog({
scope: "workspace",
cwd: "/tmp/registry-models", cwd: "/tmp/registry-models",
force: false, force: false,
}); });
@@ -959,6 +963,7 @@ describe("model merging", () => {
}); });
const { models } = await registry.claude.fetchCatalog({ const { models } = await registry.claude.fetchCatalog({
scope: "workspace",
cwd: "/tmp/registry-models", cwd: "/tmp/registry-models",
force: false, force: false,
}); });
@@ -1009,6 +1014,7 @@ describe("model merging", () => {
}); });
const { models } = await registry.claude.fetchCatalog({ const { models } = await registry.claude.fetchCatalog({
scope: "workspace",
cwd: "/tmp/registry-models", cwd: "/tmp/registry-models",
force: false, force: false,
}); });
@@ -1056,6 +1062,7 @@ describe("model merging", () => {
}); });
const { models } = await registry.codex.fetchCatalog({ const { models } = await registry.codex.fetchCatalog({
scope: "workspace",
cwd: "/tmp/registry-models", cwd: "/tmp/registry-models",
force: false, force: false,
}); });
@@ -1095,6 +1102,7 @@ describe("model merging", () => {
}); });
const { models } = await registry.claude.fetchCatalog({ const { models } = await registry.claude.fetchCatalog({
scope: "workspace",
cwd: "/tmp/registry-models", cwd: "/tmp/registry-models",
force: false, force: false,
}); });
@@ -1147,6 +1155,7 @@ describe("model merging", () => {
}); });
const { models } = await registry.claude.fetchCatalog({ const { models } = await registry.claude.fetchCatalog({
scope: "workspace",
cwd: "/tmp/registry-models", cwd: "/tmp/registry-models",
force: false, force: false,
}); });
@@ -1185,6 +1194,7 @@ describe("model merging", () => {
const registry = buildProviderRegistry(logger); const registry = buildProviderRegistry(logger);
const { models } = await registry.claude.fetchCatalog({ const { models } = await registry.claude.fetchCatalog({
scope: "workspace",
cwd: "/tmp/registry-models", cwd: "/tmp/registry-models",
force: false, force: false,
}); });
@@ -1225,6 +1235,7 @@ describe("model merging", () => {
const client = registry.codex.createClient(logger); const client = registry.codex.createClient(logger);
const catalog = await client.fetchCatalog({ const catalog = await client.fetchCatalog({
scope: "workspace",
cwd: "/tmp/registry-models", cwd: "/tmp/registry-models",
force: false, force: false,
}); });
@@ -1259,6 +1270,7 @@ describe("model merging", () => {
const client = registry.claude.createClient(logger); const client = registry.claude.createClient(logger);
const catalog = await client.fetchCatalog({ const catalog = await client.fetchCatalog({
scope: "workspace",
cwd: "/tmp/registry-models", cwd: "/tmp/registry-models",
force: false, force: false,
}); });
@@ -1287,6 +1299,7 @@ describe("model merging", () => {
}); });
const { models } = await registry.claude.fetchCatalog({ const { models } = await registry.claude.fetchCatalog({
scope: "workspace",
cwd: "/tmp/registry-models", cwd: "/tmp/registry-models",
force: false, force: false,
}); });
@@ -1304,6 +1317,7 @@ describe("fetchCatalog", () => {
const registry = buildProviderRegistry(logger); const registry = buildProviderRegistry(logger);
const catalog = await registry.codex.fetchCatalog({ const catalog = await registry.codex.fetchCatalog({
scope: "workspace",
cwd: "/tmp/catalog", cwd: "/tmp/catalog",
force: false, force: false,
}); });
@@ -1327,6 +1341,7 @@ describe("fetchCatalog", () => {
}); });
const catalog = await registry.codex.fetchCatalog({ const catalog = await registry.codex.fetchCatalog({
scope: "workspace",
cwd: "/tmp/catalog", cwd: "/tmp/catalog",
force: false, force: false,
}); });
@@ -1345,6 +1360,7 @@ describe("fetchCatalog", () => {
}); });
const catalog = await registry.codex.fetchCatalog({ const catalog = await registry.codex.fetchCatalog({
scope: "workspace",
cwd: "/tmp/catalog", cwd: "/tmp/catalog",
force: false, force: false,
}); });
@@ -1,3 +1,4 @@
import { homedir } from "node:os";
import { resolve } from "node:path"; import { resolve } from "node:path";
import { describe, expect, test, vi } from "vitest"; import { describe, expect, test, vi } from "vitest";
@@ -11,7 +12,11 @@ import type {
ResolveAgentCreateConfigInput, ResolveAgentCreateConfigInput,
} from "./agent-sdk-types.js"; } from "./agent-sdk-types.js";
import type { ManagedAgent } from "./agent-manager.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"; import { OpenCodeAgentClient } from "./providers/opencode-agent.js";
const TEST_CAPABILITIES = { const TEST_CAPABILITIES = {
@@ -488,6 +493,7 @@ describe("ProviderSnapshotManager public surface", () => {
try { try {
const result = await manager.getProviderDiagnostic("codex"); const result = await manager.getProviderDiagnostic("codex");
expect(fetchCatalog).toHaveBeenCalledTimes(1); 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("Models: 1");
expect(result.diagnostic).toContain("Status: Ready"); expect(result.diagnostic).toContain("Status: Ready");
} finally { } finally {
@@ -1063,6 +1069,60 @@ describe("ProviderSnapshotManager lifecycle", () => {
}); });
describe("ProviderSnapshotManager cwd routing", () => { 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", () => { test("different cwd keys produce independent snapshots", () => {
const manager = new ProviderSnapshotManager({ const manager = new ProviderSnapshotManager({
logger: createTestLogger(), 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({ const manager = new ProviderSnapshotManager({
logger: createTestLogger(), logger: createTestLogger(),
providerOverrides: { providerOverrides: {
@@ -1103,13 +1163,7 @@ describe("ProviderSnapshotManager cwd routing", () => {
manager.getSnapshot(); manager.getSnapshot();
manager.applyMutableProviderConfig({}); manager.applyMutableProviderConfig({});
const cwds = listener.mock.calls.map((call) => call[1]); const cwds = listener.mock.calls.map((call) => call[1]);
// applyMutableProviderConfig emits change for each primed cwd; the home expect(cwds).toContain(GLOBAL_PROVIDER_SNAPSHOT_KEY);
// snapshot must be present.
expect(cwds.length).toBeGreaterThanOrEqual(1);
for (const cwd of cwds) {
expect(typeof cwd).toBe("string");
expect(cwd.length).toBeGreaterThan(0);
}
} finally { } finally {
manager.destroy(); manager.destroy();
} }
@@ -12,6 +12,7 @@ import type {
AgentMode, AgentMode,
AgentModelDefinition, AgentModelDefinition,
AgentProvider, AgentProvider,
FetchCatalogOptions,
ProviderSnapshotEntry, ProviderSnapshotEntry,
} from "./agent-sdk-types.js"; } from "./agent-sdk-types.js";
import type { ManagedAgent } from "./agent-manager.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_REFRESH_TIMEOUT_MS = 60_000;
const DEFAULT_DIAGNOSTIC_TIMEOUT_MS = 120_000; const DEFAULT_DIAGNOSTIC_TIMEOUT_MS = 120_000;
const REFRESH_TIMEOUT_ENV_VAR = "PASEO_PROVIDER_REFRESH_TIMEOUT_MS"; 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 // Provider refresh probes can be slow on cold starts (e.g. Copilot's first
// `copilot --acp` invocation, OpenCode workspace probes with many MCP servers). // `copilot --acp` invocation, OpenCode workspace probes with many MCP servers).
@@ -81,6 +83,11 @@ interface ProviderSnapshotRefreshOptions {
providers?: AgentProvider[]; providers?: AgentProvider[];
} }
interface ProviderSnapshotWarmUpOptions {
cwd?: string | null;
providers?: AgentProvider[];
}
interface ProviderSnapshotReadOptions { interface ProviderSnapshotReadOptions {
cwd?: string | null; cwd?: string | null;
providers?: AgentProvider[]; providers?: AgentProvider[];
@@ -126,14 +133,22 @@ export interface AgentManagerProviderState {
} }
interface ProviderLoadOptions { interface ProviderLoadOptions {
cwd: string; snapshotCwd: string;
providers: AgentProvider[]; providers: AgentProvider[];
catalogScope: ProviderCatalogScope;
force: boolean; force: boolean;
} }
interface ProviderLoad { interface ProviderLoad {
promise: Promise<void>; promise: Promise<void>;
} }
type ProviderCatalogScope = { scope: "global" } | { scope: "workspace"; cwd: string };
interface ProviderSnapshotTarget {
snapshotCwd: string;
catalogScope: ProviderCatalogScope;
}
export class ProviderSnapshotManager { export class ProviderSnapshotManager {
private readonly snapshots = new Map<string, Map<AgentProvider, ProviderSnapshotEntry>>(); private readonly snapshots = new Map<string, Map<AgentProvider, ProviderSnapshotEntry>>();
private readonly providerLoads = new Map<string, Map<AgentProvider, ProviderLoad>>(); private readonly providerLoads = new Map<string, Map<AgentProvider, ProviderLoad>>();
@@ -171,37 +186,36 @@ export class ProviderSnapshotManager {
} }
getSnapshot(cwd?: string): ProviderSnapshotEntry[] { getSnapshot(cwd?: string): ProviderSnapshotEntry[] {
const resolvedCwd = resolveSnapshotCwd(cwd); const target = resolveProviderSnapshotTarget(cwd);
const providersToWarm = this.resolveProvidersToWarm(resolvedCwd); return this.getSnapshotForTarget(target);
if (providersToWarm.length > 0) {
void this.warmUp(resolvedCwd, providersToWarm);
}
return entriesToArray(this.getOrCreateSnapshot(resolvedCwd));
} }
async refreshSnapshotForCwd(options: ProviderSnapshotRefreshOptions): Promise<void> { async refreshSnapshotForCwd(options: ProviderSnapshotRefreshOptions): Promise<void> {
const snapshotCwd = resolveSnapshotCwd(options.cwd); const snapshotCwd = resolveSnapshotCwd(options.cwd);
const target = createWorkspaceSnapshotTarget(snapshotCwd);
const providers = this.resolveRefreshProviders(options.providers); const providers = this.resolveRefreshProviders(options.providers);
this.resetSnapshotToLoading(snapshotCwd, providers, { preserveExisting: false }); this.resetSnapshotToLoading(snapshotCwd, providers, { preserveExisting: false });
this.emitChange(snapshotCwd); this.emitChange(snapshotCwd);
await this.refreshProviders(snapshotCwd, providers ?? this.getProviderIds()); await this.refreshProviders(target, providers ?? this.getProviderIds());
} }
async refreshSettingsSnapshot( async refreshSettingsSnapshot(
options: Omit<ProviderSnapshotRefreshOptions, "cwd"> = {}, options: Omit<ProviderSnapshotRefreshOptions, "cwd"> = {},
): Promise<void> { ): Promise<void> {
const homeCwd = resolveSnapshotCwd(); const target = createGlobalSnapshotTarget();
const homeCwd = target.snapshotCwd;
const providers = this.resolveRefreshProviders(options.providers); const providers = this.resolveRefreshProviders(options.providers);
const providersToRefresh = providers ?? this.getProviderIds(); const providersToRefresh = providers ?? this.getProviderIds();
this.clearCachedProviders(providers); this.clearCachedProviders(providers);
this.resetSnapshotToLoading(homeCwd, providers, { preserveExisting: false }); this.resetSnapshotToLoading(homeCwd, providers, { preserveExisting: false });
this.emitChange(homeCwd); this.emitChange(homeCwd);
await this.refreshProviders(homeCwd, providersToRefresh); await this.refreshProviders(target, providersToRefresh);
} }
async warmUpSnapshotForCwd(options: ProviderSnapshotRefreshOptions): Promise<void> { async warmUpSnapshotForCwd(options: ProviderSnapshotWarmUpOptions): Promise<void> {
const snapshotCwd = resolveSnapshotCwd(options.cwd); const target = resolveProviderSnapshotTarget(options.cwd);
const snapshotCwd = target.snapshotCwd;
const providers = this.resolveRefreshProviders(options.providers); const providers = this.resolveRefreshProviders(options.providers);
if (options.providers && providers?.length === 0) { if (options.providers && providers?.length === 0) {
return; return;
@@ -211,7 +225,7 @@ export class ProviderSnapshotManager {
if (providersToWarm.length === 0) { if (providersToWarm.length === 0) {
return; return;
} }
await this.warmUp(snapshotCwd, providersToWarm); await this.warmUp(target, providersToWarm);
} }
async refresh(options: ProviderSnapshotRefreshOptions): Promise<void> { async refresh(options: ProviderSnapshotRefreshOptions): Promise<void> {
@@ -261,12 +275,12 @@ export class ProviderSnapshotManager {
} }
async listProviders(input: ProviderSnapshotReadOptions = {}): Promise<ProviderSnapshotEntry[]> { async listProviders(input: ProviderSnapshotReadOptions = {}): Promise<ProviderSnapshotEntry[]> {
const cwd = resolveSnapshotCwd(input.cwd); const target = resolveProviderSnapshotTarget(input.cwd);
if (input.wait) { 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 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; 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( private async getReadyProvider(
input: ProviderSnapshotProviderOptions, input: ProviderSnapshotProviderOptions,
): Promise<ProviderSnapshotEntry> { ): Promise<ProviderSnapshotEntry> {
@@ -471,9 +493,11 @@ export class ProviderSnapshotManager {
definition: ProviderDefinition, definition: ProviderDefinition,
): Promise<ProviderSnapshotEntry> { ): Promise<ProviderSnapshotEntry> {
try { try {
const cwd = resolveSnapshotCwd(); const target = createGlobalSnapshotTarget();
await this.refreshSnapshotForCwd({ cwd, providers: [provider] }); this.resetSnapshotToLoading(target.snapshotCwd, [provider], { preserveExisting: false });
return await this.getProvider({ cwd, provider, wait: false }); this.emitChange(target.snapshotCwd);
await this.refreshProviders(target, [provider]);
return await this.getProvider({ provider, wait: false });
} catch (error) { } catch (error) {
return { return {
provider, provider,
@@ -561,18 +585,27 @@ export class ProviderSnapshotManager {
return entries; return entries;
} }
private async warmUp(cwd: string, providers?: AgentProvider[]): Promise<void> { private async warmUp(target: ProviderSnapshotTarget, providers?: AgentProvider[]): Promise<void> {
const providersToRefresh = providers ?? this.getProviderIds(); const providersToRefresh = providers ?? this.getProviderIds();
await this.loadProviders({ await this.loadProviders({
cwd, snapshotCwd: target.snapshotCwd,
catalogScope: target.catalogScope,
providers: providersToRefresh, providers: providersToRefresh,
force: false, force: false,
}); });
} }
private async refreshProviders(cwd: string, providers: AgentProvider[]): Promise<void> { private async refreshProviders(
await this.loadProviders({ cwd, providers, force: true }); target: ProviderSnapshotTarget,
providers: AgentProvider[],
): Promise<void> {
await this.loadProviders({
snapshotCwd: target.snapshotCwd,
catalogScope: target.catalogScope,
providers,
force: true,
});
} }
private resolveProvidersToWarm(cwd: string, providers?: AgentProvider[]): AgentProvider[] { private resolveProvidersToWarm(cwd: string, providers?: AgentProvider[]): AgentProvider[] {
@@ -644,11 +677,11 @@ export class ProviderSnapshotManager {
return Promise.resolve(); return Promise.resolve();
} }
const existingLoad = this.getProviderLoad(options.cwd, options.provider); const existingLoad = this.getProviderLoad(options.snapshotCwd, options.provider);
if (existingLoad && !options.force) { if (existingLoad && !options.force) {
return existingLoad.promise; 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) { if (existingEntry && existingEntry.status !== "loading" && !options.force) {
return Promise.resolve(); return Promise.resolve();
} }
@@ -656,11 +689,12 @@ export class ProviderSnapshotManager {
const load: ProviderLoad = { const load: ProviderLoad = {
promise: Promise.resolve(), promise: Promise.resolve(),
}; };
this.setProviderLoad(options.cwd, options.provider, load); this.setProviderLoad(options.snapshotCwd, options.provider, load);
load.promise = Promise.resolve() load.promise = Promise.resolve()
.then(() => .then(() =>
this.refreshProvider({ this.refreshProvider({
cwd: options.cwd, snapshotCwd: options.snapshotCwd,
catalogScope: options.catalogScope,
provider: options.provider, provider: options.provider,
definition, definition,
load, load,
@@ -668,26 +702,27 @@ export class ProviderSnapshotManager {
}), }),
) )
.finally(() => { .finally(() => {
const providerLoads = this.providerLoads.get(options.cwd); const providerLoads = this.providerLoads.get(options.snapshotCwd);
if (providerLoads?.get(options.provider) === load) { if (providerLoads?.get(options.provider) === load) {
providerLoads.delete(options.provider); providerLoads.delete(options.provider);
} }
if (providerLoads?.size === 0) { if (providerLoads?.size === 0) {
this.providerLoads.delete(options.cwd); this.providerLoads.delete(options.snapshotCwd);
} }
}); });
return load.promise; return load.promise;
} }
private async refreshProvider(options: { private async refreshProvider(options: {
cwd: string; snapshotCwd: string;
catalogScope: ProviderCatalogScope;
provider: AgentProvider; provider: AgentProvider;
definition: ProviderDefinition; definition: ProviderDefinition;
load: ProviderLoad; load: ProviderLoad;
force: boolean; force: boolean;
}): Promise<void> { }): Promise<void> {
const { cwd, provider, definition, load, force } = options; const { snapshotCwd, catalogScope, provider, definition, load, force } = options;
const snapshot = this.getOrCreateSnapshot(options.cwd); const snapshot = this.getOrCreateSnapshot(snapshotCwd);
const base = { const base = {
provider, provider,
label: definition.label, label: definition.label,
@@ -695,11 +730,11 @@ export class ProviderSnapshotManager {
defaultModeId: definition.defaultModeId, defaultModeId: definition.defaultModeId,
}; };
const setEntry = (entry: ProviderSnapshotEntry) => { const setEntry = (entry: ProviderSnapshotEntry) => {
if (!this.isCurrentProviderLoad(cwd, provider, load)) { if (!this.isCurrentProviderLoad(snapshotCwd, provider, load)) {
return false; return false;
} }
snapshot.set(provider, entry); snapshot.set(provider, entry);
this.emitChange(cwd); this.emitChange(snapshotCwd);
return true; return true;
}; };
@@ -720,8 +755,9 @@ export class ProviderSnapshotManager {
return; return;
} }
const catalogOptions = createFetchCatalogOptions(catalogScope, force);
const catalog = await withTimeout( const catalog = await withTimeout(
definition.fetchCatalog({ cwd, force, timeoutMs: this.refreshTimeoutMs }, client), definition.fetchCatalog({ ...catalogOptions, timeoutMs: this.refreshTimeoutMs }, client),
this.refreshTimeoutMs, this.refreshTimeoutMs,
`Timed out refreshing ${definition.label} after ${this.refreshTimeoutMs}ms`, `Timed out refreshing ${definition.label} after ${this.refreshTimeoutMs}ms`,
); );
@@ -742,7 +778,10 @@ export class ProviderSnapshotManager {
error: toErrorMessage(error), error: toErrorMessage(error),
}); });
if (emitted) { 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; 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( function entriesToArray(
entries: Map<AgentProvider, ProviderSnapshotEntry>, entries: Map<AgentProvider, ProviderSnapshotEntry>,
): ProviderSnapshotEntry[] { ): ProviderSnapshotEntry[] {
@@ -1379,7 +1379,9 @@ describe("ACPAgentClient modelTransformer", () => {
modelTransformer: transformPiModels, 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: [ models: [
{ {
provider: "pi", 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: [], models: [],
modes: [ modes: [
{ {
@@ -1517,7 +1521,7 @@ describe("ACPAgentClient fetchCatalog", () => {
defaultModes: [], 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({ expect(newSession).toHaveBeenCalledWith({
cwd: "/tmp/acp-catalog-cwd", cwd: "/tmp/acp-catalog-cwd",
@@ -1563,7 +1567,9 @@ describe("ACPAgentClient fetchCatalog", () => {
defaultModes: [], 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: [], models: [],
modes: [], modes: [],
}); });
@@ -2443,7 +2449,7 @@ describe("ACPAgentClient probe cleanup", () => {
terminateProcess: terminator.terminate, 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(terminator.terminated).toContain(child);
expect(child.stdin.destroyed).toBe(true); expect(child.stdin.destroyed).toBe(true);
@@ -799,7 +799,7 @@ export class ACPAgentClient implements AgentClient {
} }
async fetchCatalog(options: FetchCatalogOptions): Promise<ProviderCatalog> { async fetchCatalog(options: FetchCatalogOptions): Promise<ProviderCatalog> {
const { cwd } = options; const cwd = options.scope === "global" ? homedir() : options.cwd;
const timeoutMs = options.timeoutMs ?? ACP_CATALOG_TIMEOUT_MS; const timeoutMs = options.timeoutMs ?? ACP_CATALOG_TIMEOUT_MS;
let probe: UninitializedACPProcess | null = null; let probe: UninitializedACPProcess | null = null;
try { try {
@@ -406,7 +406,11 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
resolveBinary: async () => "/test/claude/bin", resolveBinary: async () => "/test/claude/bin",
configDir: emptyConfigDir, 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([ expect(models.map((m) => m.id)).toEqual([
"claude-fable-5", "claude-fable-5",
@@ -441,7 +445,11 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
resolveBinary: async () => "/test/claude/bin", resolveBinary: async () => "/test/claude/bin",
configDir: emptyConfigDir, 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) => { const getThinkingIds = (modelId: string) => {
return models.find((model) => model.id === modelId)?.thinkingOptions?.map(({ id }) => id); return models.find((model) => model.id === modelId)?.thinkingOptions?.map(({ id }) => id);
}; };
@@ -78,7 +78,11 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
vi.stubEnv("CLAUDE_CONFIG_DIR", configDir); vi.stubEnv("CLAUDE_CONFIG_DIR", configDir);
const client = new ClaudeAgentClient({ logger: createTestLogger() }); 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([ expect(models).toEqual([
...getClaudeModels(), ...getClaudeModels(),
@@ -127,7 +131,11 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
vi.stubEnv("CLAUDE_CONFIG_DIR", configDir); vi.stubEnv("CLAUDE_CONFIG_DIR", configDir);
const client = new ClaudeAgentClient({ logger: createTestLogger() }); 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()); expect(models).toEqual(getClaudeModels());
}); });
@@ -137,7 +145,11 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
vi.stubEnv("CLAUDE_CONFIG_DIR", configDir); vi.stubEnv("CLAUDE_CONFIG_DIR", configDir);
const client = new ClaudeAgentClient({ logger: createTestLogger() }); 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()); expect(models).toEqual(getClaudeModels());
}); });
@@ -153,7 +165,11 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
vi.stubEnv("CLAUDE_CONFIG_DIR", configDir); vi.stubEnv("CLAUDE_CONFIG_DIR", configDir);
const client = new ClaudeAgentClient({ logger: createTestLogger() }); 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()); expect(models).toEqual(getClaudeModels());
}); });
@@ -169,7 +185,11 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
vi.stubEnv("CLAUDE_CONFIG_DIR", configDir); vi.stubEnv("CLAUDE_CONFIG_DIR", configDir);
const client = new ClaudeAgentClient({ logger: createTestLogger() }); 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([ expect(models.map((model) => model.id)).toEqual([
...getClaudeModels().map((model) => model.id), ...getClaudeModels().map((model) => model.id),
@@ -26,7 +26,7 @@ describe("Codex app-server provider (real)", () => {
test("lists models and runs a simple prompt", async () => { test("lists models and runs a simple prompt", async () => {
const client = createRealProviderClient("codex", createTestLogger()); const client = createRealProviderClient("codex", createTestLogger());
const cwd = mkdtempSync(path.join(os.tmpdir(), "codex-app-server-e2e-")); 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); expect(models.length).toBeGreaterThan(0);
const session = await client.createSession({ const session = await client.createSession({
@@ -22,7 +22,7 @@ describe("CodexAppServerAgentClient spawn error handling", () => {
try { try {
await expect( await expect(
client.fetchCatalog({ cwd: "/tmp/codex-models", force: false }), client.fetchCatalog({ scope: "workspace", cwd: "/tmp/codex-models", force: false }),
).rejects.toThrow(); ).rejects.toThrow();
// Drain microtask queue to ensure no deferred uncaught errors // Drain microtask queue to ensure no deferred uncaught errors
await new Promise((resolve) => setTimeout(resolve, 100)); await new Promise((resolve) => setTimeout(resolve, 100));
@@ -45,7 +45,9 @@ describe("CursorACPAgentClient model discovery", () => {
configOptions: [], 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: [ models: [
{ {
provider: "acp", provider: "acp",
@@ -68,7 +70,9 @@ describe("CursorACPAgentClient model discovery", () => {
configOptions: [], 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: [], models: [],
modes: [], modes: [],
}); });
@@ -33,7 +33,11 @@ describe("MockLoadTestAgentClient", () => {
test("default model is a five minute foreground stream with token-rate intervals", async () => { test("default model is a five minute foreground stream with token-rate intervals", async () => {
const client = new MockLoadTestAgentClient(); 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({ expect(models[0]).toMatchObject({
id: MOCK_LOAD_TEST_DEFAULT_MODEL_ID, id: MOCK_LOAD_TEST_DEFAULT_MODEL_ID,
@@ -81,7 +81,11 @@ describe("OpenCode auto_accept feature", () => {
serverManager: runtime, serverManager: runtime,
createClient: runtime.createClient, 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"]); expect(modes.map((mode) => mode.id)).toEqual(["build", "paseo-custom"]);
}); });
@@ -93,7 +97,11 @@ describe("OpenCode auto_accept feature", () => {
serverManager: runtime, serverManager: runtime,
createClient: runtime.createClient, 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"]); expect(modes.map((mode) => mode.id)).toEqual(["build", "plan"]);
}); });
@@ -44,7 +44,11 @@ test("allows a slow provider.list call to succeed instead of failing after 10 se
serverManager: runtime, serverManager: runtime,
createClient: runtime.createClient, 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); await vi.advanceTimersByTimeAsync(15_000);
@@ -76,7 +80,7 @@ test("uses a new server for explicit catalog refresh", async () => {
createClient: runtime.createClient, 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 }]); 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, serverManager: runtime,
createClient: runtime.createClient, 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([ expect(models).toMatchObject([
{ {
@@ -146,9 +154,9 @@ test("throws when no providers are accessible (neither connected nor api-source)
createClient: runtime.createClient, createClient: runtime.createClient,
}); });
await expect(client.fetchCatalog({ cwd: "/tmp/opencode-models", force: false })).rejects.toThrow( await expect(
"OpenCode has no connected providers", 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 () => { 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( await expect(
client.fetchCatalog({ cwd: "/tmp/opencode-models", force: false }), client.fetchCatalog({ scope: "workspace", cwd: "/tmp/opencode-models", force: false }),
).resolves.toMatchObject({ ).resolves.toMatchObject({
models: [ models: [
{ {
@@ -1,5 +1,5 @@
import { describe, expect, test, vi } from "vitest"; 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 os from "node:os";
import path from "node:path"; import path from "node:path";
@@ -305,12 +305,14 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
], ],
}; };
runtime.enqueueClient(openCodeClient); runtime.enqueueClient(openCodeClient);
const paseoHome = tmpCwd();
const opencodeHome = path.join(paseoHome, "opencode-home");
const client = new OpenCodeAgentClient(logger, undefined, { const client = new OpenCodeAgentClient(logger, undefined, {
serverManager: runtime, serverManager: runtime,
createClient: runtime.createClient, createClient: runtime.createClient,
resolveHomeDir: () => opencodeHome,
}); });
const cwd = os.homedir(); const catalog = await client.fetchCatalog({ scope: "global", force: false });
const catalog = await client.fetchCatalog({ cwd, force: false });
expect(Array.isArray(catalog.models)).toBe(true); expect(Array.isArray(catalog.models)).toBe(true);
expect(catalog.models).toHaveLength(1); expect(catalog.models).toHaveLength(1);
@@ -339,9 +341,46 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
contextWindowMaxTokens: 200_000, 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); }, 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 () => { test("limits concurrent OpenCode metadata requests across clients", async () => {
const runtime = new TestOpenCodeHarness(); const runtime = new TestOpenCodeHarness();
let activeProviderListCalls = 0; let activeProviderListCalls = 0;
@@ -382,7 +421,11 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
}); });
await Promise.all( await Promise.all(
Array.from({ length: 12 }, (_, index) => 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,
}),
), ),
); );
@@ -11,6 +11,7 @@ import {
type Session as OpenCodeSession, type Session as OpenCodeSession,
type TextPartInput as OpenCodeTextPartInput, type TextPartInput as OpenCodeTextPartInput,
} from "@opencode-ai/sdk/v2/client"; } from "@opencode-ai/sdk/v2/client";
import fs from "node:fs/promises";
import { createPathEquivalenceMatcher } from "../../../utils/path.js"; import { createPathEquivalenceMatcher } from "../../../utils/path.js";
import pLimit from "p-limit"; import pLimit from "p-limit";
import type { Logger } from "pino"; import type { Logger } from "pino";
@@ -70,6 +71,7 @@ import {
OpenCodeServerManager, OpenCodeServerManager,
type OpenCodeServerManagerLike, type OpenCodeServerManagerLike,
} from "./opencode/server-manager.js"; } from "./opencode/server-manager.js";
import { resolveOpenCodeHomeDir } from "./opencode/paths.js";
import { import {
formatProviderDiagnostic, formatProviderDiagnostic,
formatProviderDiagnosticError, formatProviderDiagnosticError,
@@ -1206,6 +1208,7 @@ export const __openCodeInternals = {
resolveOpenCodeSelectedModelContextWindow, resolveOpenCodeSelectedModelContextWindow,
isSelectableOpenCodeAgent, isSelectableOpenCodeAgent,
mapOpenCodeAgentToMode, mapOpenCodeAgentToMode,
resolveOpenCodeHomeDir,
get OpenCodeAgentSession() { get OpenCodeAgentSession() {
return OpenCodeAgentSession; return OpenCodeAgentSession;
}, },
@@ -1214,6 +1217,7 @@ export const __openCodeInternals = {
interface OpenCodeAgentClientDeps { interface OpenCodeAgentClientDeps {
serverManager?: OpenCodeServerManagerLike; serverManager?: OpenCodeServerManagerLike;
createClient?: OpenCodeClientFactory; createClient?: OpenCodeClientFactory;
resolveHomeDir?: () => string;
managedProcesses?: ManagedProcessRegistry; managedProcesses?: ManagedProcessRegistry;
} }
@@ -1231,6 +1235,7 @@ export class OpenCodeAgentClient implements AgentClient {
private readonly serverManager: OpenCodeServerManagerLike; private readonly serverManager: OpenCodeServerManagerLike;
private readonly createOpenCodeClient: OpenCodeClientFactory; private readonly createOpenCodeClient: OpenCodeClientFactory;
private readonly resolveHomeDir: () => string;
private readonly logger: Logger; private readonly logger: Logger;
private readonly runtimeSettings?: ProviderRuntimeSettings; private readonly runtimeSettings?: ProviderRuntimeSettings;
private readonly modelContextWindows = new Map<string, number>(); private readonly modelContextWindows = new Map<string, number>();
@@ -1246,8 +1251,10 @@ export class OpenCodeAgentClient implements AgentClient {
deps.serverManager ?? deps.serverManager ??
OpenCodeServerManager.getInstance(this.logger, runtimeSettings, { OpenCodeServerManager.getInstance(this.logger, runtimeSettings, {
managedProcesses: deps.managedProcesses, managedProcesses: deps.managedProcesses,
resolveHomeDir: deps.resolveHomeDir,
}); });
this.createOpenCodeClient = deps.createClient ?? createSdkOpenCodeClient; this.createOpenCodeClient = deps.createClient ?? createSdkOpenCodeClient;
this.resolveHomeDir = deps.resolveHomeDir ?? resolveOpenCodeHomeDir;
} }
async createSession( async createSession(
@@ -1348,10 +1355,22 @@ export class OpenCodeAgentClient implements AgentClient {
? await this.serverManager.acquireNew() ? await this.serverManager.acquireNew()
: await this.serverManager.acquireCurrent(); : await this.serverManager.acquireCurrent();
const { url } = acquisition.server; const { url } = acquisition.server;
const directory = options.cwd; const isGlobalCatalog = options.scope === "global";
const client = this.createOpenCodeClient({ baseUrl: url, directory });
try { 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([ const [models, modes] = await Promise.all([
this.fetchModelsFromClient(client, directory), this.fetchModelsFromClient(client, directory),
this.fetchModesFromClient(client, directory), this.fetchModesFromClient(client, directory),
@@ -1,5 +1,8 @@
import type { ChildProcess } from "node:child_process"; import type { ChildProcess } from "node:child_process";
import { EventEmitter } from "node:events"; 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 { afterEach, describe, expect, test, vi } from "vitest";
import { createTestLogger } from "../../../test-utils/test-logger.js"; import { createTestLogger } from "../../../test-utils/test-logger.js";
@@ -208,15 +211,39 @@ describe("OpenCodeServerManager managed process ledger", () => {
expect(runtime.terminatedPorts).toEqual([4602]); expect(runtime.terminatedPorts).toEqual([4602]);
expect(await runtime.managedProcesses.list()).toEqual([]); 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( function createTestManager(
ports: number[], ports: number[],
options: { autoAnnounce?: boolean } = {}, options: { autoAnnounce?: boolean; opencodeHomeDir?: string } = {},
): { ): {
manager: OpenCodeServerManager; manager: OpenCodeServerManager;
runtime: FakeOpenCodeServerRuntime; runtime: FakeOpenCodeServerRuntime;
} { } {
const { opencodeHomeDir } = options;
const runtime = new FakeOpenCodeServerRuntime(ports, { const runtime = new FakeOpenCodeServerRuntime(ports, {
autoAnnounce: options.autoAnnounce ?? true, autoAnnounce: options.autoAnnounce ?? true,
}); });
@@ -226,6 +253,7 @@ function createTestManager(
managedProcesses: runtime.managedProcesses, managedProcesses: runtime.managedProcesses,
portAllocator: runtime.allocatePort, portAllocator: runtime.allocatePort,
resolveCommandPrefix: runtime.resolveCommandPrefix, resolveCommandPrefix: runtime.resolveCommandPrefix,
...(opencodeHomeDir ? { resolveHomeDir: () => opencodeHomeDir } : {}),
spawnServerProcess: runtime.spawnServerProcess, spawnServerProcess: runtime.spawnServerProcess,
terminateProcess: runtime.terminateProcess, terminateProcess: runtime.terminateProcess,
}), }),
@@ -236,6 +264,11 @@ function createTestManager(
class FakeOpenCodeServerRuntime { class FakeOpenCodeServerRuntime {
readonly managedProcesses = new FakeManagedProcesses(); readonly managedProcesses = new FakeManagedProcesses();
readonly terminatedPorts: number[] = []; readonly terminatedPorts: number[] = [];
readonly spawnCalls: Array<{
command: string;
args: string[];
options: Parameters<OpenCodeServerProcessSpawner>[2];
}> = [];
private readonly ports: number[]; private readonly ports: number[];
private readonly autoAnnounce: boolean; private readonly autoAnnounce: boolean;
private readonly processesByChild = new Map<ChildProcess, FakeOpenCodeProcess>(); private readonly processesByChild = new Map<ChildProcess, FakeOpenCodeProcess>();
@@ -263,7 +296,8 @@ class FakeOpenCodeServerRuntime {
args: [], 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 port = Number(args.at(-1));
const process = new FakeOpenCodeProcess({ port, pid: 10_000 + port }); const process = new FakeOpenCodeProcess({ port, pid: 10_000 + port });
this.processesByChild.set(process.child, process); this.processesByChild.set(process.child, process);
@@ -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);
}
@@ -1,6 +1,6 @@
import type { ChildProcess } from "node:child_process"; import type { ChildProcess } from "node:child_process";
import { mkdirSync } from "node:fs";
import net from "node:net"; import net from "node:net";
import os from "node:os";
import type { Logger } from "pino"; import type { Logger } from "pino";
import { findExecutable } from "../../../../executable-resolution/executable-resolution.js"; import { findExecutable } from "../../../../executable-resolution/executable-resolution.js";
@@ -12,6 +12,7 @@ import {
resolveProviderCommandPrefix, resolveProviderCommandPrefix,
type ProviderRuntimeSettings, type ProviderRuntimeSettings,
} from "../../provider-launch-config.js"; } from "../../provider-launch-config.js";
import { resolveOpenCodeHomeDir } from "./paths.js";
const OPENCODE_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_MS = 5_000; const OPENCODE_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_MS = 5_000;
const OPENCODE_SERVER_FORCE_SHUTDOWN_TIMEOUT_MS = 1_000; const OPENCODE_SERVER_FORCE_SHUTDOWN_TIMEOUT_MS = 1_000;
@@ -55,6 +56,7 @@ export interface OpenCodeServerManagerOptions {
terminateProcess?: ProcessTerminator; terminateProcess?: ProcessTerminator;
portAllocator?: OpenCodePortAllocator; portAllocator?: OpenCodePortAllocator;
resolveCommandPrefix?: OpenCodeCommandPrefixResolver; resolveCommandPrefix?: OpenCodeCommandPrefixResolver;
resolveHomeDir?: () => string;
spawnServerProcess?: OpenCodeServerProcessSpawner; spawnServerProcess?: OpenCodeServerProcessSpawner;
} }
@@ -72,6 +74,7 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike {
private readonly terminateProcess: ProcessTerminator; private readonly terminateProcess: ProcessTerminator;
private readonly portAllocator: OpenCodePortAllocator; private readonly portAllocator: OpenCodePortAllocator;
private readonly resolveCommandPrefix: OpenCodeCommandPrefixResolver; private readonly resolveCommandPrefix: OpenCodeCommandPrefixResolver;
private readonly resolveHomeDir: () => string;
private readonly spawnServerProcess: OpenCodeServerProcessSpawner; private readonly spawnServerProcess: OpenCodeServerProcessSpawner;
constructor(options: OpenCodeServerManagerOptions) { constructor(options: OpenCodeServerManagerOptions) {
@@ -84,6 +87,7 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike {
this.resolveCommandPrefix = this.resolveCommandPrefix =
options.resolveCommandPrefix ?? options.resolveCommandPrefix ??
(() => resolveProviderCommandPrefix(this.runtimeSettings?.command, resolveOpenCodeBinary)); (() => resolveProviderCommandPrefix(this.runtimeSettings?.command, resolveOpenCodeBinary));
this.resolveHomeDir = options.resolveHomeDir ?? resolveOpenCodeHomeDir;
this.spawnServerProcess = options.spawnServerProcess ?? spawnProcess; this.spawnServerProcess = options.spawnServerProcess ?? spawnProcess;
} }
@@ -249,7 +253,11 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike {
const url = `http://127.0.0.1:${port}`; const url = `http://127.0.0.1:${port}`;
const launchPrefix = await this.resolveCommandPrefix(); const launchPrefix = await this.resolveCommandPrefix();
const serverArgs = [...launchPrefix.args, "serve", "--port", String(port)]; 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, { const serverProcess = this.spawnServerProcess(launchPrefix.command, serverArgs, {
cwd: serverCwd, cwd: serverCwd,
@@ -861,7 +861,11 @@ describe("PiRpcAgentClient", () => {
test("discovers models from a short-lived Pi session in the requested cwd", async () => { test("discovers models from a short-lived Pi session in the requested cwd", async () => {
const pi = new FakePi(); const pi = new FakePi();
const client = createClient(pi); 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 = [ pi.latestSession().models = [
{ {
provider: "openrouter", provider: "openrouter",
@@ -1971,7 +1971,9 @@ export class PiRpcAgentClient implements AgentClient {
} }
async fetchCatalog(options: FetchCatalogOptions): Promise<ProviderCatalog> { async fetchCatalog(options: FetchCatalogOptions): Promise<ProviderCatalog> {
const runtimeSession = await this.runtime.startSession({ cwd: options.cwd }); const runtimeSession = await this.runtime.startSession({
cwd: options.scope === "global" ? homedir() : options.cwd,
});
try { try {
const models = transformPiModels( const models = transformPiModels(
(await runtimeSession.getAvailableModels(PI_CATALOG_REQUEST_TIMEOUT_MS)).map(mapPiModel), (await runtimeSession.getAvailableModels(PI_CATALOG_REQUEST_TIMEOUT_MS)).map(mapPiModel),
@@ -40,7 +40,7 @@ import {
type ScheduleCadence, type ScheduleCadence,
type UpdateScheduleInput, type UpdateScheduleInput,
} from "@getpaseo/protocol/schedule/types"; } from "@getpaseo/protocol/schedule/types";
import { resolveSnapshotCwd, type ProviderSnapshotManager } from "../provider-snapshot-manager.js"; import type { ProviderSnapshotManager } from "../provider-snapshot-manager.js";
import { import {
AgentModelSchema, AgentModelSchema,
AgentProviderEnum, AgentProviderEnum,
@@ -2319,7 +2319,6 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
}, },
async ({ provider }) => { async ({ provider }) => {
const models = await providerSnapshotManager.listModels({ const models = await providerSnapshotManager.listModels({
cwd: resolveSnapshotCwd(),
provider, provider,
wait: true, wait: true,
}); });
@@ -656,7 +656,7 @@ test(
const client = createPiClient(); const client = createPiClient();
const cwd = tmpCwd("pi-list-models-"); const cwd = tmpCwd("pi-list-models-");
try { try {
const { models } = await client.fetchCatalog({ cwd, force: false }); const { models } = await client.fetchCatalog({ scope: "workspace", cwd, force: false });
expect(models.length).toBeGreaterThan(0); expect(models.length).toBeGreaterThan(0);
for (const model of models) { for (const model of models) {
+5 -5
View File
@@ -1,6 +1,6 @@
import { execSync } from "child_process"; import { execSync } from "child_process";
import { mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "fs"; 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 { join, resolve as resolvePath } from "path";
import pino from "pino"; import pino from "pino";
import { afterEach, describe, expect, test, vi } from "vitest"; import { afterEach, describe, expect, test, vi } from "vitest";
@@ -1212,7 +1212,7 @@ describe("session provider refresh cwd routing", () => {
expect(getSnapshot).toHaveBeenCalledWith(workspaceCwd); 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 messages: unknown[] = [];
const { const {
manager: providerSnapshotManager, manager: providerSnapshotManager,
@@ -1239,9 +1239,9 @@ describe("session provider refresh cwd routing", () => {
requestId: "modes-home", requestId: "modes-home",
}); });
expect(getSnapshot).toHaveBeenCalledWith(homedir()); expect(getSnapshot).toHaveBeenCalledWith(undefined);
expect(warmUpSnapshotForCwd).toHaveBeenCalledWith({ expect(warmUpSnapshotForCwd).toHaveBeenCalledWith({
cwd: homedir(), cwd: undefined,
providers: ["codex"], providers: ["codex"],
}); });
}); });
@@ -1343,7 +1343,7 @@ describe("session provider refresh cwd routing", () => {
}); });
expect(warmUpSnapshotForCwd).toHaveBeenCalledWith({ expect(warmUpSnapshotForCwd).toHaveBeenCalledWith({
cwd: homedir(), cwd: undefined,
providers: ["codex"], providers: ["codex"],
}); });
warmupDeferred.resolve(); warmupDeferred.resolve();
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it, vi } from "vitest";
import pino from "pino"; import pino from "pino";
import { import {
ProviderCatalogSession, ProviderCatalogSession,
@@ -8,7 +8,7 @@ import { createStub } from "../../test-utils/class-mocks.js";
import { findByType } from "../../test-utils/session-stubs.js"; import { findByType } from "../../test-utils/session-stubs.js";
import type { SessionOutboundMessage } from "../../messages.js"; import type { SessionOutboundMessage } from "../../messages.js";
import { import {
resolveSnapshotCwd, GLOBAL_PROVIDER_SNAPSHOT_KEY,
type ProviderSnapshotManager, type ProviderSnapshotManager,
} from "../../agent/provider-snapshot-manager.js"; } from "../../agent/provider-snapshot-manager.js";
import type { ProviderSnapshotEntry } from "../../agent/agent-sdk-types.js"; import type { ProviderSnapshotEntry } from "../../agent/agent-sdk-types.js";
@@ -66,7 +66,10 @@ function makeSubsystem(options: MakeOptions = {}) {
providerUsageService: createStub<ProviderUsageService>(options.usage ?? {}), providerUsageService: createStub<ProviderUsageService>(options.usage ?? {}),
logger: pino({ level: "silent" }), 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"); if (!changeHandler) throw new Error("start() must run before a snapshot change");
changeHandler(entries, cwd); changeHandler(entries, cwd);
} }
@@ -142,6 +145,26 @@ describe("ProviderCatalogSession", () => {
expect(res?.payload.error).toBe("Provider codex is disabled"); 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 () => { it("surfaces a usage-list failure as an rpc_error envelope", async () => {
const { subsystem, emitted } = makeSubsystem({ const { subsystem, emitted } = makeSubsystem({
usage: { usage: {
@@ -2,7 +2,7 @@ import type pino from "pino";
import { getErrorMessage } from "@getpaseo/protocol/error-utils"; import { getErrorMessage } from "@getpaseo/protocol/error-utils";
import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js"; import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js";
import { import {
resolveSnapshotCwd, isGlobalProviderSnapshotKey,
type ProviderSnapshotManager, type ProviderSnapshotManager,
} from "../../agent/provider-snapshot-manager.js"; } from "../../agent/provider-snapshot-manager.js";
import type { import type {
@@ -77,7 +77,7 @@ export class ProviderCatalogSession {
const visibleEntries = entries.filter((entry) => const visibleEntries = entries.filter((entry) =>
this.host.isProviderVisibleToClient(entry.provider), this.host.isProviderVisibleToClient(entry.provider),
); );
const snapshotCwd = cwd === resolveSnapshotCwd() ? undefined : cwd; const snapshotCwd = isGlobalProviderSnapshotKey(cwd) ? undefined : cwd;
this.host.emit({ this.host.emit({
type: "providers_snapshot_update", type: "providers_snapshot_update",
payload: { payload: {
@@ -141,7 +141,7 @@ export class ProviderCatalogSession {
async handleListProviderModelsRequest( async handleListProviderModelsRequest(
msg: Extract<SessionInboundMessage, { type: "list_provider_models_request" }>, msg: Extract<SessionInboundMessage, { type: "list_provider_models_request" }>,
): Promise<void> { ): Promise<void> {
const cwd = resolveSnapshotCwd(msg.cwd ? expandTilde(msg.cwd) : undefined); const cwd = resolveCatalogRequestCwd(msg.cwd);
const fetchedAt = new Date().toISOString(); const fetchedAt = new Date().toISOString();
const entry = await this.getProviderSnapshotEntryForRead(cwd, msg.provider); const entry = await this.getProviderSnapshotEntryForRead(cwd, msg.provider);
@@ -198,7 +198,7 @@ export class ProviderCatalogSession {
msg: Extract<SessionInboundMessage, { type: "list_provider_modes_request" }>, msg: Extract<SessionInboundMessage, { type: "list_provider_modes_request" }>,
): Promise<void> { ): Promise<void> {
const fetchedAt = new Date().toISOString(); 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); const entry = await this.getProviderSnapshotEntryForRead(cwd, msg.provider);
if (!entry) { if (!entry) {
@@ -250,7 +250,7 @@ export class ProviderCatalogSession {
} }
private async getProviderSnapshotEntryForRead( private async getProviderSnapshotEntryForRead(
cwd: string, cwd: string | undefined,
provider: AgentProvider, provider: AgentProvider,
): Promise<ProviderSnapshotEntry | undefined> { ): Promise<ProviderSnapshotEntry | undefined> {
const manager = this.providerSnapshotManager; 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;
}