1/**
2 * ClaudeDriver — `ProviderDriver` for the Claude Agent SDK runtime.
3 *
4 * Mirrors `CodexDriver`: a plain value whose `create()` returns one
5 * `ProviderInstance` bundling `snapshot` / `adapter` / `textGeneration`
6 * closures captured over the per-instance `ClaudeSettings`.
7 *
8 * Unlike Codex, the Claude snapshot probe may invoke a secondary probe
9 * (`probeClaudeCapabilities`) to read Anthropic account + slash-command
10 * metadata. That probe is per-instance and keyed by binary + resolved HOME so
11 * two concurrent Claude instances don't cross-contaminate account metadata.
12 *
13 * @module provider/Drivers/ClaudeDriver
14 */
15import { ClaudeSettings, ProviderDriverKind } from "@t3tools/contracts";
16import * as Cache from "effect/Cache";
17import * as Duration from "effect/Duration";
18import * as Crypto from "effect/Crypto";
19import * as Effect from "effect/Effect";
20import * as FileSystem from "effect/FileSystem";
21import * as Path from "effect/Path";
22import * as Schema from "effect/Schema";
23import { HttpClient } from "effect/http";
24import { ChildProcessSpawner } from "effect/process";
25
26import { makeClaudeTextGeneration } from "../../textGeneration/ClaudeTextGeneration.ts";
27import * as ManagedProviderHome from "../ManagedProviderHome.ts";
28import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions";
29import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost";
30import * as ServerConfig from "../../config.ts";
31import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion";
32import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers";
33import {
34 createClaudeAdapterV2,
35 type ClaudeAdapterV2DriverEnv,
36} from "../../orchestration-v2/Adapters/ClaudeAdapterV2.ts";
37import { ProviderDriverError } from "../Errors.ts";
38import { claudeUsageReader } from "./claudeUsage.ts";
39import { makeClaudeScopedLimitNames } from "../claudeUsageLimits.ts";
40import * as ClaudeResetCredits from "../claudeResetCredits.ts";
41import * as ResetCreditCoordinator from "../resetCreditCoordinator.ts";
42import {
43 checkClaudeProviderStatus,
44 makePendingClaudeProvider,
45 probeClaudeCapabilities,
46 probeClaudeWorkspaceSnapshot,
47} from "../ClaudeProvider.ts";
48import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider";
49import * as ModelCatalog from "@t3tools/provider-core/server/ModelCatalog";
50import { resolveClaudeModelCatalog } from "../ClaudeModelCatalog.ts";
51import {
52 defaultProviderContinuationIdentity,
53 type ProviderDriver,
54 type ProviderInstance,
55} from "@t3tools/provider-core/server/driver";
56import { withInstanceIdentity } from "@t3tools/provider-core/server/instanceIdentity";
57import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment";
58import {
59 enrichProviderSnapshotWithVersionAdvisory,
60 makeCachedProviderMaintenanceResolution,
61 makePackageManagedProviderMaintenanceResolver,
62 normalizeCommandPath,
63 resolveProviderMaintenanceCapabilitiesEffect,
64} from "@t3tools/provider-core/server/maintenanceResolver";
65import {
66 haveProviderSnapshotSettingsChanged,
67 makeProviderSnapshotSettingsSource,
68 type ProviderSnapshotSettings,
69} from "@t3tools/provider-core/server/snapshotSettings";
70import {
71 makeClaudeCapabilitiesCacheKey,
72 makeClaudeContinuationGroupKey,
73 resolveClaudeHomePath,
74} from "./ClaudeHome.ts";
75import * as HostProcess from "@t3tools/shared/HostProcess";
76const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings);
77
78const DRIVER_KIND = ProviderDriverKind.make("claudeAgent");
79const CAPABILITIES_PROBE_TTL = Duration.minutes(5);
80
81function isClaudeNativeCommandPath(commandPath: string): boolean {
82 const normalized = normalizeCommandPath(commandPath);
83 return (
84 normalized.endsWith("/.local/bin/claude") ||
85 normalized.endsWith("/.local/bin/claude.exe") ||
86 normalized.includes("/.local/share/claude/")
87 );
88}
89
90const UPDATE = makePackageManagedProviderMaintenanceResolver({
91 provider: DRIVER_KIND,
92 npmPackageName: "@anthropic-ai/claude-code",
93 nativeUpdate: {
94 args: ["update"],
95 isCommandPath: isClaudeNativeCommandPath,
96 },
97});
98
99export type ClaudeDriverEnv =
100 | ManagedProviderHome.ManagedProviderHome
101 | ClaudeAdapterV2DriverEnv
102 | ProviderHost.ProviderHost
103 | ChildProcessSpawner.ChildProcessSpawner
104 | ResetCreditCoordinator.ResetCreditCoordinator
105 | Crypto.Crypto
106 | FileSystem.FileSystem
107 | HttpClient.HttpClient
108 | ProviderLatestVersions.ProviderLatestVersions
109 | ModelCatalog.ModelCatalog
110 | Path.Path
111 | ProviderEventLoggers.ProviderEventLoggers
112 | ServerConfig.ServerConfig;
113
114export const ClaudeDriver: ProviderDriver<
115 ClaudeSettings,
116 ClaudeDriverEnv,
117 Path.Path | ServerConfig.ServerConfig
118> = {
119 driverKind: DRIVER_KIND,
120 metadata: {
121 displayName: "Claude",
122 supportsMultipleInstances: true,
123 },
124 configSchema: ClaudeSettings,
125 defaultConfig: (): ClaudeSettings => decodeClaudeSettings({}),
126 usage: claudeUsageReader,
127 create: ({
128 instanceId,
129 displayName,
130 accentColor,
131 environment: configuredEnvironment,
132 enabled,
133 config: configured,
134 }) =>
135 Effect.gen(function* () {
136 const sharedId = configured.sharedAccountId;
137 const managedHomes = sharedId ? yield* ManagedProviderHome.ManagedProviderHome : undefined;
138 const shared =
139 sharedId && managedHomes
140 ? yield* Effect.gen(function* () {
141 const credentials = yield* managedHomes.prepare("claudeAgent", sharedId);
142 const paths = yield* managedHomes.paths("claudeAgent", sharedId);
143 return { credentials, paths };
144 }).pipe(
145 Effect.mapError(
146 (cause) =>
147 new ProviderDriverError({
148 driver: DRIVER_KIND,
149 instanceId,
150 detail: cause.message,
151 cause,
152 }),
153 ),
154 )
155 : undefined;
156 const config = shared ? { ...configured, homePath: shared.paths.account } : configured;
157 const environment = shared
158 ? [
159 ...configuredEnvironment.filter(
160 ({ name }) =>
161 !["CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"].includes(
162 name,
163 ),
164 ),
165 {
166 name: "CLAUDE_CODE_OAUTH_TOKEN",
167 value: shared.credentials.accessToken,
168 sensitive: true,
169 },
170 { name: "ANTHROPIC_API_KEY", value: "", sensitive: true },
171 { name: "ANTHROPIC_AUTH_TOKEN", value: "", sensitive: true },
172 ]
173 : configuredEnvironment;
174 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
175 const fileSystem = yield* FileSystem.FileSystem;
176 const path = yield* Path.Path;
177 // Instance-level probes are not tied to a project, so they run in the
178 // server's own state dir. The server cwd is $HOME in packaged desktop
179 // builds, and `claude` wrappers that scope themselves to their cwd refuse
180 // to run there. Workspace probes use the thread cwd via `snapshotForCwd`.
181 const { stateDir } = yield* ServerConfig.ServerConfig;
182 const httpClient = yield* HttpClient.HttpClient;
183 const latestVersions = yield* ProviderLatestVersions.ProviderLatestVersions;
184 const resetCreditCoordinator = yield* ResetCreditCoordinator.ResetCreditCoordinator;
185 const catalogService = yield* ModelCatalog.ModelCatalog;
186 const modelCatalog = catalogService
187 .current(DRIVER_KIND)
188 .pipe(Effect.map(resolveClaudeModelCatalog));
189 const processEnv = yield* mergeProviderInstanceEnvironment(environment);
190 const fallbackContinuationIdentity = defaultProviderContinuationIdentity({
191 driverKind: DRIVER_KIND,
192 instanceId,
193 });
194 const effectiveConfig = {
195 ...config,
196 enabled,
197 binaryPath: expandHomePath(config.binaryPath, yield* HostProcess.HomeDirectory),
198 } satisfies ClaudeSettings;
199 const resolveMaintenance = yield* makeCachedProviderMaintenanceResolution(
200 resolveProviderMaintenanceCapabilitiesEffect(UPDATE, {
201 binaryPath: effectiveConfig.binaryPath,
202 env: processEnv,
203 }).pipe(
204 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
205 Effect.provideService(FileSystem.FileSystem, fileSystem),
206 Effect.provideService(Path.Path, path),
207 ),
208 );
209 const continuationGroupKey = yield* makeClaudeContinuationGroupKey(
210 shared ? { ...effectiveConfig, homePath: shared.paths.shared } : effectiveConfig,
211 processEnv,
212 );
213 const configDir = yield* resolveClaudeHomePath(effectiveConfig, processEnv);
214 const accountConfigPath = yield* ClaudeResetCredits.claudeAccountConfigPath(
215 effectiveConfig.homePath.trim() || processEnv.CLAUDE_CONFIG_DIR?.trim()
216 ? configDir
217 : undefined,
218 );
219 const stampIdentity = withInstanceIdentity({
220 instanceId,
221 driverKind: DRIVER_KIND,
222 displayName,
223 accentColor,
224 continuationGroupKey,
225 });
226
227 const scopedLimitNames = yield* makeClaudeScopedLimitNames;
228 const orchestrationAdapter = yield* createClaudeAdapterV2(
229 {
230 instanceId,
231 displayName,
232 accentColor,
233 environment,
234 enabled,
235 config,
236 },
237 { scopedLimitNames, onUsageLimits: (update) => snapshot.applyUsageLimits(update) },
238 ).pipe(
239 Effect.mapError(
240 (cause) =>
241 new ProviderDriverError({
242 driver: DRIVER_KIND,
243 instanceId,
244 detail: "Failed to build Claude orchestration adapter.",
245 cause,
246 }),
247 ),
248 );
249 const textGeneration = yield* makeClaudeTextGeneration(
250 effectiveConfig,
251 processEnv,
252 modelCatalog,
253 );
254
255 // Per-instance capabilities cache: keyed on binary + resolved HOME so
256 // account-specific probes never share auth metadata across instances.
257 const capabilitiesProbeCache = yield* Cache.make({
258 capacity: 1,
259 timeToLive: CAPABILITIES_PROBE_TTL,
260 lookup: () =>
261 Effect.all([
262 probeClaudeCapabilities(effectiveConfig, processEnv, stateDir),
263 ClaudeResetCredits.readClaudeOrganizationId(accountConfigPath),
264 ]).pipe(
265 Effect.map(([capabilities, workspaceId]) =>
266 capabilities && workspaceId ? { ...capabilities, workspaceId } : capabilities,
267 ),
268 Effect.provideService(FileSystem.FileSystem, fileSystem),
269 Effect.provideService(Path.Path, path),
270 ),
271 });
272 const capabilitiesCacheKey = yield* makeClaudeCapabilitiesCacheKey(
273 effectiveConfig,
274 stateDir,
275 processEnv,
276 );
277
278 // Start the TTL-gated refresh without delaying provider readiness. The
279 // next check observes a remote manifest after the background fetch lands.
280 const checkProvider = catalogService.refreshInBackground.pipe(
281 Effect.andThen(
282 modelCatalog.pipe(
283 Effect.flatMap((catalog) =>
284 checkClaudeProviderStatus(
285 effectiveConfig,
286 () => Cache.get(capabilitiesProbeCache, capabilitiesCacheKey),
287 processEnv,
288 stateDir,
289 catalog,
290 scopedLimitNames,
291 (version) =>
292 ClaudeResetCredits.readClaudeResetCredits(configDir, version).pipe(
293 Effect.provideService(HttpClient.HttpClient, httpClient),
294 Effect.provideService(FileSystem.FileSystem, fileSystem),
295 Effect.provideService(Path.Path, path),
296 ),
297 ),
298 ),
299 Effect.map(stampIdentity),
300 ),
301 ),
302 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
303 Effect.provideService(FileSystem.FileSystem, fileSystem),
304 Effect.provideService(Path.Path, path),
305 );
306
307 const snapshotSettings = yield* makeProviderSnapshotSettingsSource(effectiveConfig);
308 const snapshot = yield* makeManagedServerProvider<ProviderSnapshotSettings<ClaudeSettings>>({
309 resolveMaintenance,
310 getSettings: snapshotSettings.getSettings,
311 streamSettings: snapshotSettings.streamSettings,
312 haveSettingsChanged: haveProviderSnapshotSettingsChanged,
313 initialSnapshot: (settings) =>
314 modelCatalog.pipe(
315 Effect.flatMap((catalog) => makePendingClaudeProvider(settings.provider, catalog)),
316 Effect.map(stampIdentity),
317 ),
318 checkProvider,
319 enrichSnapshot: ({ settings, snapshot, publishSnapshot }) =>
320 resolveMaintenance().pipe(
321 Effect.flatMap((maintenanceCapabilities) =>
322 enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities, {
323 enableProviderUpdateChecks: settings.enableProviderUpdateChecks,
324 }),
325 ),
326 Effect.provideService(HttpClient.HttpClient, httpClient),
327 Effect.provideService(ProviderLatestVersions.ProviderLatestVersions, latestVersions),
328 Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)),
329 ),
330 }).pipe(
331 Effect.mapError(
332 (cause) =>
333 new ProviderDriverError({
334 driver: DRIVER_KIND,
335 instanceId,
336 detail: `Failed to build Claude snapshot: ${cause.message ?? String(cause)}`,
337 cause,
338 }),
339 ),
340 );
341
342 // Same rules as Codex: serialised on the config directory that holds the
343 // login, one request id kept until Claude answers (a cooldown or rate
344 // limit is an answer), then a re-probe.
345 const consumeResetCredit: NonNullable<ProviderInstance["consumeResetCredit"]> = () =>
346 Effect.gen(function* () {
347 const current = yield* snapshot.getSnapshot;
348 const grantId = current.usageLimits?.resetCredits?.nextCreditId;
349 if (!grantId || !current.version) return "noCredit" as const;
350 const version = current.version;
351 return yield* resetCreditCoordinator.redeem(
352 configDir,
353 (requestId) =>
354 ClaudeResetCredits.consumeClaudeResetCredit({
355 configDir,
356 accountConfigPath,
357 version,
358 grantId,
359 requestId,
360 }),
361 ClaudeResetCredits.isSettledClaudeResetCreditFailure,
362 );
363 }).pipe(
364 Effect.provideService(HttpClient.HttpClient, httpClient),
365 Effect.provideService(FileSystem.FileSystem, fileSystem),
366 Effect.provideService(Path.Path, path),
367 Effect.mapError(
368 (cause) =>
369 new ProviderDriverError({
370 driver: DRIVER_KIND,
371 instanceId,
372 detail:
373 cause._tag === "ClaudeResetCreditError"
374 ? cause.message
375 : "Claude could not redeem the reset.",
376 cause,
377 }),
378 ),
379 // Re-probe after any answer, but only a reset claims the limits
380 // changed, so only a reset reports an unconfirmed refresh.
381 Effect.tap((outcome) =>
382 Effect.gen(function* () {
383 const before = (yield* snapshot.getSnapshot).usageLimits?.checkedAt;
384 yield* Cache.invalidateAll(capabilitiesProbeCache);
385 const refreshed = yield* snapshot.refresh;
386 const after = refreshed.usageLimits?.checkedAt;
387 if (
388 outcome === "reset" &&
389 (after === undefined ||
390 after === before ||
391 refreshed.usageLimits?.unavailable?.reason === "probeFailed")
392 ) {
393 return yield* new ProviderDriverError({
394 driver: DRIVER_KIND,
395 instanceId,
396 detail:
397 "The reset was applied, but Claude could not confirm the new limits. Refresh to check.",
398 });
399 }
400 }),
401 ),
402 );
403
404 return {
405 instanceId,
406 driverKind: DRIVER_KIND,
407 continuationIdentity: {
408 ...fallbackContinuationIdentity,
409 continuationKey: continuationGroupKey,
410 },
411 displayName,
412 accentColor,
413 enabled,
414 snapshot,
415 invalidateCaches: Cache.invalidateAll(capabilitiesProbeCache),
416 snapshotForCwd: (cwd: string) =>
417 snapshot.getSnapshot.pipe(
418 Effect.flatMap((machineSnapshot) =>
419 probeClaudeWorkspaceSnapshot(effectiveConfig, machineSnapshot, cwd, processEnv),
420 ),
421 Effect.provideService(FileSystem.FileSystem, fileSystem),
422 Effect.provideService(Path.Path, path),
423 ),
424 orchestrationAdapter,
425 textGeneration,
426 consumeResetCredit,
427 } satisfies ProviderInstance;
428 }),
429};