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
33 changes: 8 additions & 25 deletions apps/server/src/provider/Drivers/ClaudeDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,6 @@
* @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";
Expand All @@ -35,8 +33,8 @@ import * as ClaudeResetCredits from "../Layers/claudeResetCredits.ts";
import * as ResetCreditCoordinator from "../Layers/resetCreditCoordinator.ts";
import {
checkClaudeProviderStatus,
makeClaudeCapabilitiesCache,
makePendingClaudeProvider,
probeClaudeCapabilities,
} from "../Layers/ClaudeProvider.ts";
import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts";
import { resolveClaudeModelCatalog } from "../ClaudeModelCatalog.ts";
Expand All @@ -61,16 +59,11 @@ import {
makeProviderSnapshotSettingsSource,
type ProviderSnapshotSettings,
} from "../providerUpdateSettings.ts";
import {
makeClaudeCapabilitiesCacheKey,
makeClaudeContinuationGroupKey,
resolveClaudeHomePath,
} from "./ClaudeHome.ts";
import { makeClaudeContinuationGroupKey, resolveClaudeHomePath } from "./ClaudeHome.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 Down Expand Up @@ -178,21 +171,11 @@ 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(
const capabilities = yield* makeClaudeCapabilitiesCache(
effectiveConfig,
cwd,
processEnv,
);
cwd,
).pipe(Effect.provideService(Path.Path, path));

// 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 +185,7 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
Effect.flatMap((manifest) =>
checkClaudeProviderStatus(
effectiveConfig,
() => Cache.get(capabilitiesProbeCache, capabilitiesCacheKey),
() => capabilities.get,
processEnv,
cwd,
resolveClaudeModelCatalog(manifest),
Expand Down Expand Up @@ -312,7 +295,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* capabilities.invalidate;
const refreshed = yield* snapshot.refresh;
const after = refreshed.usageLimits?.checkedAt;
if (
Expand Down Expand Up @@ -343,7 +326,7 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
accentColor,
enabled,
snapshot,
invalidateCaches: Cache.invalidateAll(capabilitiesProbeCache),
invalidateCaches: capabilities.invalidate,
snapshotForCwd,
adapter,
textGeneration,
Expand Down
63 changes: 63 additions & 0 deletions apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import * as Schema from "effect/Schema";
import {
buildClaudeCapabilitiesProbeQueryOptions,
CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES,
makeClaudeCapabilitiesCache,
probeClaudeCapabilities,
} from "./ClaudeProvider.ts";

Expand Down Expand Up @@ -223,3 +224,65 @@ it.effect("preserves initialized capabilities when optional usage times out", ()
assert.equal(abortSignal?.aborted, true);
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);

it.effect("answers one failed probe with the last good account, and never caches a failure", () =>
Effect.gen(function* () {
let initialization: "fails" | "succeeds" = "fails";
const query = vi.spyOn(ClaudeSdk, "query").mockImplementation(
() =>
({
initializationResult: async () => {
if (initialization === "fails") {
throw new Error("Claude Code process exited with code 1");
}
return {
account: {
email: "dev@example.com",
subscriptionType: "pro",
tokenSource: "oauth",
},
commands: [{ name: "review", description: "Review changes", argumentHint: "[path]" }],
};
},
usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET: async () => ({
rate_limits_available: true,
rate_limits: {},
}),
}) as unknown as ReturnType<typeof ClaudeSdk.query>,
);
yield* Effect.addFinalizer(() => Effect.sync(() => query.mockRestore()));
const capabilities = yield* makeClaudeCapabilitiesCache(
decodeClaudeSettings({ binaryPath: "claude" }),
);

// Nothing good to fall back on yet, so the caller sees the failure.
const firstFailure = yield* Effect.flip(capabilities.get);
assert.equal(firstFailure._tag, "UnknownError");

initialization = "succeeds";
const good = yield* capabilities.get;
assert.equal(good.email, "dev@example.com");
assert.equal(good.usage?.rate_limits_available, true);
yield* capabilities.get;
assert.equal(query.mock.calls.length, 2);

initialization = "fails";
yield* TestClock.adjust("5 minutes");
const lastGood = yield* capabilities.get;
assert.equal(lastGood.email, "dev@example.com");
assert.equal(lastGood.subscriptionType, "pro");
assert.deepEqual(lastGood.slashCommands, [
{ name: "review", description: "Review changes", input: { hint: "[path]" } },
]);
// Five-minute-old usage windows would be republished as current.
assert.equal(lastGood.usage, undefined);
yield* capabilities.get;
assert.equal(query.mock.calls.length, 3);

// A second failure in a row reaches the caller, and the next check probes again.
yield* TestClock.adjust("5 minutes");
assert.equal((yield* Effect.flip(capabilities.get))._tag, "UnknownError");
yield* Effect.flip(capabilities.get);
assert.equal(query.mock.calls.length, 5);
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);
97 changes: 77 additions & 20 deletions apps/server/src/provider/Layers/ClaudeProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,12 @@ import {
type ServerProviderSlashCommand,
type ServerProviderResetCredits,
} from "@t3tools/contracts";
import * as Cache from "effect/Cache";
import * as Cause from "effect/Cause";
import * as DateTime from "effect/DateTime";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Exit from "effect/Exit";
import * as FileSystem from "effect/FileSystem";
import * as Option from "effect/Option";
import * as Path from "effect/Path";
Expand Down Expand Up @@ -34,7 +38,7 @@ import {
type ServerProviderDraft,
} from "../providerSnapshot.ts";
import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts";
import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts";
import { makeClaudeCapabilitiesCacheKey, makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts";
import { discoverClaudeSkills } from "../Drivers/ClaudeSkills.ts";
import { makeUnavailableUsageLimits } from "../providerUsageLimits.ts";
import {
Expand Down Expand Up @@ -168,9 +172,10 @@ function apiProviderAuthMetadata(

// Amazon Bedrock initializes far slower than first-party auth: the SDK boots the
// Bedrock backend and runs the `awsAuthRefresh` credential hook before returning
// account info. The previous 8s budget expired mid-init, so the probe returned
// `undefined` and left the provider unverified and unselectable in the picker.
// account info. The previous 8s budget expired mid-init, so the probe timed out
// and left the provider unverified and unselectable in the picker.
const CAPABILITIES_PROBE_TIMEOUT_MS = 25_000;
const CAPABILITIES_PROBE_TTL = Duration.minutes(5);

/**
* Keep workspace-scoped command discovery intact while isolating the periodic
Expand Down Expand Up @@ -237,9 +242,10 @@ type ClaudeCapabilitiesProbe = {
readonly apiProvider: string | undefined;
readonly slashCommands: ReadonlyArray<ServerProviderSlashCommand>;
/**
* Subscription windows from the SDK's `get_usage` control request, or
* `undefined` when the request itself failed. Absent windows on an
* otherwise successful response mean the account has none (API key).
* Subscription windows from the SDK's `get_usage` control request.
* `undefined` when that request failed, or when the last good probe answers
* for a failed one. Absent windows on an otherwise successful response mean
* the account has none (API key).
*/
readonly usage?: Pick<SDKControlGetUsageResponse, "rate_limits_available" | "rate_limits">;
};
Expand Down Expand Up @@ -326,8 +332,8 @@ function waitForAbortSignal(signal: AbortSignal): Promise<void> {
* account info and slash commands) but never starts an API request to
* Anthropic. We read the init data and then abort the subprocess.
*
* This is used as a fallback when `claude auth status` does not include
* subscription type information.
* Fails with the timeout or SDK error. `makeClaudeCapabilitiesCache` decides
* what a failed probe means for the provider snapshot.
*/
const probeClaudeCapabilities = (
claudeSettings: ClaudeSettings,
Expand Down Expand Up @@ -396,11 +402,58 @@ const probeClaudeCapabilities = (
if (!abort.signal.aborted) abort.abort();
}),
),
Effect.result,
Effect.map((result) => (Result.isSuccess(result) ? result.success : undefined)),
// Log only the tag. SDK errors can quote the CLI's stderr.
Effect.tapError((error) =>
Effect.logWarning("Claude capability probe failed.", { errorTag: error._tag }),
),
);
};

type ClaudeCapabilitiesProbeError = Effect.Error<ReturnType<typeof probeClaudeCapabilities>>;

/**
* One Claude instance's capability probe, as `checkClaudeProviderStatus` reads
* it. Every answer is reused for five minutes. One stalled probe must not turn
* a working Claude into an auth warning, so when a probe fails, the last good
* probe answers once more, without its usage windows. Those would otherwise be
* republished as current. A failure with nothing to fall back on reaches the
* caller and is never cached, so the next status check probes again.
*/
export const makeClaudeCapabilitiesCache = Effect.fn("makeClaudeCapabilitiesCache")(function* (
claudeSettings: ClaudeSettings,
environment?: NodeJS.ProcessEnv,
cwd?: string,
) {
// Keyed on binary + resolved HOME so account metadata never crosses instances.
const key = yield* makeClaudeCapabilitiesCacheKey(claudeSettings, cwd, environment);
// Emptied when it answers for a failed probe, so a second failure in a row surfaces.
const lastGood = yield* Ref.make<ClaudeCapabilitiesProbe | undefined>(undefined);
const cache = yield* Cache.makeWith(
(_key: string) =>
probeClaudeCapabilities(claudeSettings, environment, cwd).pipe(
Effect.tap((capabilities) => Ref.set(lastGood, capabilities)),
Effect.catch((error) =>
Effect.flatMap(Ref.getAndSet(lastGood, undefined), (last) => {
if (!last) return Effect.fail(error);
const { usage: _staleUsage, ...capabilities } = last;
return Effect.succeed<ClaudeCapabilitiesProbe>(capabilities);
}),
),
),
{
capacity: 1,
timeToLive: Exit.match({
onSuccess: () => CAPABILITIES_PROBE_TTL,
onFailure: () => Duration.zero,
}),
},
);
return {
get: Cache.get(cache, key),
invalidate: Cache.invalidateAll(cache),
};
});

const runClaudeCommand = Effect.fn("runClaudeCommand")(function* (
claudeSettings: ClaudeSettings,
args: ReadonlyArray<string>,
Expand All @@ -419,9 +472,9 @@ const runClaudeCommand = Effect.fn("runClaudeCommand")(function* (

export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(function* (
claudeSettings: ClaudeSettings,
resolveCapabilities?: (
resolveCapabilities: (
claudeSettings: ClaudeSettings,
) => Effect.Effect<ClaudeCapabilitiesProbe | undefined>,
) => Effect.Effect<ClaudeCapabilitiesProbe, ClaudeCapabilitiesProbeError>,
environment?: NodeJS.ProcessEnv,
cwd?: string,
modelCatalog: ClaudeModelCatalog = BUNDLED_CLAUDE_MODEL_CATALOG,
Expand Down Expand Up @@ -533,31 +586,35 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
);
const versionUpgradeMessage = formatClaudeVersionUpgradeMessage(modelCatalog, parsedVersion);

const capabilities = resolveCapabilities
? yield* resolveCapabilities(claudeSettings).pipe(Effect.orElseSucceed(() => undefined))
: undefined;
const probeResult = yield* resolveCapabilities(claudeSettings).pipe(Effect.result);
const skills = yield* discoverClaudeSkills(claudeSettings, cwd, resolvedEnvironment);
const slashCommands = [COMPACT_SLASH_COMMAND, ...(capabilities?.slashCommands ?? [])];
const dedupedSlashCommands = dedupeSlashCommands(slashCommands);

if (!capabilities) {
if (Result.isFailure(probeResult)) {
return buildServerProvider({
presentation: CLAUDE_PRESENTATION,
enabled: claudeSettings.enabled,
checkedAt,
models,
slashCommands: dedupedSlashCommands,
slashCommands: [COMPACT_SLASH_COMMAND],
skills,
probe: {
installed: true,
version: parsedVersion,
status: "warning",
auth: { status: "unknown" },
message: "Could not verify Claude authentication status from initialization result.",
// A failed probe says nothing about the login, so name what failed.
message: Cause.isTimeoutError(probeResult.failure)
? `Timed out after ${CAPABILITIES_PROBE_TIMEOUT_MS / 1_000}s while checking Claude account status.`
: "Claude Agent CLI failed while checking account status.",
},
});
}

const capabilities = probeResult.success;
const dedupedSlashCommands = dedupeSlashCommands([
COMPACT_SLASH_COMMAND,
...capabilities.slashCommands,
]);
const authMetadata =
claudeAuthMetadata({
subscriptionType: capabilities.subscriptionType,
Expand Down
Loading
Loading