diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index c6135fe247b..de317859807 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -29,6 +29,7 @@ import { import { compareCliVersions } from "../cliVersion.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; import { ClaudeProvider } from "../Services/ClaudeProvider.ts"; +import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { ServerSettingsError } from "@t3tools/contracts"; @@ -462,10 +463,16 @@ function dedupeSlashCommands( * after the local initialization phase completes. This gives us the * user's subscription type without incurring any token cost. * + * `cwd` determines how the SDK resolves the `"project"` and `"local"` + * setting sources, which is how project-level slash commands (including + * `.claude/skills/`) enter the initialization result. Pass the active + * project directory; otherwise the SDK falls back to the T3 server + * process cwd and project-level commands are missed (#2048). + * * This is used as a fallback when `claude auth status` does not include * subscription type information. */ -const probeClaudeCapabilities = (binaryPath: string) => { +const probeClaudeCapabilities = (binaryPath: string, cwd?: string) => { const abort = new AbortController(); return Effect.tryPromise(async () => { const q = claudeQuery({ @@ -473,6 +480,7 @@ const probeClaudeCapabilities = (binaryPath: string) => { options: { persistSession: false, pathToClaudeCodeExecutable: binaryPath, + ...(cwd ? { cwd } : {}), abortController: abort, maxTurns: 0, settingSources: ["user", "project", "local"], @@ -759,25 +767,47 @@ const makePendingClaudeProvider = (claudeSettings: ClaudeSettings): ServerProvid }); }; +/** + * Cache key for the Claude capability probe. `cwd` is included so probes + * from two different project directories stay in separate cache entries; + * a probe from `/repo-a` must not satisfy a probe for `/repo-b` because + * the SDK resolves `.claude/skills` against the provided cwd (#2048). + */ +export const claudeProbeCacheKey = (binaryPath: string, cwd?: string) => + `${binaryPath}|${cwd ?? ""}`; + export const ClaudeProviderLive = Layer.effect( ClaudeProvider, Effect.gen(function* () { const serverSettings = yield* ServerSettingsService; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - + // The active workspace the server was launched in. We pass this as the + // probe cwd so the Claude Agent SDK resolves `settingSources: ['project', + // 'local']` — and therefore `.claude/skills/` — against the user's + // project instead of the T3 server process cwd (#2048). + const { cwd: workspaceRoot } = yield* ServerConfig; + + // Cache key is `${binaryPath}|${cwd ?? ''}` so probes with different + // project cwds don't collide. Capacity is 8 so a handful of recent + // projects stay cached without ballooning memory (#2048). const subscriptionProbeCache = yield* Cache.make({ - capacity: 1, + capacity: 8, timeToLive: Duration.minutes(5), - lookup: (binaryPath: string) => probeClaudeCapabilities(binaryPath), + lookup: (key: string) => { + const sep = key.indexOf("|"); + const binaryPath = sep >= 0 ? key.slice(0, sep) : key; + const cwd = sep >= 0 ? key.slice(sep + 1) : ""; + return probeClaudeCapabilities(binaryPath, cwd || undefined); + }, }); const checkProvider = checkClaudeProviderStatus( (binaryPath) => - Cache.get(subscriptionProbeCache, binaryPath).pipe( + Cache.get(subscriptionProbeCache, claudeProbeCacheKey(binaryPath, workspaceRoot)).pipe( Effect.map((probe) => probe?.subscriptionType), ), (binaryPath) => - Cache.get(subscriptionProbeCache, binaryPath).pipe( + Cache.get(subscriptionProbeCache, claudeProbeCacheKey(binaryPath, workspaceRoot)).pipe( Effect.map((probe) => probe?.slashCommands), ), ).pipe( diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 170521d2d27..eab76cb1b45 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -29,7 +29,11 @@ import { parseAuthStatusFromOutput, readCodexConfigModelProvider, } from "./CodexProvider.ts"; -import { checkClaudeProviderStatus, parseClaudeAuthStatusFromOutput } from "./ClaudeProvider.ts"; +import { + checkClaudeProviderStatus, + claudeProbeCacheKey, + parseClaudeAuthStatusFromOutput, +} from "./ClaudeProvider.ts"; import { haveProvidersChanged, ProviderRegistryLive } from "./ProviderRegistry.ts"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService, type ServerSettingsShape } from "../../serverSettings.ts"; @@ -1270,6 +1274,28 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( ); }); + // ── claudeProbeCacheKey (per-cwd cache separation, #2048) ──────── + + describe("claudeProbeCacheKey", () => { + it("produces distinct keys for the same binary with different cwds", () => { + const a = claudeProbeCacheKey("/usr/local/bin/claude", "/repo-a"); + const b = claudeProbeCacheKey("/usr/local/bin/claude", "/repo-b"); + assert.notStrictEqual(a, b); + }); + + it("treats undefined and empty-string cwd as the same key", () => { + const undef = claudeProbeCacheKey("/usr/local/bin/claude"); + const empty = claudeProbeCacheKey("/usr/local/bin/claude", ""); + assert.strictEqual(undef, empty); + }); + + it("separates different binaries even with the same cwd", () => { + const a = claudeProbeCacheKey("/opt/claude-a", "/repo"); + const b = claudeProbeCacheKey("/opt/claude-b", "/repo"); + assert.notStrictEqual(a, b); + }); + }); + // ── parseClaudeAuthStatusFromOutput pure tests ──────────────────── describe("parseClaudeAuthStatusFromOutput", () => {