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
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.
---
@@ -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.
@@ -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[];
@@ -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,
});
@@ -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();
}
@@ -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<void>;
}
type ProviderCatalogScope = { scope: "global" } | { scope: "workspace"; cwd: string };
interface ProviderSnapshotTarget {
snapshotCwd: string;
catalogScope: ProviderCatalogScope;
}
export class ProviderSnapshotManager {
private readonly snapshots = new Map<string, Map<AgentProvider, ProviderSnapshotEntry>>();
private readonly providerLoads = new Map<string, Map<AgentProvider, ProviderLoad>>();
@@ -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<void> {
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<ProviderSnapshotRefreshOptions, "cwd"> = {},
): Promise<void> {
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<void> {
const snapshotCwd = resolveSnapshotCwd(options.cwd);
async warmUpSnapshotForCwd(options: ProviderSnapshotWarmUpOptions): Promise<void> {
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<void> {
@@ -261,12 +275,12 @@ export class ProviderSnapshotManager {
}
async listProviders(input: ProviderSnapshotReadOptions = {}): Promise<ProviderSnapshotEntry[]> {
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<ProviderSnapshotEntry> {
@@ -471,9 +493,11 @@ export class ProviderSnapshotManager {
definition: ProviderDefinition,
): Promise<ProviderSnapshotEntry> {
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<void> {
private async warmUp(target: ProviderSnapshotTarget, providers?: AgentProvider[]): Promise<void> {
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<void> {
await this.loadProviders({ cwd, providers, force: true });
private async refreshProviders(
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[] {
@@ -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<void> {
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<AgentProvider, ProviderSnapshotEntry>,
): ProviderSnapshotEntry[] {
@@ -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);
@@ -799,7 +799,7 @@ export class ACPAgentClient implements AgentClient {
}
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;
let probe: UninitializedACPProcess | null = null;
try {
@@ -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);
};
@@ -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),
@@ -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({
@@ -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));
@@ -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: [],
});
@@ -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,
@@ -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"]);
});
@@ -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: [
{
@@ -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,
}),
),
);
@@ -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<string, number>();
@@ -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),
@@ -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<OpenCodeServerProcessSpawner>[2];
}> = [];
private readonly ports: number[];
private readonly autoAnnounce: boolean;
private readonly processesByChild = new Map<ChildProcess, FakeOpenCodeProcess>();
@@ -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);
@@ -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 { 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,
@@ -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",
@@ -1971,7 +1971,9 @@ export class PiRpcAgentClient implements AgentClient {
}
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 {
const models = transformPiModels(
(await runtimeSession.getAvailableModels(PI_CATALOG_REQUEST_TIMEOUT_MS)).map(mapPiModel),
@@ -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,
});
@@ -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) {
+5 -5
View File
@@ -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();
@@ -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<ProviderUsageService>(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: {
@@ -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<SessionInboundMessage, { type: "list_provider_models_request" }>,
): Promise<void> {
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<SessionInboundMessage, { type: "list_provider_modes_request" }>,
): Promise<void> {
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<ProviderSnapshotEntry | undefined> {
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;
}