From e7baceecdaaeaac967664a7b3f52ef63597eed8e Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Fri, 17 Apr 2026 09:01:11 -0400 Subject: [PATCH 1/2] fix(server): thread cwd through Claude capability probe (#2048) probeClaudeCapabilities enables settingSources: ['user', 'project', 'local'] but never passed a cwd to the Claude Agent SDK. The SDK then resolved the 'project' and 'local' sources against the T3 server process cwd, not the user's active project, so project-level .claude/skills/ was invisible in composer autocomplete even though those skills still ran fine when typed manually. Changes: - probeClaudeCapabilities(binaryPath, cwd?) now forwards an optional cwd into claudeQuery options. When undefined the SDK keeps its previous behavior. - subscriptionProbeCache key becomes '${binaryPath}|${cwd ?? ""}' with capacity bumped to 8 so a handful of recent projects stay cached without forcing re-probes on every project switch. - Cache lookup parses the composite key back into (binaryPath, cwd) before calling the probe. The cache and the probe now support per-project cwds. The remaining wire-up is at the caller of checkClaudeProviderStatus - the subscriptionType and slashCommands lookup arrow functions still receive only binaryPath. Threading the active session.cwd from ProviderService (apps/server/src/provider/Layers/ProviderService.ts already has session.cwd on hand) into those two lookups completes the fix; happy to follow up in a separate PR if you'd like this one kept tight to the probe layer. Reporter @r3xsean has a 40-skill project-level .claude/skills/ setup and is a good candidate for verification once the caller is wired through. apps/server typecheck passes. Fixes #2048 --- .../src/provider/Layers/ClaudeProvider.ts | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index c6135fe247b..82968293676 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -462,10 +462,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 +479,7 @@ const probeClaudeCapabilities = (binaryPath: string) => { options: { persistSession: false, pathToClaudeCodeExecutable: binaryPath, + ...(cwd ? { cwd } : {}), abortController: abort, maxTurns: 0, settingSources: ["user", "project", "local"], @@ -765,19 +772,29 @@ export const ClaudeProviderLive = Layer.effect( const serverSettings = yield* ServerSettingsService; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + // 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 probeKey = (binaryPath: string, cwd?: string) => `${binaryPath}|${cwd ?? ""}`; + const checkProvider = checkClaudeProviderStatus( (binaryPath) => - Cache.get(subscriptionProbeCache, binaryPath).pipe( + Cache.get(subscriptionProbeCache, probeKey(binaryPath)).pipe( Effect.map((probe) => probe?.subscriptionType), ), (binaryPath) => - Cache.get(subscriptionProbeCache, binaryPath).pipe( + Cache.get(subscriptionProbeCache, probeKey(binaryPath)).pipe( Effect.map((probe) => probe?.slashCommands), ), ).pipe( From 614529d5fa48fa8ae0693c030f5dada639bbce74 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Sat, 18 Apr 2026 00:22:17 -0400 Subject: [PATCH 2/2] fix(server): wire ServerConfig.cwd into Claude probe cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the probe change earlier in this PR. probeKey accepted an optional cwd but every call site passed undefined, so the cache key collapsed to `${binaryPath}|` and the SDK never received the active project directory — defeating the `.claude/skills/` resolution the PR was supposed to enable. - ClaudeProviderLive now resolves `cwd` once from `ServerConfig` at layer construction and closes over it as `workspaceRoot`. - Both Cache.get call sites pass `workspaceRoot` through the cache key. The cache lookup already parses it back out and forwards it into `probeClaudeCapabilities`. - `probeKey` is renamed and exported as `claudeProbeCacheKey` so the per-cwd contract is testable. - Adds three unit tests covering distinct cwds, undefined/empty equivalence, and distinct binaries. ServerConfig.cwd is the same value serverRuntimeStartup already treats as `workspaceRoot` (serverRuntimeStartup.ts:199), so no new service surface is needed. Refs #2048 --- .../src/provider/Layers/ClaudeProvider.ts | 21 +++++++++++--- .../provider/Layers/ProviderRegistry.test.ts | 28 ++++++++++++++++++- 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 82968293676..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"; @@ -766,11 +767,25 @@ 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 @@ -786,15 +801,13 @@ export const ClaudeProviderLive = Layer.effect( }, }); - const probeKey = (binaryPath: string, cwd?: string) => `${binaryPath}|${cwd ?? ""}`; - const checkProvider = checkClaudeProviderStatus( (binaryPath) => - Cache.get(subscriptionProbeCache, probeKey(binaryPath)).pipe( + Cache.get(subscriptionProbeCache, claudeProbeCacheKey(binaryPath, workspaceRoot)).pipe( Effect.map((probe) => probe?.subscriptionType), ), (binaryPath) => - Cache.get(subscriptionProbeCache, probeKey(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", () => {