Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
4967cf2
perf(server): Claude instances that share a home share one capabiliti…
t3dotgg Sep 25, 2026
de08e34
test(server): cover the Claude probe gate and fix the driver header
t3dotgg Sep 25, 2026
cd07128
fix(server): Claude probe cache no longer thrashes past 64 instances
t3dotgg Sep 25, 2026
8691ad7
refactor(server): export make for the Claude probe cache
t3dotgg Sep 25, 2026
e84169f
fix(server): keep ClaudeProbeCache make module-private
t3dotgg Sep 25, 2026
e996211
refactor(server): export ClaudeProbeCache make like other services
t3dotgg Sep 26, 2026
9fb0039
fix(server): Claude probe cache retries a failed usage read after 30 …
t3dotgg Sep 26, 2026
9161e7d
test(server): an empty Claude home and an explicit ~/.claude run sepa…
t3dotgg Sep 26, 2026
ce9d3b3
fix(server): shared Claude probes keep honest usage times and run wit…
t3dotgg Sep 26, 2026
bbfa575
fix(server): cached Claude reads keep fresh reset credits, and a repe…
t3dotgg Sep 26, 2026
acb67c2
docs(server): say reset credits come from the probed limits
t3dotgg Sep 26, 2026
f88b84b
fix(server): bound the Claude probe cache's failure memory
t3dotgg Sep 26, 2026
df89a24
fix(server): new Claude instances probe fresh, and a replaced probe c…
t3dotgg Sep 26, 2026
81204d0
refactor(server): shrink the shared Claude probe cache to one Effect …
t3dotgg Sep 26, 2026
cc29c1f
fix(server): a new or rebuilt Claude instance probes fresh
t3dotgg Sep 26, 2026
10c54c9
docs(server): drop a stale reference from the Claude probe cache doc
t3dotgg Sep 26, 2026
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
55 changes: 24 additions & 31 deletions apps/server/src/provider/Drivers/ClaudeDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,18 @@
*
* Unlike Codex, the Claude snapshot probe may invoke a secondary probe
* (`probeClaudeCapabilities`) to read Anthropic account + slash-command
* metadata. That probe is per-instance and keyed by binary + resolved HOME so
* two concurrent Claude instances don't cross-contaminate account metadata.
* metadata. That probe goes through the server-wide `ClaudeProbeCache`, keyed
* on the full probe input, so instances with the same probe input share one
* probe and instances on different homes never see each other's account.
*
* @module provider/Drivers/ClaudeDriver
*/
import { ClaudeSettings, ProviderDriverKind } from "@t3tools/contracts";
import * as Cache from "effect/Cache";
import * as Duration from "effect/Duration";
import * as Crypto from "effect/Crypto";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Option from "effect/Option";
import * as Path from "effect/Path";
import * as Schema from "effect/Schema";
import { HttpClient } from "effect/unstable/http";
Expand All @@ -33,11 +34,7 @@ import { makeClaudeAdapter } from "../Layers/ClaudeAdapter.ts";
import { makeClaudeScopedLimitNames } from "../Layers/claudeUsageLimits.ts";
import * as ClaudeResetCredits from "../Layers/claudeResetCredits.ts";
import * as ResetCreditCoordinator from "../Layers/resetCreditCoordinator.ts";
import {
checkClaudeProviderStatus,
makePendingClaudeProvider,
probeClaudeCapabilities,
} from "../Layers/ClaudeProvider.ts";
import { checkClaudeProviderStatus, makePendingClaudeProvider } from "../Layers/ClaudeProvider.ts";
import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts";
import { resolveClaudeModelCatalog } from "../ClaudeModelCatalog.ts";
import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
Expand All @@ -61,16 +58,12 @@ import {
makeProviderSnapshotSettingsSource,
type ProviderSnapshotSettings,
} from "../providerUpdateSettings.ts";
import {
makeClaudeCapabilitiesCacheKey,
makeClaudeContinuationGroupKey,
resolveClaudeHomePath,
} from "./ClaudeHome.ts";
import { makeClaudeContinuationGroupKey, resolveClaudeHomePath } from "./ClaudeHome.ts";
import * as ClaudeProbeCache from "./ClaudeProbeCache.ts";
import { discoverClaudeSkills } from "./ClaudeSkills.ts";
const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings);

const DRIVER_KIND = ProviderDriverKind.make("claudeAgent");
const CAPABILITIES_PROBE_TTL = Duration.minutes(5);

function isClaudeNativeCommandPath(commandPath: string): boolean {
const normalized = normalizeCommandPath(commandPath);
Expand All @@ -93,6 +86,7 @@ const UPDATE = makePackageManagedProviderMaintenanceResolver({
export type ClaudeDriverEnv =
| BackgroundPolicy.BackgroundPolicy
| ChildProcessSpawner.ChildProcessSpawner
| ClaudeProbeCache.ClaudeProbeCache
| ResetCreditCoordinator.ResetCreditCoordinator
| Crypto.Crypto
| FileSystem.FileSystem
Expand All @@ -119,6 +113,7 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
const { cwd } = yield* ServerConfig;
const httpClient = yield* HttpClient.HttpClient;
const resetCreditCoordinator = yield* ResetCreditCoordinator.ResetCreditCoordinator;
const probeCache = yield* ClaudeProbeCache.ClaudeProbeCache;
const serverSettings = yield* ServerSettingsService;
const eventLoggers = yield* ProviderEventLoggers;
const modelManifest = yield* ModelManifest.ModelManifest;
Expand Down Expand Up @@ -178,21 +173,19 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
modelCatalog,
);

// Per-instance capabilities cache: keyed on binary + resolved HOME so
// account-specific probes never share auth metadata across instances.
const capabilitiesProbeCache = yield* Cache.make({
capacity: 1,
timeToLive: CAPABILITIES_PROBE_TTL,
lookup: () =>
probeClaudeCapabilities(effectiveConfig, processEnv, cwd).pipe(
Effect.provideService(Path.Path, path),
),
});
const capabilitiesCacheKey = yield* makeClaudeCapabilitiesCacheKey(
effectiveConfig,
// Shared with every instance that has the same probe input.
const probeInput = {
binaryPath: effectiveConfig.binaryPath,
homePath: effectiveConfig.homePath,
cwd,
processEnv,
);
environment,
} satisfies ClaudeProbeCache.ClaudeProbeInput;
// A new or rebuilt instance probes fresh, so a config edit never shows
// an older result. An in-flight probe is joined instead, so instances
// that start together at boot still run one probe.
if (Option.isSome(yield* Cache.getSuccess(probeCache, probeInput))) {
yield* Cache.invalidate(probeCache, probeInput);
}

// Start the TTL-gated refresh without delaying provider readiness. The
// next check observes a remote manifest after the background fetch lands.
Expand All @@ -202,7 +195,7 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
Effect.flatMap((manifest) =>
checkClaudeProviderStatus(
effectiveConfig,
() => Cache.get(capabilitiesProbeCache, capabilitiesCacheKey),
() => Cache.get(probeCache, probeInput),
processEnv,
cwd,
resolveClaudeModelCatalog(manifest),
Expand Down Expand Up @@ -312,7 +305,7 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
Effect.tap((outcome) =>
Effect.gen(function* () {
const before = (yield* snapshot.getSnapshot).usageLimits?.checkedAt;
yield* Cache.invalidateAll(capabilitiesProbeCache);
yield* Cache.invalidate(probeCache, probeInput);
const refreshed = yield* snapshot.refresh;
const after = refreshed.usageLimits?.checkedAt;
if (
Expand Down Expand Up @@ -343,7 +336,7 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
accentColor,
enabled,
snapshot,
invalidateCaches: Cache.invalidateAll(capabilitiesProbeCache),
invalidateCaches: Cache.invalidate(probeCache, probeInput),
snapshotForCwd,
adapter,
textGeneration,
Expand Down
15 changes: 1 addition & 14 deletions apps/server/src/provider/Drivers/ClaudeHome.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import * as Path from "effect/Path";

import {
claudeSignedOutMessage,
makeClaudeCapabilitiesCacheKey,
makeClaudeContinuationGroupKey,
makeClaudeEnvironment,
resolveClaudeHomePath,
Expand All @@ -32,7 +31,7 @@ it.layer(NodeServices.layer)("ClaudeHome", (it) => {
}),
);

it.effect("resolves configured Claude HOME and stamps continuation/cache keys with it", () =>
it.effect("resolves configured Claude HOME and stamps continuation keys with it", () =>
Effect.gen(function* () {
const path = yield* Path.Path;
const homePath = "~/.claude-work";
Expand All @@ -41,9 +40,6 @@ it.layer(NodeServices.layer)("ClaudeHome", (it) => {
expect(yield* resolveClaudeHomePath({ homePath })).toBe(resolved);
expect((yield* makeClaudeEnvironment({ homePath })).CLAUDE_CONFIG_DIR).toBe(resolved);
expect(yield* makeClaudeContinuationGroupKey({ homePath })).toBe(`claude:home:${resolved}`);
expect(yield* makeClaudeCapabilitiesCacheKey({ binaryPath: "claude", homePath })).toBe(
`claude\0${resolved}\0`,
);
}),
);

Expand Down Expand Up @@ -75,14 +71,5 @@ it.layer(NodeServices.layer)("ClaudeHome", (it) => {
expect(message).not.toContain("CLAUDE_CONFIG_DIR=");
expect(message).toContain("then start a new thread");
});

it.effect("separates capability probes by cwd", () =>
Effect.gen(function* () {
const config = { binaryPath: "claude", homePath: "" };
const first = yield* makeClaudeCapabilitiesCacheKey(config, "/repo-a");
const second = yield* makeClaudeCapabilitiesCacheKey(config, "/repo-b");
expect(first).not.toBe(second);
}),
);
});
});
11 changes: 0 additions & 11 deletions apps/server/src/provider/Drivers/ClaudeHome.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,17 +63,6 @@ export const makeClaudeContinuationGroupKey = Effect.fn("makeClaudeContinuationG
},
);

export const makeClaudeCapabilitiesCacheKey = Effect.fn("makeClaudeCapabilitiesCacheKey")(
function* (
config: Pick<ClaudeSettings, "binaryPath" | "homePath">,
cwd?: string,
environment?: NodeJS.ProcessEnv,
): Effect.fn.Return<string, never, Path.Path> {
const resolvedHomePath = yield* resolveClaudeHomePath(config, environment);
return `${config.binaryPath}\0${resolvedHomePath}\0${cwd ?? ""}`;
},
);

/**
* Describe the spawned CLI's environment separately from the login command so
* paths remain literal on every shell, including relative inherited values.
Expand Down
43 changes: 43 additions & 0 deletions apps/server/src/provider/Drivers/ClaudeProbeCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* One server-wide cache for the Claude capabilities probe. Claude instances
* with the same probe input read the same account, so they share one cached
* result, and concurrent reads of one input join one SDK probe. Each entry
* keeps 5 minutes, a failed probe (`undefined`) included.
*
* @module provider/Drivers/ClaudeProbeCache
*/
import type { ClaudeSettings, ProviderInstanceEnvironment } from "@t3tools/contracts";
import * as Cache from "effect/Cache";
import * as Context from "effect/Context";
import * as Duration from "effect/Duration";
import * as Layer from "effect/Layer";

import { type ClaudeCapabilitiesProbe, probeClaudeCapabilities } from "../Layers/ClaudeProvider.ts";
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts";

/**
* Every instance input the probe reads, and also the cache key. The lookup
* gets only this value, so the probe cannot read an input the key leaves out.
* Keys compare structurally.
*/
export type ClaudeProbeInput = Pick<ClaudeSettings, "binaryPath" | "homePath"> & {
readonly cwd: string;
readonly environment: ProviderInstanceEnvironment;
};

export class ClaudeProbeCache extends Context.Service<
ClaudeProbeCache,
Cache.Cache<ClaudeProbeInput, ClaudeCapabilitiesProbe | undefined>
>()("t3/provider/Drivers/ClaudeProbeCache") {}

/** @public Service construction is part of the canonical Effect module API. */
export const make = Cache.make({
// Far above any real input count. Each refresh reads keys in the same
// order, so a cap below the live key count would re-probe every key.
capacity: 256,
Comment thread
t3dotgg marked this conversation as resolved.
timeToLive: Duration.minutes(5),
lookup: (input: ClaudeProbeInput) =>
probeClaudeCapabilities(input, mergeProviderInstanceEnvironment(input.environment), input.cwd),
});

export const layer = Layer.effect(ClaudeProbeCache, make);
4 changes: 2 additions & 2 deletions apps/server/src/provider/Layers/ClaudeProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,7 @@ function nonEmptyProbeString(value: string): string | undefined {
return candidate ? candidate : undefined;
}

type ClaudeCapabilitiesProbe = {
export type ClaudeCapabilitiesProbe = {
readonly email: string | undefined;
readonly subscriptionType: string | undefined;
readonly tokenSource: string | undefined;
Expand Down Expand Up @@ -330,7 +330,7 @@ function waitForAbortSignal(signal: AbortSignal): Promise<void> {
* subscription type information.
*/
const probeClaudeCapabilities = (
claudeSettings: ClaudeSettings,
claudeSettings: Pick<ClaudeSettings, "binaryPath" | "homePath">,
environment?: NodeJS.ProcessEnv,
cwd?: string,
) => {
Expand Down
Loading
Loading