diff --git a/apps/server/src/claudeModelOptions.ts b/apps/server/src/claudeModelOptions.ts index 7d815f5f9af55..12530db50ec5e 100644 --- a/apps/server/src/claudeModelOptions.ts +++ b/apps/server/src/claudeModelOptions.ts @@ -12,14 +12,19 @@ import { normalizeClaudeCatalogEffort, resolveClaudeCatalogApiModelId, resolveClaudeCatalogEffort, + resolveClaudeModelSlug, type ClaudeModelCatalog, } from "./provider/ClaudeModelCatalog.ts"; +const CLAUDE_CODE_MODEL_ALIASES = new Set(["opus", "sonnet", "haiku", "fable"]); + export interface CompiledClaudeModelSelection { readonly apiModelId: string; readonly effort: string | undefined; readonly promptEffort: string | undefined; readonly settings: Readonly>; + /** The alias of a custom model named like one, which Claude Code would otherwise expand. */ + readonly pinnedAlias: string | undefined; readonly queryIdentity: string; } @@ -51,11 +56,23 @@ export function compileClaudeModelSelection( }; const apiModelId = resolveClaudeCatalogApiModelId(catalog, selection); const promptEffort = resolvePromptInjectedEffort(capabilities, rawEffort) ?? undefined; + // An alias the catalog routes a built-in model to is Claude Code's own and is not pinned. + const pinnedAlias = + CLAUDE_CODE_MODEL_ALIASES.has(apiModelId) && + resolveClaudeModelSlug(catalog, selection.model) === apiModelId + ? apiModelId + : undefined; return { apiModelId, effort, promptEffort, settings, - queryIdentity: JSON.stringify({ apiModelId, effort: effort ?? null, settings }), + pinnedAlias, + queryIdentity: JSON.stringify({ + apiModelId, + effort: effort ?? null, + settings, + pinnedAlias: pinnedAlias ?? null, + }), }; } diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index dd350fb736216..fc46e2b100232 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -70,6 +70,7 @@ import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as PubSub from "effect/PubSub"; import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; @@ -101,8 +102,15 @@ import { ClaudeExecutableFileCheck } from "../../provider/Drivers/ClaudeExecutab import { BUNDLED_CLAUDE_MODEL_CATALOG, type ClaudeModelCatalog, + resolveClaudeModelCatalog, scopeClaudeModelCatalog, + withClaudeCodeModelRoutes, } from "../../provider/ClaudeModelCatalog.ts"; +import { + makeClaudeCodeModelRoutes, + resolveClaudeModelSettingsKey, +} from "../../provider/ClaudeCodeModelRoutes.ts"; +import { resolveProviderCatalog } from "../../provider/ModelManifest.ts"; import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import type * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; @@ -162,6 +170,9 @@ const CUSTOM_MODEL_CLAUDE_SETTINGS = Schema.decodeSync(ClaudeSettings)({ }, ], }); +const ALIAS_CUSTOM_MODEL_CLAUDE_SETTINGS = Schema.decodeSync(ClaudeSettings)({ + customModels: ["haiku", "sonnet"], +}); const ULTRACODE_CUSTOM_MODEL_CLAUDE_SETTINGS = Schema.decodeSync(ClaudeSettings)({ customModels: [ { @@ -3392,6 +3403,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { readonly allocateSessionId?: Effect.Effect; readonly offer?: (message: SDKUserMessage) => Effect.Effect; readonly promptSuggestions?: boolean; + readonly routeModelCatalog?: ClaudeAdapterV2.ClaudeAdapterV2Options["routeModelCatalog"]; }) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -3455,6 +3467,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { path: yield* Path.Path, random: yield* SecureRandom.SecureRandom, idAllocator, + ...(options?.routeModelCatalog ? { routeModelCatalog: options.routeModelCatalog } : {}), continuationRequests: { offer: (request) => Effect.sync(() => { @@ -6245,6 +6258,174 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + it.effect("sends the model id Claude Code reported for the selected catalog model", () => + Effect.scoped( + Effect.gen(function* () { + const modelCatalog = yield* Ref.make(BUNDLED_CLAUDE_MODEL_CATALOG); + const harness = yield* makeWakeHarnessWithOptions({ + modelCatalog: Ref.get(modelCatalog), + settings: ALIAS_CUSTOM_MODEL_CLAUDE_SETTINGS, + close: Queue.shutdown, + freshQueueOnReopen: true, + // Bedrock with ANTHROPIC_DEFAULT_*_MODEL set, as the probe reports it. + routeModelCatalog: (catalog) => + Effect.succeed( + withClaudeCodeModelRoutes(catalog, [ + { value: "default", resolvedModel: "global.anthropic.claude-sonnet-6" }, + { value: "sonnet", resolvedModel: "global.anthropic.claude-sonnet-6" }, + { value: "haiku", resolvedModel: "global.anthropic.claude-haiku-6" }, + ]), + ), + }); + const now = yield* DateTime.now; + const turn = (ordinal: number, model: string) => + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make(`attempt-reported-model:${ordinal}`), + text: `Request ${ordinal}`, + attachments: [], + providerTurnOrdinal: ordinal, + modelSelection: { instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, model }, + }); + // Models the live manifest delivers after a release, so the bundled catalog lacks them. + const manifestCatalog = resolveClaudeModelCatalog( + resolveProviderCatalog( + { + version: 1, + currentModels: {}, + providers: { + claudeAgent: { + profiles: {}, + models: [ + { slug: "claude-haiku-6", name: "Claude Haiku 6", status: "current" }, + { slug: "claude-sonnet-6", name: "Claude Sonnet 6", status: "current" }, + ], + }, + }, + }, + ProviderDriverKind.make("claudeAgent"), + ) ?? undefined, + ); + yield* Ref.set(modelCatalog, manifestCatalog); + + const openedPins = () => { + const env = ( + harness.getOpenedOptions()?.settings as { env?: Record } | undefined + )?.env; + return [env?.ANTHROPIC_DEFAULT_HAIKU_MODEL, env?.ANTHROPIC_DEFAULT_SONNET_MODEL]; + }; + yield* harness.runtime.startTurn(turn(1, "claude-haiku-6")); + assert.equal(harness.getOpenedOptions()?.model, "haiku"); + // The user's ANTHROPIC_DEFAULT_HAIKU_MODEL maps the routed alias to the Bedrock id. + assert.deepEqual(openedPins(), [undefined, undefined]); + yield* harness.offerAndWait(turnOneResult); + yield* Queue.take(harness.terminalReceipts); + + yield* harness.runtime.startTurn(turn(2, "claude-sonnet-6")); + assert.deepEqual(harness.liveSelectionChanges[0], { model: "sonnet" }); + yield* harness.offerAndWait( + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000b02", result: "Two." }), + ); + yield* Queue.take(harness.terminalReceipts); + + // Custom models named like an alias pin it, so a change of pin reopens Claude Code. + yield* harness.runtime.startTurn(turn(3, "haiku")); + assert.deepEqual(openedPins(), ["haiku", undefined]); + yield* harness.offerAndWait( + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000b03", result: "Three." }), + ); + yield* Queue.take(harness.terminalReceipts); + yield* harness.runtime.startTurn(turn(4, "sonnet")); + assert.deepEqual(openedPins(), [undefined, "sonnet"]); + yield* harness.offerAndWait( + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000b04", result: "Four." }), + ); + yield* Queue.take(harness.terminalReceipts); + yield* harness.runtime.startTurn(turn(5, "claude-haiku-6")); + assert.deepEqual(openedPins(), [undefined, undefined]); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ), + ); + + it.effect("keeps the reported model id when a running turn is steered or changes effort", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWakeHarnessWithOptions({ + routeModelCatalog: (catalog) => + Effect.succeed( + withClaudeCodeModelRoutes(catalog, [ + { value: "haiku", resolvedModel: "global.anthropic.claude-haiku-5-5" }, + ]), + ), + }); + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const haiku = { + instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, + model: "claude-haiku-5-5", + } satisfies ModelSelection; + const input = makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-reported-model-live-change"), + text: "Run the slow build.", + attachments: [], + modelSelection: haiku, + }); + yield* harness.runtime.startTurn(input); + assert.equal(harness.getOpenedOptions()?.model, "haiku"); + + yield* harness.runtime.steerTurn({ + threadId: harness.threadId, + runId: input.runId, + providerThread: harness.providerThread, + providerTurnId: idAllocator.derive.providerTurn({ + driver: ClaudeAdapterV2.CLAUDE_PROVIDER, + nativeTurnId: `turn:${input.attemptId}`, + }), + modelSelection: haiku, + message: { + createdBy: "user", + creationSource: "web", + messageId: MessageId.make("steer-reported-model"), + text: "Also check the tests.", + attachments: [], + }, + }); + assert.deepEqual(harness.liveSelectionChanges, []); + assert.lengthOf(harness.offeredMessages, 2); + + yield* harness.runtime.applyModelSelection!({ + providerThread: harness.providerThread, + modelSelection: { ...haiku, options: [{ id: "effort", value: "high" }] }, + }); + assert.deepEqual(harness.liveSelectionChanges, [ + { effortLevel: "high", fastMode: null, ultracode: null }, + ]); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ), + ); + it.effect.each([ { content: { type: "advisor_tool_result_error", error_code: "overloaded" }, @@ -6310,6 +6491,130 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + it.effect( + "sends Claude Code's reported model id only where the cwd resolves it as the probe did", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sdk = yield* Effect.promise(() => + vi.importActual("@anthropic-ai/claude-agent-sdk"), + ); + vi.mocked(ClaudeSdk.resolveSettings).mockImplementation(sdk.resolveSettings); + const probeCwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-claude-probe-" }); + const project = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-claude-project-" }); + const overridden = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-claude-local-", + }); + yield* fileSystem.makeDirectory(path.join(overridden, ".claude")); + yield* fileSystem.writeFileString( + path.join(overridden, ".claude", "settings.local.json"), + JSON.stringify({ + env: { ANTHROPIC_DEFAULT_HAIKU_MODEL: "global.anthropic.claude-sonnet-4-6" }, + }), + ); + const routes = yield* makeClaudeCodeModelRoutes( + (cwd) => + resolveClaudeModelSettingsKey(["project", "local"], cwd).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ), + probeCwd, + ); + yield* routes.record([ + { value: "haiku", resolvedModel: "us.anthropic.claude-haiku-4-5-20251001-v1:0" }, + ]); + yield* routes.settle; + const openedModel = (cwd: string, launchSettings?: Record) => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWakeHarnessWithOptions({ + routeModelCatalog: routes.route, + settings: { + ...DEFAULT_CLAUDE_SETTINGS, + launchArgs: + launchSettings === undefined + ? "" + : `--settings '${JSON.stringify(launchSettings)}'`, + }, + }); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-reported-model-cwd"), + text: "Request", + attachments: [], + modelSelection: { + instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, + model: "claude-haiku-4-5", + }, + runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd, + }), + }), + ); + return harness.getOpenedOptions()?.model; + }), + ); + + assert.equal(yield* openedModel(project), "haiku"); + assert.equal( + yield* openedModel(project, { + env: { ANTHROPIC_DEFAULT_HAIKU_MODEL: "global.anthropic.claude-sonnet-4-6" }, + }), + "claude-haiku-4-5", + ); + assert.equal(yield* openedModel(project, { env: { FOO: "1" } }), "haiku"); + // Claude Code resolves `haiku` to Sonnet there, so the slug is the only safe id. + assert.equal(yield* openedModel(overridden), "claude-haiku-4-5"); + + // A linked worktree and a repository subdirectory read the main checkout's local file. + const repo = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-claude-repo-" }); + const worktree = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-claude-wt-" }); + const worktreeGitDir = path.join(repo, ".git", "worktrees", "wt"); + yield* fileSystem.makeDirectory(worktreeGitDir, { recursive: true }); + yield* fileSystem.writeFileString(path.join(worktreeGitDir, "commondir"), "../..\n"); + yield* fileSystem.writeFileString( + path.join(worktree, ".git"), + `gitdir: ${worktreeGitDir}\n`, + ); + yield* fileSystem.makeDirectory(path.join(repo, ".claude")); + yield* fileSystem.makeDirectory(path.join(repo, "packages", "app"), { recursive: true }); + const rootLocal = (settings: object) => + fileSystem.writeFileString( + path.join(repo, ".claude", "settings.local.json"), + JSON.stringify(settings), + ); + yield* rootLocal({ + env: { ANTHROPIC_DEFAULT_HAIKU_MODEL: "global.anthropic.claude-sonnet-4-6" }, + }); + assert.equal( + yield* openedModel(worktree), + (yield* HostProcess.Platform) === "win32" ? "haiku" : "claude-haiku-4-5", + ); + assert.equal( + yield* openedModel(path.join(repo, "packages", "app")), + (yield* HostProcess.Platform) === "win32" ? "haiku" : "claude-haiku-4-5", + ); + yield* rootLocal({ env: { FOO: "1" } }); + assert.equal(yield* openedModel(worktree), "haiku"); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + describe("MCP elicitations", () => { const testLayer = Layer.mergeAll( IdAllocator.layer, @@ -15347,7 +15652,13 @@ describe("ClaudeAdapterV2 background wake turns", () => { it.effect("leaves buffered wake messages for the continuation queued behind a user turn", () => Effect.gen(function* () { - const harness = yield* makeWakeHarness; + const fileSystem = yield* FileSystem.FileSystem; + const directory = yield* fileSystem.makeTempDirectoryScoped(); + const settingsPath = NodePath.join(directory, "settings.json"); + yield* fileSystem.writeFileString(settingsPath, "{}"); + const harness = yield* makeWakeHarnessWithOptions({ + settings: { ...DEFAULT_CLAUDE_SETTINGS, launchArgs: `--settings "${settingsPath}"` }, + }); const now = yield* DateTime.now; yield* harness.runtime.startTurn( @@ -15367,6 +15678,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { yield* Queue.offer(harness.sdkMessages, wakeResult); yield* harness.awaitContinuation(1); + yield* fileSystem.remove(settingsPath); yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ threadId: harness.threadId, diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index f1ae8c58e7ae9..17a9ca8486a38 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -187,6 +187,7 @@ import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; import { spawnAndCollect } from "@t3tools/provider-core/server/snapshotProbe"; import { signalProcessGroup } from "@t3tools/provider-core/server/processGroup"; import { + claudeLocalSettingsDirectory, claudeSignedOutMessage, makeClaudeEnvironment, mergeClaudeProcessEnvironment, @@ -1891,7 +1892,8 @@ function canApplyClaudeSelectionLive( ): boolean { return ( live.settings.alwaysThinkingEnabled === next.settings.alwaysThinkingEnabled && - (next.effort !== undefined || live.effort === undefined) + (next.effort !== undefined || live.effort === undefined) && + live.pinnedAlias === next.pinnedAlias ); } @@ -1987,8 +1989,6 @@ const subagentDeviceInstructions: HookCallback = () => }, }); -const CLAUDE_CODE_MODEL_ALIASES = new Set(["opus", "sonnet", "haiku", "fable"]); - export function makeClaudeQueryOptions(input: { readonly modelSelection: ModelSelection; readonly modelCatalog?: ClaudeModelCatalog; @@ -2070,12 +2070,13 @@ export function makeClaudeQueryOptions(input: { } as ClaudeSdkSettings); // Claude Code expands a bare alias to its own model, so a custom model with an alias's name // pins the alias to itself. - const aliasOverrideEnv = CLAUDE_CODE_MODEL_ALIASES.has(compiledSelection.apiModelId) - ? { - [`ANTHROPIC_DEFAULT_${compiledSelection.apiModelId.toUpperCase()}_MODEL`]: - compiledSelection.apiModelId, - } - : undefined; + const aliasOverrideEnv = + compiledSelection.pinnedAlias === undefined + ? undefined + : { + [`ANTHROPIC_DEFAULT_${compiledSelection.pinnedAlias.toUpperCase()}_MODEL`]: + compiledSelection.pinnedAlias, + }; // A settings file path cannot take extra keys, so it goes through unchanged. const effectiveQuerySettings = (input.toolSearchOverride === undefined && @@ -5284,6 +5285,15 @@ export interface ClaudeAdapterV2Options { readonly queryRunner: ClaudeAgentSdkQueryRunnerShape; readonly scopedLimitNames?: Ref.Ref; readonly onUsageLimits?: ManagedServerProvider["applyUsageLimits"]; + /** + * Applies Claude Code's reported model ids for a turn's cwd, so turns send ids its backend + * accepts. Defaults to the catalog unchanged. + */ + readonly routeModelCatalog?: ( + catalog: ClaudeModelCatalog, + cwd: string | null, + launchSettings?: ClaudeSdkSettings, + ) => Effect.Effect; readonly continuationRequests?: { readonly offer: ( request: ProviderContinuationRequests.ProviderContinuationRequest, @@ -5365,6 +5375,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }; }, ); + const routeModelCatalog = adapterOptions.routeModelCatalog ?? Effect.succeed; // Claude Code's own subagent TTL setting, in its precedence order. Unset or unreadable means // Anthropic's default applies. @@ -5401,44 +5412,15 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }), Effect.orElseSucceed(() => undefined), ); - // A worktree's .git file points at
/.git/worktrees/, whose commondir leads back to - //
/.git. A bare repository has no main checkout, so its worktree keeps its own file. - const mainCheckoutRoot = (checkout: string) => - fileSystem.readFileString(path.join(checkout, ".git")).pipe( - Effect.flatMap((pointer) => { - const gitDir = /^gitdir:\s*(.+)$/m.exec(pointer)?.[1]?.trim(); - if (gitDir === undefined) return Effect.succeed(checkout); - const worktreeGitDir = path.resolve(checkout, gitDir); - return fileSystem.readFileString(path.join(worktreeGitDir, "commondir")).pipe( - Effect.map((commonDir) => { - const common = path.resolve(worktreeGitDir, commonDir.trim()); - return path.basename(common) === ".git" ? path.dirname(common) : checkout; - }), - ); - }), - Effect.orElseSucceed(() => checkout), - ); - // Claude Code keeps .claude/settings.local.json at the repository root, or in cwd outside a - // repository, on Windows, or when the root is home. Its owner check is skipped: missing it only - // leaves tool search on, Claude Code's default. - const localSettingsDirectory = Effect.fn("ClaudeAdapterV2.localSettingsDirectory")(function* ( - cwd: string, - ) { - if ((yield* HostProcess.Platform) === "win32") return cwd; - for (let directory = cwd; ; directory = path.dirname(directory)) { - const isCheckout = yield* fileSystem - .exists(path.join(directory, ".git")) - .pipe(Effect.orElseSucceed(() => false)); - if (isCheckout) { - const root = yield* mainCheckoutRoot(directory); - return root === (yield* HostProcess.HomeDirectory) ? cwd : root; - } - if (path.dirname(directory) === directory) return cwd; - } - }); const settingsFilesByPrecedence = Effect.fn("ClaudeAdapterV2.settingsFilesByPrecedence")( function* (cwd: string | null) { - const localDirectory = cwd === null ? null : yield* localSettingsDirectory(cwd); + const localDirectory = + cwd === null + ? null + : yield* claudeLocalSettingsDirectory(cwd).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ); return [ ...(cwd === null || localDirectory === null ? [] @@ -12105,6 +12087,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( return false; }); + const routedCatalogFor = (live: ClaudeLiveQueryContext) => + routeModelCatalog(modelCatalog, live.settingsCwd, live.launchArgSettings); + const applySelectionLive = Effect.fnUntraced(function* ( live: ClaudeLiveQueryContext, selection: CompiledClaudeModelSelection, @@ -12143,10 +12128,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( : { allowedTools: queryPolicy.allowedTools }), }); const queryPolicyKey = claudeEffectiveQueryPolicyKey(queryPolicy, mcpOverrides); - const compiledSelection = compileClaudeModelSelection( - turnInput.modelSelection, - modelCatalog, - ); const resumeSessionAt = yield* getNativeConversationHeadId(turnInput.providerThread); const existing = yield* Ref.get(queryContext); // Continuations drain existing output without prompting; retain the live process regardless of selection. @@ -12158,6 +12139,26 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ) { return existing; } + const reuseLaunchSettings = + existing !== null && + existing.nativeThreadId === nativeThreadId && + existing.queryPolicyKey === queryPolicyKey && + !existing.stopping; + const readLaunchSettings = readClaudeLaunchArgSettings({ + settings: adapterOptions.settings, + cwd: turnInput.runtimePolicy.cwd, + fileSystem, + path, + }); + let launchArgSettings = reuseLaunchSettings + ? existing.launchArgSettings + : yield* readLaunchSettings; + let catalog = yield* routeModelCatalog( + modelCatalog, + turnInput.runtimePolicy.cwd, + launchArgSettings, + ); + let compiledSelection = compileClaudeModelSelection(turnInput.modelSelection, catalog); if ( existing !== null && existing.nativeThreadId === nativeThreadId && @@ -12208,32 +12209,29 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( return yield* new ClaudeBackgroundWorkBlocksQueryReplacementError(); } - // Read before closing the live process so a bad path leaves it running. - const [appendSystemPromptFileText, { launchArgSettings, toolSearchOverride }] = - yield* Effect.all( - [ - readClaudeAppendSystemPromptFile({ - settings: adapterOptions.settings, - cwd: turnInput.runtimePolicy.cwd, - fileSystem, - path, - }), - Effect.gen(function* () { - const launchArgSettings = yield* readClaudeLaunchArgSettings({ - settings: adapterOptions.settings, - cwd: turnInput.runtimePolicy.cwd, - fileSystem, - path, - }); - const toolSearchOverride = yield* resolveToolSearchOverride( - turnInput.runtimePolicy.cwd, - launchArgSettings, - ); - return { launchArgSettings, toolSearchOverride }; - }), - ], - { concurrency: "unbounded" }, + if (reuseLaunchSettings) { + launchArgSettings = yield* readLaunchSettings; + catalog = yield* routeModelCatalog( + modelCatalog, + turnInput.runtimePolicy.cwd, + launchArgSettings, ); + compiledSelection = compileClaudeModelSelection(turnInput.modelSelection, catalog); + } + + // Read before closing the live process so a bad path leaves it running. + const [appendSystemPromptFileText, toolSearchOverride] = yield* Effect.all( + [ + readClaudeAppendSystemPromptFile({ + settings: adapterOptions.settings, + cwd: turnInput.runtimePolicy.cwd, + fileSystem, + path, + }), + resolveToolSearchOverride(turnInput.runtimePolicy.cwd, launchArgSettings), + ], + { concurrency: "unbounded" }, + ); const closedExistingNativeThreadId = existing !== null ? existing.nativeThreadId : null; clearedNativeSessions.delete(nativeThreadId); if (existing !== null) { @@ -12254,7 +12252,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( resumeSessionAt !== undefined || openedWithResume || hasPersistedProviderTurn; const queryOptions = makeClaudeQueryOptions({ modelSelection: turnInput.modelSelection, - modelCatalog, + modelCatalog: catalog, nativeThreadId, title: turnInput.appThread.title, resume: shouldResume, @@ -13063,7 +13061,10 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const steerSelection = turnInput.modelSelection === undefined ? undefined - : compileClaudeModelSelection(turnInput.modelSelection, modelCatalog); + : compileClaudeModelSelection( + turnInput.modelSelection, + yield* routedCatalogFor(existing), + ); const liveSteerSelection = steerSelection !== undefined && existing.selection.queryIdentity !== steerSelection.queryIdentity && @@ -13480,12 +13481,19 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( modelSelection, }) { const live = yield* Ref.get(queryContext); - const selection = compileClaudeModelSelection(modelSelection, modelCatalog); - // A model change still waits for the next turn, which checks it against the turn's permission mode. if ( live === null || live.stopping || - live.nativeThreadId !== providerThread.nativeThreadRef?.nativeId || + live.nativeThreadId !== providerThread.nativeThreadRef?.nativeId + ) { + return; + } + const selection = compileClaudeModelSelection( + modelSelection, + yield* routedCatalogFor(live), + ); + // A model change still waits for the next turn, which checks it against the turn's permission mode. + if ( live.selection.queryIdentity === selection.queryIdentity || live.selection.apiModelId !== selection.apiModelId || !canApplyClaudeSelectionLive(live.selection, selection) @@ -13863,7 +13871,10 @@ export type ClaudeAdapterV2DriverEnv = export const createClaudeAdapterV2 = Effect.fn("ClaudeAdapterV2Driver.create")( function* ( input: ProviderAdapterDriverCreateInput, - hooks: Pick = {}, + hooks: Pick< + ClaudeAdapterV2Options, + "scopedLimitNames" | "onUsageLimits" | "modelCatalog" | "routeModelCatalog" + > = {}, ) { const { instanceId, environment, enabled, config } = input; const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/provider/ClaudeCodeModelRoutes.test.ts b/apps/server/src/provider/ClaudeCodeModelRoutes.test.ts new file mode 100644 index 0000000000000..2c06148cac680 --- /dev/null +++ b/apps/server/src/provider/ClaudeCodeModelRoutes.test.ts @@ -0,0 +1,67 @@ +import { assert, describe, it } from "@effect/vitest"; +import { type ModelSelection, ProviderInstanceId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Option from "effect/Option"; +import * as TestClock from "effect/testing/TestClock"; + +import { makeClaudeCodeModelRoutes } from "./ClaudeCodeModelRoutes.ts"; +import { + BUNDLED_CLAUDE_MODEL_CATALOG, + resolveClaudeCatalogApiModelId, +} from "./ClaudeModelCatalog.ts"; + +describe("Claude Code model routes", () => { + it.effect( + "a turn that starts before the first probe settles waits and then gets its routes", + () => + Effect.gen(function* () { + const haiku = { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-haiku-4-5", + } satisfies ModelSelection; + const routes = yield* makeClaudeCodeModelRoutes( + () => Effect.succeed("same settings"), + "/state", + ); + const early = yield* routes + .route(BUNDLED_CLAUDE_MODEL_CATALOG, "/project") + .pipe(Effect.forkChild({ startImmediately: true })); + yield* routes.record([ + { value: "haiku", resolvedModel: "us.anthropic.claude-haiku-4-5-20251001-v1:0" }, + ]); + yield* routes.settle; + assert.strictEqual( + resolveClaudeCatalogApiModelId(yield* Fiber.join(early), haiku), + "haiku", + ); + + // A first check that never reaches the probe still releases waiting turns. + const unprobed = yield* makeClaudeCodeModelRoutes( + () => Effect.succeed("same settings"), + "/state", + ); + const waiting = yield* unprobed + .route(BUNDLED_CLAUDE_MODEL_CATALOG, "/project") + .pipe(Effect.forkChild({ startImmediately: true })); + yield* unprobed.settle; + assert.strictEqual(yield* Fiber.join(waiting), BUNDLED_CLAUDE_MODEL_CATALOG); + + const stuck = yield* makeClaudeCodeModelRoutes( + () => Effect.succeed("same settings"), + "/state", + ); + const fallback = yield* stuck + .route(BUNDLED_CLAUDE_MODEL_CATALOG, "/project") + .pipe(Effect.forkChild({ startImmediately: true })); + yield* TestClock.adjust("2 minutes"); + assert.strictEqual(yield* Fiber.join(fallback), BUNDLED_CLAUDE_MODEL_CATALOG); + // Later turns, steers and effort changes do not wait for the stuck probe again. + const later = yield* stuck + .route(BUNDLED_CLAUDE_MODEL_CATALOG, "/project") + .pipe(Effect.timeoutOption("1 second"), Effect.forkChild({ startImmediately: true })); + yield* TestClock.adjust("1 second"); + assert.deepStrictEqual(yield* Fiber.join(later), Option.some(BUNDLED_CLAUDE_MODEL_CATALOG)); + }), + ); +}); diff --git a/apps/server/src/provider/ClaudeCodeModelRoutes.ts b/apps/server/src/provider/ClaudeCodeModelRoutes.ts new file mode 100644 index 0000000000000..6a9e7a48e64c5 --- /dev/null +++ b/apps/server/src/provider/ClaudeCodeModelRoutes.ts @@ -0,0 +1,116 @@ +import { + type ModelInfo as ClaudeModelInfo, + resolveSettings as resolveClaudeSettings, + type SettingSource, + type Settings as ClaudeSdkSettings, +} from "@anthropic-ai/claude-agent-sdk"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; + +import { claudeLocalSettingsDirectory } from "./Drivers/ClaudeHome.ts"; +import { type ClaudeModelCatalog, withClaudeCodeModelRoutes } from "./ClaudeModelCatalog.ts"; + +// Env names that pick the backend, its region, or the model a family alias resolves to. +const MODEL_SETTINGS_ENV_PREFIXES = [ + "ANTHROPIC_", + "CLAUDE_CODE_USE_", + "AWS_", + "CLOUD_ML_", + "VERTEX_", +]; + +/** Equal keys mean Claude Code resolves model ids the same way under both settings. */ +function claudeModelSettingsKey(settings: ClaudeSdkSettings): string { + const sortedEntries = (record: Readonly> | undefined) => + Object.entries(record ?? {}).toSorted(([a], [b]) => a.localeCompare(b)); + return JSON.stringify([ + settings.availableModels ?? null, + sortedEntries(settings.modelOverrides), + sortedEntries(settings.env).filter(([name]) => + MODEL_SETTINGS_ENV_PREFIXES.some((prefix) => name.startsWith(prefix)), + ), + ]); +} + +const NO_MODEL_SETTINGS_KEY = claudeModelSettingsKey({}); + +class ClaudeModelSettingsReadError extends Schema.TaggedError()( + "ClaudeModelSettingsReadError", + { cause: Schema.Defect() }, +) {} + +export const resolveClaudeModelSettingsKey = Effect.fn("resolveClaudeModelSettingsKey")(function* ( + settingSources: ReadonlyArray, + cwd: string | null, +) { + // The SDK reads the local file from cwd, Claude Code from the main checkout's root. No key, so + // no routes, when that root file touches model settings. + const localDirectory = cwd === null ? null : yield* claudeLocalSettingsDirectory(cwd); + if (localDirectory !== null && localDirectory !== cwd) { + const rootLocal = yield* Effect.tryPromise({ + try: () => resolveClaudeSettings({ cwd: localDirectory, settingSources: ["local"] }), + catch: (cause) => new ClaudeModelSettingsReadError({ cause }), + }).pipe( + Effect.map(({ sources }) => sources.find(({ source }) => source === "local")?.settings), + Effect.orElseSucceed(() => undefined), + ); + if (claudeModelSettingsKey(rootLocal ?? {}) !== NO_MODEL_SETTINGS_KEY) return undefined; + } + return yield* Effect.tryPromise({ + try: () => + resolveClaudeSettings({ + ...(cwd === null ? {} : { cwd }), + settingSources: [...settingSources], + }), + catch: (cause) => new ClaudeModelSettingsReadError({ cause }), + }).pipe( + Effect.map(({ effective }) => claudeModelSettingsKey(effective)), + Effect.orElseSucceed(() => undefined), + ); +}); + +type ReportedModel = Pick; + +/** + * Claude Code's reported model ids, applied to a turn only where its cwd resolves model settings + * as the probe's cwd did. `settle` releases turns waiting for the first probe, whatever its outcome. + */ +export const makeClaudeCodeModelRoutes = Effect.fn("makeClaudeCodeModelRoutes")(function* ( + settingsKey: (cwd: string | null) => Effect.Effect, + probeCwd: string, +) { + const firstProbeSettled = yield* Deferred.make(); + const reported = yield* Ref.make<{ + readonly models: ReadonlyArray; + readonly settingsKey: string | undefined; + }>({ models: [], settingsKey: undefined }); + return { + record: Effect.fn("ClaudeCodeModelRoutes.record")(function* ( + models: ReadonlyArray, + ) { + yield* Ref.set(reported, { models, settingsKey: yield* settingsKey(probeCwd) }); + }), + settle: Deferred.succeed(firstProbeSettled, undefined).pipe(Effect.asVoid), + route: Effect.fn("ClaudeCodeModelRoutes.route")(function* ( + base: ClaudeModelCatalog, + cwd: string | null, + launchSettings?: ClaudeSdkSettings, + ) { + // Leave room for the sequential version and capability probes before falling back once; + // an expired wait counts as settled so later calls do not wait again. + yield* Deferred.await(firstProbeSettled).pipe( + Effect.timeoutOption("2 minutes"), + Effect.andThen(Deferred.succeed(firstProbeSettled, undefined)), + ); + if (claudeModelSettingsKey(launchSettings ?? {}) !== NO_MODEL_SETTINGS_KEY) return base; + const { models, settingsKey: probeKey } = yield* Ref.get(reported); + const routed = withClaudeCodeModelRoutes(base, models); + // Project settings can point an alias at another model, so routes hold only where the + // cwd resolves the same model settings as the probe's. + if (routed === base || probeKey === undefined) return base; + return (yield* settingsKey(cwd)) === probeKey ? routed : base; + }), + }; +}); diff --git a/apps/server/src/provider/ClaudeModelCatalog.test.ts b/apps/server/src/provider/ClaudeModelCatalog.test.ts index c8c8abdfcf2b0..2be0e69bf02b1 100644 --- a/apps/server/src/provider/ClaudeModelCatalog.test.ts +++ b/apps/server/src/provider/ClaudeModelCatalog.test.ts @@ -1,8 +1,9 @@ import { assert, describe, it } from "@effect/vitest"; -import { ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts"; +import { type ModelSelection, ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts"; import { resolveProviderCatalog, type ModelManifestData } from "./ModelManifest.ts"; import { + BUNDLED_CLAUDE_MODEL_CATALOG, capClaudeCatalogEffort, type ClaudeModelCatalog, formatClaudeVersionUpgradeMessage, @@ -16,6 +17,7 @@ import { resolveClaudeModelSlug, resolveClaudeUpdateRequiredModels, scopeClaudeModelCatalog, + withClaudeCodeModelRoutes, } from "./ClaudeModelCatalog.ts"; const manifest = (): ModelManifestData => ({ @@ -233,6 +235,65 @@ describe("Claude model catalog", () => { ); }); + // Rows Claude Code 2.1.310 reports from initialization for each configuration. + it("sends the model id Claude Code reports when it serves a catalog model under another id", () => { + const apiModel = ( + catalog: ClaudeModelCatalog, + model: string, + options?: ModelSelection["options"], + ) => + resolveClaudeCatalogApiModelId(catalog, { + instanceId: ProviderInstanceId.make("claudeAgent"), + model, + ...(options ? { options } : {}), + }); + + // Bedrock with ANTHROPIC_DEFAULT_*_MODEL and an availableModels allowlist of their ids. + const configuredBedrock = withClaudeCodeModelRoutes(BUNDLED_CLAUDE_MODEL_CATALOG, [ + { value: "default", resolvedModel: "global.anthropic.claude-opus-5-5" }, + { value: "sonnet", resolvedModel: "global.anthropic.claude-sonnet-5-5[1m]" }, + { value: "opus", resolvedModel: "global.anthropic.claude-opus-5-5" }, + { value: "haiku", resolvedModel: "global.anthropic.claude-haiku-5-5" }, + ]); + assert.strictEqual(apiModel(configuredBedrock, "claude-haiku-5-5"), "haiku"); + assert.strictEqual(apiModel(configuredBedrock, "haiku-5.5"), "haiku"); + assert.strictEqual(apiModel(configuredBedrock, "claude-sonnet-5-5"), "sonnet"); + assert.strictEqual(apiModel(configuredBedrock, "claude-opus-5-5"), "opus"); + assert.strictEqual(apiModel(configuredBedrock, "claude-opus-5"), "claude-opus-5"); + + // Bedrock with no model settings: Claude Code's `haiku` is Haiku 4.5 there. + const defaultBedrock = withClaudeCodeModelRoutes(BUNDLED_CLAUDE_MODEL_CATALOG, [ + { + value: "us.anthropic.claude-opus-4-6-v1", + resolvedModel: "us.anthropic.claude-opus-4-6-v1", + }, + { + value: "us.anthropic.claude-opus-4-6-v1[1m]", + resolvedModel: "us.anthropic.claude-opus-4-6-v1[1m]", + }, + { value: "haiku", resolvedModel: "us.anthropic.claude-haiku-4-5-20251001-v1:0" }, + ]); + assert.strictEqual( + apiModel(defaultBedrock, "claude-opus-4-6", [{ id: "contextWindow", value: "1m" }]), + "us.anthropic.claude-opus-4-6-v1[1m]", + ); + assert.strictEqual( + apiModel(defaultBedrock, "claude-opus-4-6", [{ id: "contextWindow", value: "200k" }]), + "us.anthropic.claude-opus-4-6-v1", + ); + assert.strictEqual(apiModel(defaultBedrock, "claude-haiku-4-5"), "haiku"); + assert.strictEqual(apiModel(defaultBedrock, "claude-haiku-5-5"), "claude-haiku-5-5"); + + // First-party ids already are what Claude Code runs, even when an alias points elsewhere. + const firstParty = withClaudeCodeModelRoutes(BUNDLED_CLAUDE_MODEL_CATALOG, [ + { value: "default", resolvedModel: "claude-opus-5-5" }, + { value: "opus", resolvedModel: "claude-opus-5-5" }, + { value: "claude-fable-5-1[1m]", resolvedModel: "claude-fable-5-1[1m]" }, + { value: "haiku", resolvedModel: "claude-haiku-4-5-20251001" }, + ]); + assert.strictEqual(firstParty, BUNDLED_CLAUDE_MODEL_CATALOG); + }); + it("marks effort options that would run above the cap without changing ids or defaults", () => { const effortModel = (slug: string) => ({ slug, diff --git a/apps/server/src/provider/ClaudeModelCatalog.ts b/apps/server/src/provider/ClaudeModelCatalog.ts index ceeb94a355e86..1afb1e1cc6149 100644 --- a/apps/server/src/provider/ClaudeModelCatalog.ts +++ b/apps/server/src/provider/ClaudeModelCatalog.ts @@ -32,6 +32,8 @@ export interface ClaudeCatalogModel { readonly model: ServerProviderModel; readonly runtime: ClaudeCodeProfile; readonly compatibility: ClaudeCodeCompatibility; + /** What to send Claude Code instead of the slug, from its own model list. */ + readonly apiModel?: string; } export interface ClaudeModelCatalog { @@ -235,6 +237,44 @@ function resolveClaudeCatalogModel( ); } +// Cloud backends decorate first-party ids: Bedrock adds a region prefix and a +// version ("us.anthropic.claude-opus-4-6-v1"), Vertex adds "@". +function stripClaudeBackendDecoration(wireModel: string): string { + return wireModel + .replace(/^(?:[a-z-]+\.)?anthropic\./, "") + .replace(/-v\d+(?::\d+)?$/, "") + .replace(/@[^@]*$/, ""); +} + +/** + * On Bedrock, Vertex and similar backends Claude Code does not accept the first-party slug: it + * either sends it verbatim or, when `availableModels` excludes it, silently runs the default + * model instead. Its model list names the id that does work, often a family alias that + * `ANTHROPIC_DEFAULT_*_MODEL` points at a backend id. + */ +export function withClaudeCodeModelRoutes( + catalog: ClaudeModelCatalog, + reportedModels: ReadonlyArray>, +): ClaudeModelCatalog { + const apiModels = new Map(); + for (const reported of reportedModels) { + if (reported.value === "default") continue; + const wireModel = stripClaudeContextWindowSuffix(reported.resolvedModel ?? reported.value); + const firstPartyModel = stripClaudeBackendDecoration(wireModel); + if (firstPartyModel === wireModel) continue; + const entry = resolveClaudeCatalogModel(catalog, firstPartyModel); + if (!entry || apiModels.has(entry.model.slug)) continue; + apiModels.set(entry.model.slug, stripClaudeContextWindowSuffix(reported.value)); + } + if (apiModels.size === 0) return catalog; + return { + models: catalog.models.map((entry) => { + const apiModel = apiModels.get(entry.model.slug); + return apiModel ? { ...entry, apiModel } : entry; + }), + }; +} + export function resolveClaudeModelSlug(catalog: ClaudeModelCatalog, slugOrAlias: string): string { return resolveClaudeCatalogModel(catalog, slugOrAlias)?.model.slug ?? slugOrAlias; } @@ -506,7 +546,7 @@ export function resolveClaudeCatalogApiModelId( modelSelection: ModelSelection, ): string { const entry = resolveClaudeCatalogModel(catalog, modelSelection.model); - const slug = entry?.model.slug ?? modelSelection.model; + const slug = entry?.apiModel ?? entry?.model.slug ?? modelSelection.model; const descriptors = getProviderOptionDescriptors({ caps: entry?.model.capabilities ?? EMPTY_CAPABILITIES, selections: modelSelection.options, diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index af20b76754943..d75b1cad86402 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -36,6 +36,10 @@ import { import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; import * as ModelCatalog from "@t3tools/provider-core/server/ModelCatalog"; import { resolveClaudeModelCatalog } from "../ClaudeModelCatalog.ts"; +import { + makeClaudeCodeModelRoutes, + resolveClaudeModelSettingsKey, +} from "../ClaudeCodeModelRoutes.ts"; import { defaultProviderContinuationIdentity, type ProviderDriver, @@ -146,6 +150,23 @@ export const ClaudeDriver: ProviderDriver 0 || + processEnv.CLAUDE_CONFIG_DIR !== (yield* HostProcess.Environment).CLAUDE_CONFIG_DIR; + // Only a successful probe records routes, so a failed one never sends turns back to slugs + // the backend rejects. + const modelRoutes = yield* makeClaudeCodeModelRoutes( + (cwd) => + resolveClaudeModelSettingsKey( + customHome ? ["project", "local"] : ["user", "project", "local"], + cwd, + ).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ), + stateDir, + ); const orchestrationAdapter = yield* createClaudeAdapterV2( { instanceId, @@ -159,6 +180,7 @@ export const ClaudeDriver: ProviderDriver snapshot.applyUsageLimits(update), modelCatalog, + routeModelCatalog: modelRoutes.route, }, ).pipe( Effect.mapError( @@ -175,6 +197,7 @@ export const ClaudeDriver: ProviderDriver Ref.set(lastGoodCapabilities, capabilities)), + Effect.tap(({ capabilities }) => + Ref.set(lastGoodCapabilities, capabilities).pipe( + Effect.andThen(modelRoutes.record(capabilities.models ?? [])), + ), + ), + Effect.ensuring(modelRoutes.settle), Effect.catch((error) => Ref.getAndSet(lastGoodCapabilities, undefined).pipe( Effect.flatMap((capabilities) => @@ -262,6 +290,8 @@ export const ClaudeDriver: ProviderDriver false)); }); +// A worktree's .git file points at
/.git/worktrees/, whose commondir leads back to +//
/.git. A bare repository has no main checkout, so its worktree keeps its own file. +// Untraced because claudeLocalSettingsDirectory already owns the span. +const mainCheckoutRoot = Effect.fnUntraced(function* (checkout: string) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + return yield* fileSystem.readFileString(path.join(checkout, ".git")).pipe( + Effect.flatMap((pointer) => { + const gitDir = /^gitdir:\s*(.+)$/m.exec(pointer)?.[1]?.trim(); + if (gitDir === undefined) return Effect.succeed(checkout); + const worktreeGitDir = path.resolve(checkout, gitDir); + return fileSystem.readFileString(path.join(worktreeGitDir, "commondir")).pipe( + Effect.map((commonDir) => { + const common = path.resolve(worktreeGitDir, commonDir.trim()); + return path.basename(common) === ".git" ? path.dirname(common) : checkout; + }), + ); + }), + Effect.orElseSucceed(() => checkout), + ); +}); + +// Claude Code keeps .claude/settings.local.json at the repository root, or in cwd outside a +// repository, on Windows, or when the root is home. Its owner check is skipped, so callers must +// stay safe when Claude Code ignores the root file. +export const claudeLocalSettingsDirectory = Effect.fn("claudeLocalSettingsDirectory")(function* ( + cwd: string, +) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + if ((yield* HostProcess.Platform) === "win32") return cwd; + for (let directory = cwd; ; directory = path.dirname(directory)) { + const isCheckout = yield* fileSystem + .exists(path.join(directory, ".git")) + .pipe(Effect.orElseSucceed(() => false)); + if (isCheckout) { + const root = yield* mainCheckoutRoot(directory); + return root === (yield* HostProcess.HomeDirectory) ? cwd : root; + } + if (path.dirname(directory) === directory) return cwd; + } +}); + export const makeClaudeContinuationGroupKey = Effect.fn("makeClaudeContinuationGroupKey")( function* ( config: Pick, diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.ts index 34e7415b3c9f4..756a068926a60 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.ts @@ -2,6 +2,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; @@ -97,6 +98,10 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu claudeSettings: ClaudeSettings, environment?: NodeJS.ProcessEnv, modelCatalog: Effect.Effect = Effect.succeed(BUNDLED_CLAUDE_MODEL_CATALOG), + routeModelCatalog: ( + catalog: ClaudeModelCatalog, + cwd: string, + ) => Effect.Effect = Effect.succeed, ) { const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const fileSystem = yield* FileSystem.FileSystem; @@ -146,9 +151,19 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu }: TextGenerationOperations.Request): Effect.fn.Return< S["Type"], TextGenerationError, - S["DecodingServices"] + S["DecodingServices"] | Scope.Scope > { - const catalog = yield* scopedModelCatalog; + const workingDirectory = + operation === "generateThreadTitle" + ? yield* fileSystem + .makeTempDirectoryScoped({ prefix: "t3code-claude-title-" }) + .pipe( + Effect.mapError((cause) => + normalizeCliError("claude", operation, cause, "Failed to create title directory"), + ), + ) + : cwd; + const catalog = yield* routeModelCatalog(yield* scopedModelCatalog, workingDirectory); const resolvedModelSelection = { ...modelSelection, model: resolveClaudeModelSlug(catalog, modelSelection.model), @@ -200,16 +215,6 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu const runClaudeCommand = Effect.fn("runClaudeJson.runClaudeCommand")(function* ( noSessionPersistence: boolean, ) { - const workingDirectory = - operation === "generateThreadTitle" - ? yield* fileSystem - .makeTempDirectoryScoped({ prefix: "t3code-claude-title-" }) - .pipe( - Effect.mapError((cause) => - normalizeCliError("claude", operation, cause, "Failed to create title directory"), - ), - ) - : cwd; const managedMcpConfig = yield* hasClaudeManagedMcpConfig(); const spawnCommand = yield* resolveSpawnCommand( claudeSettings.binaryPath || "claude", @@ -344,5 +349,9 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu ); }); - return TextGenerationOperations.fromRunner("ClaudeTextGeneration", runClaudeJson); + return TextGenerationOperations.fromRunner( + "ClaudeTextGeneration", + (request: TextGenerationOperations.Request) => + Effect.scoped(runClaudeJson(request)), + ); });