Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 36 additions & 6 deletions apps/server/src/provider/Layers/ClaudeProvider.ts
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -462,17 +463,24 @@ 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({
prompt: ".",
options: {
persistSession: false,
pathToClaudeCodeExecutable: binaryPath,
...(cwd ? { cwd } : {}),
abortController: abort,
maxTurns: 0,
settingSources: ["user", "project", "local"],
Expand Down Expand Up @@ -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(
Expand Down
28 changes: 27 additions & 1 deletion apps/server/src/provider/Layers/ProviderRegistry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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", () => {
Expand Down
Loading