From a8a6f51015d16b3022d585da8d35670dec9b7e91 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Wed, 1 Jul 2026 19:45:08 -0700 Subject: [PATCH 1/5] add subagent model setting --- packages/agent/package.json | 4 + .../src/adapters/claude/session/models.ts | 4 + .../adapters/claude/session/options.test.ts | 53 ++++++++ .../src/adapters/claude/session/options.ts | 25 +++- .../adapters/claude/session/settings.test.ts | 106 ++++++++++++++- .../src/adapters/claude/session/settings.ts | 54 +++++++- packages/agent/tsup.config.ts | 1 + .../host-router/src/routers/agent.router.ts | 16 +++ .../settings/sections/ClaudeCodeSettings.tsx | 123 +++++++++++++++++- .../src/services/agent/agent.test.ts | 39 ++++++ .../src/services/agent/agent.ts | 15 +++ .../src/services/agent/schemas.ts | 9 ++ 12 files changed, 440 insertions(+), 9 deletions(-) diff --git a/packages/agent/package.json b/packages/agent/package.json index 1ccc632d6e..ac3bd94fae 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -60,6 +60,10 @@ "types": "./dist/adapters/claude/session/models.d.ts", "import": "./dist/adapters/claude/session/models.js" }, + "./adapters/claude/session/settings": { + "types": "./dist/adapters/claude/session/settings.d.ts", + "import": "./dist/adapters/claude/session/settings.js" + }, "./adapters/reasoning-effort": { "types": "./dist/adapters/reasoning-effort.d.ts", "import": "./dist/adapters/reasoning-effort.js" diff --git a/packages/agent/src/adapters/claude/session/models.ts b/packages/agent/src/adapters/claude/session/models.ts index 4ea33fd269..685de02ae9 100644 --- a/packages/agent/src/adapters/claude/session/models.ts +++ b/packages/agent/src/adapters/claude/session/models.ts @@ -6,6 +6,10 @@ export const DEFAULT_MODEL = "opus"; // at spawn, so this must stay distinct from the alias form used for DEFAULT_MODEL. export const FALLBACK_MODEL = "claude-opus-4-8"; +// Task-tool subagents and workflow fan-out agents default to a cheaper tier +// than the main loop, injected as CLAUDE_CODE_SUBAGENT_MODEL at CLI spawn. +export const DEFAULT_SUBAGENT_MODEL = "sonnet"; + // Default thinking level when the user hasn't picked one. Adaptive-only models // like claude-fable-5 reject the SDK's no-effort `thinking: { type: "disabled" }` // shape, so effort-capable models default to high to keep thinking enabled. diff --git a/packages/agent/src/adapters/claude/session/options.test.ts b/packages/agent/src/adapters/claude/session/options.test.ts index 87fd3b2ae0..a0b84e32ab 100644 --- a/packages/agent/src/adapters/claude/session/options.test.ts +++ b/packages/agent/src/adapters/claude/session/options.test.ts @@ -193,6 +193,59 @@ describe("buildSessionOptions", () => { }); }); + describe("CLAUDE_CODE_SUBAGENT_MODEL", () => { + const originalSubagentModel = process.env.CLAUDE_CODE_SUBAGENT_MODEL; + + beforeEach(() => { + delete process.env.CLAUDE_CODE_SUBAGENT_MODEL; + }); + + afterEach(() => { + if (originalSubagentModel === undefined) { + delete process.env.CLAUDE_CODE_SUBAGENT_MODEL; + } else { + process.env.CLAUDE_CODE_SUBAGENT_MODEL = originalSubagentModel; + } + }); + + it("defaults subagents to sonnet when nothing overrides", () => { + const options = buildSessionOptions(makeParams()); + + expect(options.env?.CLAUDE_CODE_SUBAGENT_MODEL).toBe("sonnet"); + }); + + it("prefers a pre-set process env value over the default", () => { + process.env.CLAUDE_CODE_SUBAGENT_MODEL = "inherit"; + + const options = buildSessionOptions(makeParams()); + + expect(options.env?.CLAUDE_CODE_SUBAGENT_MODEL).toBe("inherit"); + }); + + it("prefers the merged settings env value over process env", () => { + process.env.CLAUDE_CODE_SUBAGENT_MODEL = "inherit"; + const params = makeParams(); + vi.spyOn(params.settingsManager, "getSettings").mockReturnValue({ + env: { CLAUDE_CODE_SUBAGENT_MODEL: "claude-haiku-4-5" }, + }); + + const options = buildSessionOptions(params); + + expect(options.env?.CLAUDE_CODE_SUBAGENT_MODEL).toBe("claude-haiku-4-5"); + }); + + it("converts gateway model ids from settings to CLI aliases", () => { + const params = makeParams(); + vi.spyOn(params.settingsManager, "getSettings").mockReturnValue({ + env: { CLAUDE_CODE_SUBAGENT_MODEL: "claude-opus-4-8" }, + }); + + const options = buildSessionOptions(params); + + expect(options.env?.CLAUDE_CODE_SUBAGENT_MODEL).toBe("opus"); + }); + }); + describe("ANTHROPIC_CUSTOM_HEADERS", () => { const originalProjectId = process.env.POSTHOG_PROJECT_ID; const originalCustomHeaders = process.env.ANTHROPIC_CUSTOM_HEADERS; diff --git a/packages/agent/src/adapters/claude/session/options.ts b/packages/agent/src/adapters/claude/session/options.ts index fa71653b2d..4ceaffdc08 100644 --- a/packages/agent/src/adapters/claude/session/options.ts +++ b/packages/agent/src/adapters/claude/session/options.ts @@ -28,7 +28,12 @@ import type { CodeExecutionMode } from "../tools"; import type { EffortLevel } from "../types"; import { APPENDED_INSTRUCTIONS } from "./instructions"; import { loadUserClaudeJsonMcpServers } from "./mcp-config"; -import { DEFAULT_MODEL, FALLBACK_MODEL } from "./models"; +import { + DEFAULT_MODEL, + DEFAULT_SUBAGENT_MODEL, + FALLBACK_MODEL, + toSdkModelId, +} from "./models"; import type { SettingsManager } from "./settings"; export interface ProcessSpawnedInfo { @@ -134,7 +139,10 @@ function buildMcpServers( }; } -function buildEnvironment(gateway?: GatewayEnv): Record { +function buildEnvironment( + gateway?: GatewayEnv, + settingsSubagentModel?: string, +): Record { // Custom HTTP headers reach the model only through the Claude CLI subprocess, // which reads them from this env var (newline-delimited `name: value` lines) // — the SDK has no direct header option. We finalize them here, the single @@ -192,6 +200,14 @@ function buildEnvironment(gateway?: GatewayEnv): Record { MCP_CONNECTION_NONBLOCKING: mcpNonblocking, }), ANTHROPIC_CUSTOM_HEADERS: customHeaders, + // Subagents (Task tool, workflow fan-outs) run on a cheaper model than the + // main loop by default. The CLI treats this as a hard override for every + // subagent; the merged .claude/settings.json env block (user layer written + // by the Settings UI, stored as a gateway model id) or a pre-set process + // env var wins over the default. + CLAUDE_CODE_SUBAGENT_MODEL: settingsSubagentModel + ? toSdkModelId(settingsSubagentModel) + : (process.env.CLAUDE_CODE_SUBAGENT_MODEL ?? DEFAULT_SUBAGENT_MODEL), }; } @@ -443,7 +459,10 @@ export function buildSessionOptions(params: BuildOptionsParams): Options { params.mcpServers, loadUserClaudeJsonMcpServers(params.cwd, params.logger), ), - env: buildEnvironment(params.gatewayEnv), + env: buildEnvironment( + params.gatewayEnv, + params.settingsManager.getSettings().env?.CLAUDE_CODE_SUBAGENT_MODEL, + ), hooks: buildHooks( params.userProvidedOptions?.hooks, params.onModeChange, diff --git a/packages/agent/src/adapters/claude/session/settings.test.ts b/packages/agent/src/adapters/claude/session/settings.test.ts index 5f6a91f425..0ec6410b86 100644 --- a/packages/agent/src/adapters/claude/session/settings.test.ts +++ b/packages/agent/src/adapters/claude/session/settings.test.ts @@ -4,7 +4,12 @@ import * as os from "node:os"; import * as path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { resolveMainRepoPath } from "./repo-path"; -import { mergeAvailableModels, SettingsManager } from "./settings"; +import { + getUserSettingsEnvVar, + mergeAvailableModels, + SettingsManager, + setUserSettingsEnvVar, +} from "./settings"; function runGit(cwd: string, args: string[]): void { execFileSync("git", args, { cwd, stdio: ["ignore", "ignore", "pipe"] }); @@ -308,3 +313,102 @@ describe("mergeAvailableModels", () => { ).toEqual(["managed-a"]); }); }); + +describe("user settings env vars", () => { + let configDir: string; + let originalConfigDir: string | undefined; + + const settingsPath = () => path.join(configDir, "settings.json"); + + const readSettings = async () => + JSON.parse(await fs.promises.readFile(settingsPath(), "utf-8")); + + beforeEach(async () => { + configDir = await fs.promises.mkdtemp( + path.join(os.tmpdir(), "user-settings-env-"), + ); + originalConfigDir = process.env.CLAUDE_CONFIG_DIR; + process.env.CLAUDE_CONFIG_DIR = configDir; + }); + + afterEach(async () => { + if (originalConfigDir === undefined) { + delete process.env.CLAUDE_CONFIG_DIR; + } else { + process.env.CLAUDE_CONFIG_DIR = originalConfigDir; + } + await fs.promises.rm(configDir, { recursive: true, force: true }); + }); + + it("creates the settings file when absent", async () => { + await setUserSettingsEnvVar("CLAUDE_CODE_SUBAGENT_MODEL", "sonnet"); + + expect(await readSettings()).toEqual({ + env: { CLAUDE_CODE_SUBAGENT_MODEL: "sonnet" }, + }); + }); + + it("preserves sibling settings and other env keys", async () => { + await fs.promises.writeFile( + settingsPath(), + JSON.stringify({ + model: "opus", + permissions: { allow: ["Bash(ls:*)"] }, + env: { FOO: "bar" }, + }), + ); + + await setUserSettingsEnvVar( + "CLAUDE_CODE_SUBAGENT_MODEL", + "claude-haiku-4-5", + ); + + expect(await readSettings()).toEqual({ + model: "opus", + permissions: { allow: ["Bash(ls:*)"] }, + env: { FOO: "bar", CLAUDE_CODE_SUBAGENT_MODEL: "claude-haiku-4-5" }, + }); + }); + + it("deletes the key on undefined and prunes an empty env block", async () => { + await setUserSettingsEnvVar("CLAUDE_CODE_SUBAGENT_MODEL", "sonnet"); + await setUserSettingsEnvVar("CLAUDE_CODE_SUBAGENT_MODEL", undefined); + + expect(await readSettings()).toEqual({}); + }); + + it("keeps other env keys when deleting one", async () => { + await fs.promises.writeFile( + settingsPath(), + JSON.stringify({ env: { FOO: "bar", CLAUDE_CODE_SUBAGENT_MODEL: "x" } }), + ); + + await setUserSettingsEnvVar("CLAUDE_CODE_SUBAGENT_MODEL", undefined); + + expect(await readSettings()).toEqual({ env: { FOO: "bar" } }); + }); + + it("round-trips through getUserSettingsEnvVar", async () => { + expect(await getUserSettingsEnvVar("CLAUDE_CODE_SUBAGENT_MODEL")).toBe( + null, + ); + + await setUserSettingsEnvVar("CLAUDE_CODE_SUBAGENT_MODEL", "inherit"); + + expect(await getUserSettingsEnvVar("CLAUDE_CODE_SUBAGENT_MODEL")).toBe( + "inherit", + ); + }); + + it("refuses to overwrite an unparseable settings file", async () => { + await fs.promises.writeFile(settingsPath(), "{not json"); + + await expect( + setUserSettingsEnvVar("CLAUDE_CODE_SUBAGENT_MODEL", "sonnet"), + ).rejects.toThrow(); + + expect(await fs.promises.readFile(settingsPath(), "utf-8")).toBe( + "{not json", + ); + }); +}); diff --git a/packages/agent/src/adapters/claude/session/settings.ts b/packages/agent/src/adapters/claude/session/settings.ts index b4aa9ab6ed..c19a020f47 100644 --- a/packages/agent/src/adapters/claude/session/settings.ts +++ b/packages/agent/src/adapters/claude/session/settings.ts @@ -210,6 +210,56 @@ export interface PermissionCheckResult { source?: "allow" | "deny" | "ask"; } +function getUserSettingsFilePath(): string { + const configDir = + process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude"); + return path.join(configDir, "settings.json"); +} + +const userSettingsWriteMutex = new AsyncMutex(); + +export async function getUserSettingsEnvVar( + key: string, +): Promise { + const settings = await loadSettingsFile(getUserSettingsFilePath()); + return settings.env?.[key] ?? null; +} + +/** + * Sets or clears one key in the `env` block of the user-layer settings.json + * (`/settings.json`, app-scoped since the host sets + * CLAUDE_CONFIG_DIR). `buildSessionOptions` reads the merged env at every + * session spawn, so changes apply to new and resumed sessions without any + * per-session threading. `value === undefined` deletes the key. Mirrors + * `addAllowRules`: mutex-serialised, atomic temp-file + rename. + */ +export async function setUserSettingsEnvVar( + key: string, + value: string | undefined, +): Promise { + await userSettingsWriteMutex.acquire(); + try { + const filePath = getUserSettingsFilePath(); + const existing = await readSettingsFileForUpdate(filePath); + const env = { ...existing.env }; + if (value === undefined) { + delete env[key]; + } else { + env[key] = value; + } + const next: ClaudeCodeSettings = { ...existing }; + if (Object.keys(env).length > 0) { + next.env = env; + } else { + delete next.env; + } + await fs.promises.mkdir(path.dirname(filePath), { recursive: true }); + await writeFileAtomic(filePath, `${JSON.stringify(next, null, 2)}\n`); + } finally { + userSettingsWriteMutex.release(); + } +} + export function getManagedSettingsPath(): string { switch (process.platform) { case "darwin": @@ -267,9 +317,7 @@ export class SettingsManager { } private getUserSettingsPath(): string { - const configDir = - process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude"); - return path.join(configDir, "settings.json"); + return getUserSettingsFilePath(); } private getProjectSettingsPath(): string { diff --git a/packages/agent/tsup.config.ts b/packages/agent/tsup.config.ts index bb5335eb1f..513b70753d 100644 --- a/packages/agent/tsup.config.ts +++ b/packages/agent/tsup.config.ts @@ -120,6 +120,7 @@ export default defineConfig([ "src/adapters/claude/conversion/tool-use-to-acp.ts", "src/adapters/claude/session/jsonl-hydration.ts", "src/adapters/claude/session/models.ts", + "src/adapters/claude/session/settings.ts", "src/adapters/codex/models.ts", "src/adapters/claude/mcp/tool-metadata.ts", "src/adapters/codex/structured-output-mcp-server.ts", diff --git a/packages/host-router/src/routers/agent.router.ts b/packages/host-router/src/routers/agent.router.ts index e62ea18cf9..eefa43ffa0 100644 --- a/packages/host-router/src/routers/agent.router.ts +++ b/packages/host-router/src/routers/agent.router.ts @@ -13,6 +13,7 @@ import { getGatewayModelsOutput, getPreviewConfigOptionsInput, getPreviewConfigOptionsOutput, + getSubagentModelOutput, listSessionsInput, listSessionsOutput, notifySessionContextInput, @@ -23,6 +24,7 @@ import { respondToPermissionInput, sessionResponseSchema, setConfigOptionInput, + setSubagentModelInput, startSessionInput, subscribeSessionInput, } from "@posthog/workspace-server/services/agent/schemas"; @@ -227,4 +229,18 @@ export const agentRouter = router({ .get(AGENT_SERVICE) .getPreviewConfigOptions(input.apiHost, input.adapter), ), + + getSubagentModel: publicProcedure + .output(getSubagentModelOutput) + .query(({ ctx }) => + ctx.container.get(AGENT_SERVICE).getSubagentModel(), + ), + + setSubagentModel: publicProcedure + .input(setSubagentModelInput) + .mutation(({ ctx, input }) => + ctx.container + .get(AGENT_SERVICE) + .setSubagentModel(input.model), + ), }); diff --git a/packages/ui/src/features/settings/sections/ClaudeCodeSettings.tsx b/packages/ui/src/features/settings/sections/ClaudeCodeSettings.tsx index 92f62bbdce..3c41ded1d3 100644 --- a/packages/ui/src/features/settings/sections/ClaudeCodeSettings.tsx +++ b/packages/ui/src/features/settings/sections/ClaudeCodeSettings.tsx @@ -1,5 +1,14 @@ import { ArrowSquareOut, Check, Copy, Warning } from "@phosphor-icons/react"; -import { ANALYTICS_EVENTS } from "@posthog/shared"; +import { useHostTRPC } from "@posthog/host-router/react"; +import { + ANALYTICS_EVENTS, + flattenSelectOptions, + GLM_MODEL_FLAG, + getCloudUrlFromRegion, +} from "@posthog/shared"; +import { useAuthStateValue } from "@posthog/ui/features/auth/store"; +import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; +import { stripGlmModelOption } from "@posthog/ui/features/sessions/modelOptionFilters"; import { SettingRow } from "@posthog/ui/features/settings/SettingRow"; import { PermissionsSettings } from "@posthog/ui/features/settings/sections/PermissionsSettings"; import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore"; @@ -12,9 +21,11 @@ import { Flex, IconButton, Link, + Select, Switch, Text, } from "@radix-ui/themes"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { useCallback, useState } from "react"; function CopyableCommand({ command }: { command: string }) { @@ -69,6 +80,107 @@ function SettingDescription({ ); } +const SUBAGENT_MODEL_DEFAULT_VALUE = "__default__"; +const SUBAGENT_MODEL_INHERIT_VALUE = "inherit"; + +function SubagentModelSetting() { + const hostTRPC = useHostTRPC(); + const glmEnabled = useFeatureFlag(GLM_MODEL_FLAG); + const cloudRegion = useAuthStateValue((state) => state.cloudRegion); + const apiHost = cloudRegion ? getCloudUrlFromRegion(cloudRegion) : null; + + const subagentModel = useQuery( + hostTRPC.agent.getSubagentModel.queryOptions(), + ); + const previewConfig = useQuery({ + ...hostTRPC.agent.getPreviewConfigOptions.queryOptions({ + apiHost: apiHost ?? "", + adapter: "claude", + }), + enabled: apiHost !== null, + }); + const setSubagentModel = useMutation( + hostTRPC.agent.setSubagentModel.mutationOptions(), + ); + + const rawModelOption = previewConfig.data?.find( + (option) => option.category === "model" && option.type === "select", + ); + const modelOption = + rawModelOption?.type === "select" + ? glmEnabled + ? rawModelOption + : stripGlmModelOption(rawModelOption) + : undefined; + const modelItems = + modelOption?.type === "select" + ? flattenSelectOptions(modelOption.options) + : []; + + const storedModel = subagentModel.data ?? null; + const selectValue = storedModel ?? SUBAGENT_MODEL_DEFAULT_VALUE; + const storedModelHasItem = + !storedModel || + storedModel === SUBAGENT_MODEL_INHERIT_VALUE || + modelItems.some((item) => item.value === storedModel); + + const handleChange = useCallback( + (value: string) => { + const next = value === SUBAGENT_MODEL_DEFAULT_VALUE ? null : value; + track(ANALYTICS_EVENTS.SETTING_CHANGED, { + setting_name: "subagent_model", + new_value: next ?? "default", + old_value: storedModel ?? "default", + }); + setSubagentModel.mutate( + { model: next }, + { onSettled: () => subagentModel.refetch() }, + ); + }, + [storedModel, setSubagentModel, subagentModel], + ); + + return ( + + + + + + + Sonnet (default) + + + Inherit main model + + {!storedModelHasItem && storedModel && ( + {storedModel} + )} + {modelItems.map((item) => ( + + {item.name} + + ))} + + + {setSubagentModel.isError && ( + + Failed to save subagent model + + )} + + + ); +} + export function ClaudeCodeSettings() { const { allowBypassPermissions, setAllowBypassPermissions } = useSettingsStore(); @@ -104,8 +216,15 @@ export function ClaudeCodeSettings() { return ( + {/* Models */} + Models + + + {/* Extensions */} - Extensions + + Extensions + vi.fn()); +const mockSetUserSettingsEnvVar = vi.hoisted(() => vi.fn()); + const mockApp = vi.hoisted(() => ({ getAppPath: vi.fn(() => "/mock/appPath"), isPackaged: false, @@ -82,6 +85,11 @@ vi.mock("@posthog/agent/adapters/claude/session/jsonl-hydration", () => ({ hydrateSessionJsonl: vi.fn().mockResolvedValue(undefined), })); +vi.mock("@posthog/agent/adapters/claude/session/settings", () => ({ + getUserSettingsEnvVar: mockGetUserSettingsEnvVar, + setUserSettingsEnvVar: mockSetUserSettingsEnvVar, +})); + vi.mock("node:fs", async (importOriginal) => { const original = await importOriginal(); return { @@ -235,6 +243,37 @@ describe("AgentService", () => { vi.unstubAllGlobals(); }); + describe("subagent model", () => { + it("reads the persisted value from user settings", async () => { + mockGetUserSettingsEnvVar.mockResolvedValue("claude-haiku-4-5"); + + await expect(service.getSubagentModel()).resolves.toBe( + "claude-haiku-4-5", + ); + expect(mockGetUserSettingsEnvVar).toHaveBeenCalledWith( + "CLAUDE_CODE_SUBAGENT_MODEL", + ); + }); + + it("persists the chosen model", async () => { + await service.setSubagentModel("claude-sonnet-5"); + + expect(mockSetUserSettingsEnvVar).toHaveBeenCalledWith( + "CLAUDE_CODE_SUBAGENT_MODEL", + "claude-sonnet-5", + ); + }); + + it("clears the key when set to null", async () => { + await service.setSubagentModel(null); + + expect(mockSetUserSettingsEnvVar).toHaveBeenCalledWith( + "CLAUDE_CODE_SUBAGENT_MODEL", + undefined, + ); + }); + }); + describe("MCP servers", () => { it("marks desktop sessions as local even though they have a taskRunId", async () => { await service.startSession({ diff --git a/packages/workspace-server/src/services/agent/agent.ts b/packages/workspace-server/src/services/agent/agent.ts index 83341d0c1e..ca2eea8b63 100644 --- a/packages/workspace-server/src/services/agent/agent.ts +++ b/packages/workspace-server/src/services/agent/agent.ts @@ -19,6 +19,10 @@ import { } from "@posthog/agent"; import type { McpToolApprovals } from "@posthog/agent/adapters/claude/mcp/tool-metadata"; import { hydrateSessionJsonl } from "@posthog/agent/adapters/claude/session/jsonl-hydration"; +import { + getUserSettingsEnvVar, + setUserSettingsEnvVar, +} from "@posthog/agent/adapters/claude/session/settings"; import { getReasoningEffortOptions } from "@posthog/agent/adapters/reasoning-effort"; import { Agent } from "@posthog/agent/agent"; import { @@ -2127,6 +2131,17 @@ For git operations while detached: }); } + async getSubagentModel(): Promise { + return getUserSettingsEnvVar("CLAUDE_CODE_SUBAGENT_MODEL"); + } + + async setSubagentModel(model: string | null): Promise { + await setUserSettingsEnvVar( + "CLAUDE_CODE_SUBAGENT_MODEL", + model ?? undefined, + ); + } + async getPreviewConfigOptions( apiHost: string, adapter: "claude" | "codex" = "claude", diff --git a/packages/workspace-server/src/services/agent/schemas.ts b/packages/workspace-server/src/services/agent/schemas.ts index 493e79943e..6343fd0637 100644 --- a/packages/workspace-server/src/services/agent/schemas.ts +++ b/packages/workspace-server/src/services/agent/schemas.ts @@ -79,6 +79,15 @@ export const startSessionInput = z.object({ export type StartSessionInput = z.infer; +export const setSubagentModelInput = z.object({ + /** Gateway model id or "inherit"; null clears back to the built-in default. */ + model: z.string().min(1).nullable(), +}); + +export type SetSubagentModelInput = z.infer; + +export const getSubagentModelOutput = z.string().nullable(); + export const modelOptionSchema = z.object({ modelId: z.string(), name: z.string(), From f65126642f05634a815b36ec23f7f3fa37c334e9 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Wed, 1 Jul 2026 20:18:38 -0700 Subject: [PATCH 2/5] address review feedback --- .../adapters/claude/session/options.test.ts | 12 +- .../src/adapters/claude/session/options.ts | 8 +- .../adapters/claude/session/settings.test.ts | 23 +++ .../src/adapters/claude/session/settings.ts | 142 +++++++------- .../sections/ClaudeCodeSettings.test.tsx | 179 ++++++++++++++++++ .../settings/sections/ClaudeCodeSettings.tsx | 53 ++++-- 6 files changed, 332 insertions(+), 85 deletions(-) create mode 100644 packages/ui/src/features/settings/sections/ClaudeCodeSettings.test.tsx diff --git a/packages/agent/src/adapters/claude/session/options.test.ts b/packages/agent/src/adapters/claude/session/options.test.ts index a0b84e32ab..fe15c0ed28 100644 --- a/packages/agent/src/adapters/claude/session/options.test.ts +++ b/packages/agent/src/adapters/claude/session/options.test.ts @@ -234,15 +234,21 @@ describe("buildSessionOptions", () => { expect(options.env?.CLAUDE_CODE_SUBAGENT_MODEL).toBe("claude-haiku-4-5"); }); - it("converts gateway model ids from settings to CLI aliases", () => { + it.each([ + ["claude-opus-4-7", "opus"], + ["claude-opus-4-8", "opus"], + ["claude-sonnet-4-6", "sonnet"], + ["claude-haiku-4-5", "claude-haiku-4-5"], + ["inherit", "inherit"], + ])("maps settings value %s to env value %s", (settingsValue, expected) => { const params = makeParams(); vi.spyOn(params.settingsManager, "getSettings").mockReturnValue({ - env: { CLAUDE_CODE_SUBAGENT_MODEL: "claude-opus-4-8" }, + env: { CLAUDE_CODE_SUBAGENT_MODEL: settingsValue }, }); const options = buildSessionOptions(params); - expect(options.env?.CLAUDE_CODE_SUBAGENT_MODEL).toBe("opus"); + expect(options.env?.CLAUDE_CODE_SUBAGENT_MODEL).toBe(expected); }); }); diff --git a/packages/agent/src/adapters/claude/session/options.ts b/packages/agent/src/adapters/claude/session/options.ts index 4ceaffdc08..c0b1453417 100644 --- a/packages/agent/src/adapters/claude/session/options.ts +++ b/packages/agent/src/adapters/claude/session/options.ts @@ -205,9 +205,11 @@ function buildEnvironment( // subagent; the merged .claude/settings.json env block (user layer written // by the Settings UI, stored as a gateway model id) or a pre-set process // env var wins over the default. - CLAUDE_CODE_SUBAGENT_MODEL: settingsSubagentModel - ? toSdkModelId(settingsSubagentModel) - : (process.env.CLAUDE_CODE_SUBAGENT_MODEL ?? DEFAULT_SUBAGENT_MODEL), + CLAUDE_CODE_SUBAGENT_MODEL: toSdkModelId( + settingsSubagentModel ?? + process.env.CLAUDE_CODE_SUBAGENT_MODEL ?? + DEFAULT_SUBAGENT_MODEL, + ), }; } diff --git a/packages/agent/src/adapters/claude/session/settings.test.ts b/packages/agent/src/adapters/claude/session/settings.test.ts index 0ec6410b86..cd9dba349d 100644 --- a/packages/agent/src/adapters/claude/session/settings.test.ts +++ b/packages/agent/src/adapters/claude/session/settings.test.ts @@ -411,4 +411,27 @@ describe("user settings env vars", () => { "{not json", ); }); + + it("serialises concurrent writes without clobbering", async () => { + await Promise.all([ + setUserSettingsEnvVar("A", "1"), + setUserSettingsEnvVar("B", "2"), + setUserSettingsEnvVar("C", "3"), + ]); + + expect(await readSettings()).toEqual({ + env: { A: "1", B: "2", C: "3" }, + }); + }); + + it("merges the written env into SettingsManager settings", async () => { + await setUserSettingsEnvVar("CLAUDE_CODE_SUBAGENT_MODEL", "sonnet"); + + const manager = new SettingsManager(configDir); + await manager.initialize(); + + expect(manager.getSettings().env?.CLAUDE_CODE_SUBAGENT_MODEL).toBe( + "sonnet", + ); + }); }); diff --git a/packages/agent/src/adapters/claude/session/settings.ts b/packages/agent/src/adapters/claude/session/settings.ts index c19a020f47..f6ad41ceee 100644 --- a/packages/agent/src/adapters/claude/session/settings.ts +++ b/packages/agent/src/adapters/claude/session/settings.ts @@ -216,6 +216,31 @@ function getUserSettingsFilePath(): string { return path.join(configDir, "settings.json"); } +/** + * Mutex-serialised read-modify-write of a settings file with an atomic + * temp-file + rename, refusing to overwrite unparseable content. Returning + * `existing` unchanged from `mutate` skips the write. + */ +async function updateSettingsFile( + mutex: AsyncMutex, + filePath: string, + mutate: (existing: ClaudeCodeSettings) => ClaudeCodeSettings, +): Promise { + await mutex.acquire(); + try { + const existing = await readSettingsFileForUpdate(filePath); + const next = mutate(existing); + if (next === existing) { + return existing; + } + await fs.promises.mkdir(path.dirname(filePath), { recursive: true }); + await writeFileAtomic(filePath, `${JSON.stringify(next, null, 2)}\n`); + return next; + } finally { + mutex.release(); + } +} + const userSettingsWriteMutex = new AsyncMutex(); export async function getUserSettingsEnvVar( @@ -230,34 +255,31 @@ export async function getUserSettingsEnvVar( * (`/settings.json`, app-scoped since the host sets * CLAUDE_CONFIG_DIR). `buildSessionOptions` reads the merged env at every * session spawn, so changes apply to new and resumed sessions without any - * per-session threading. `value === undefined` deletes the key. Mirrors - * `addAllowRules`: mutex-serialised, atomic temp-file + rename. + * per-session threading. `value === undefined` deletes the key. */ export async function setUserSettingsEnvVar( key: string, value: string | undefined, ): Promise { - await userSettingsWriteMutex.acquire(); - try { - const filePath = getUserSettingsFilePath(); - const existing = await readSettingsFileForUpdate(filePath); - const env = { ...existing.env }; - if (value === undefined) { - delete env[key]; - } else { - env[key] = value; - } - const next: ClaudeCodeSettings = { ...existing }; - if (Object.keys(env).length > 0) { - next.env = env; - } else { - delete next.env; - } - await fs.promises.mkdir(path.dirname(filePath), { recursive: true }); - await writeFileAtomic(filePath, `${JSON.stringify(next, null, 2)}\n`); - } finally { - userSettingsWriteMutex.release(); - } + await updateSettingsFile( + userSettingsWriteMutex, + getUserSettingsFilePath(), + (existing) => { + const env = { ...existing.env }; + if (value === undefined) { + delete env[key]; + } else { + env[key] = value; + } + const next: ClaudeCodeSettings = { ...existing }; + if (Object.keys(env).length > 0) { + next.env = env; + } else { + delete next.env; + } + return next; + }, + ); } export function getManagedSettingsPath(): string { @@ -468,27 +490,22 @@ export class SettingsManager { async addAllowRules(rules: PermissionRuleValue[]): Promise { if (rules.length === 0) return; if (!this.initialized) await this.initialize(); - await this.writeMutex.acquire(); - try { - const filePath = this.getLocalSettingsPath(); - const existing = await readSettingsFileForUpdate(filePath); - const permissions: PermissionSettings = { - ...(existing.permissions ?? {}), - }; - const current = new Set(permissions.allow ?? []); - for (const rule of rules) { - current.add(formatRule(rule)); - } - permissions.allow = Array.from(current); - const next: ClaudeCodeSettings = { ...existing, permissions }; - await fs.promises.mkdir(path.dirname(filePath), { recursive: true }); - await writeFileAtomic(filePath, `${JSON.stringify(next, null, 2)}\n`); - - this.localSettings = next; - this.mergeAllSettings(); - } finally { - this.writeMutex.release(); - } + this.localSettings = await updateSettingsFile( + this.writeMutex, + this.getLocalSettingsPath(), + (existing) => { + const permissions: PermissionSettings = { + ...(existing.permissions ?? {}), + }; + const current = new Set(permissions.allow ?? []); + for (const rule of rules) { + current.add(formatRule(rule)); + } + permissions.allow = Array.from(current); + return { ...existing, permissions }; + }, + ); + this.mergeAllSettings(); } hasPostHogExecApproval(subTool: string): boolean { @@ -505,27 +522,22 @@ export class SettingsManager { async addPostHogExecApproval(subTool: string): Promise { if (!subTool) return; if (!this.initialized) await this.initialize(); - await this.writeMutex.acquire(); - try { - const filePath = this.getLocalSettingsPath(); - const existing = await readSettingsFileForUpdate(filePath); - const current = new Set(existing.posthogApprovedExecTools ?? []); - if (current.has(subTool)) { - return; - } - current.add(subTool); - const next: ClaudeCodeSettings = { - ...existing, - posthogApprovedExecTools: Array.from(current), - }; - await fs.promises.mkdir(path.dirname(filePath), { recursive: true }); - await writeFileAtomic(filePath, `${JSON.stringify(next, null, 2)}\n`); - - this.localSettings = next; - this.mergeAllSettings(); - } finally { - this.writeMutex.release(); - } + this.localSettings = await updateSettingsFile( + this.writeMutex, + this.getLocalSettingsPath(), + (existing) => { + const current = new Set(existing.posthogApprovedExecTools ?? []); + if (current.has(subTool)) { + return existing; + } + current.add(subTool); + return { + ...existing, + posthogApprovedExecTools: Array.from(current), + }; + }, + ); + this.mergeAllSettings(); } async setCwd(cwd: string): Promise { diff --git a/packages/ui/src/features/settings/sections/ClaudeCodeSettings.test.tsx b/packages/ui/src/features/settings/sections/ClaudeCodeSettings.test.tsx new file mode 100644 index 0000000000..06aaf36833 --- /dev/null +++ b/packages/ui/src/features/settings/sections/ClaudeCodeSettings.test.tsx @@ -0,0 +1,179 @@ +import { Theme } from "@radix-ui/themes"; +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + unobserve() {} + disconnect() {} + }, +); + +const mutateMock = vi.fn(); + +let subagentModelResult: { + data: string | null | undefined; + isLoading: boolean; +}; +let previewConfigResult: { data: unknown }; +let mutationResult: { + mutate: typeof mutateMock; + isPending: boolean; + isError: boolean; +}; +let glmEnabled: boolean; + +vi.mock("@posthog/host-router/react", () => ({ + useHostTRPC: () => ({ + agent: { + getSubagentModel: { + queryOptions: () => ({ queryKey: ["subagentModel"] }), + }, + getPreviewConfigOptions: { + queryOptions: () => ({ queryKey: ["previewConfig"] }), + }, + setSubagentModel: { mutationOptions: () => ({}) }, + }, + }), +})); + +vi.mock("@tanstack/react-query", () => ({ + useQuery: (options: { queryKey?: string[] }) => + options.queryKey?.[0] === "subagentModel" + ? subagentModelResult + : previewConfigResult, + useMutation: () => mutationResult, + useQueryClient: () => ({ + cancelQueries: vi.fn(), + getQueryData: vi.fn(), + setQueryData: vi.fn(), + invalidateQueries: vi.fn(), + }), +})); + +vi.mock("@posthog/ui/features/auth/store", () => ({ + useAuthStateValue: (selector: (state: { cloudRegion: string }) => unknown) => + selector({ cloudRegion: "us" }), +})); + +vi.mock("@posthog/ui/features/feature-flags/useFeatureFlag", () => ({ + useFeatureFlag: () => glmEnabled, +})); + +vi.mock("@posthog/ui/features/settings/settingsStore", () => ({ + useSettingsStore: () => ({ + allowBypassPermissions: false, + setAllowBypassPermissions: vi.fn(), + }), +})); + +vi.mock("@posthog/ui/features/settings/sections/PermissionsSettings", () => ({ + PermissionsSettings: () => null, +})); + +vi.mock("@posthog/ui/shell/analytics", () => ({ + track: vi.fn(), +})); + +import { SubagentModelSetting } from "./ClaudeCodeSettings"; + +const CATALOG_OPTIONS = [ + { + id: "model", + name: "Model", + type: "select", + category: "model", + currentValue: "claude-opus-4-8", + options: [ + { value: "claude-opus-4-8", name: "Claude Opus 4.8" }, + { value: "claude-sonnet-5", name: "Claude Sonnet 5" }, + { value: "glm-5", name: "GLM 5" }, + ], + }, +]; + +function renderSetting() { + return render( + + + , + ); +} + +describe("SubagentModelSetting", () => { + beforeEach(() => { + vi.clearAllMocks(); + glmEnabled = false; + subagentModelResult = { data: null, isLoading: false }; + previewConfigResult = { data: CATALOG_OPTIONS }; + mutationResult = { mutate: mutateMock, isPending: false, isError: false }; + }); + + it("shows the sonnet default when nothing is stored", () => { + renderSetting(); + + expect(screen.getByRole("combobox")).toHaveTextContent("Sonnet (default)"); + }); + + it("shows the inherit choice when stored", () => { + subagentModelResult = { data: "inherit", isLoading: false }; + + renderSetting(); + + expect(screen.getByRole("combobox")).toHaveTextContent( + "Inherit main model", + ); + }); + + it("shows a catalog model by display name when stored", () => { + subagentModelResult = { data: "claude-sonnet-5", isLoading: false }; + + renderSetting(); + + expect(screen.getByRole("combobox")).toHaveTextContent("Claude Sonnet 5"); + }); + + it("falls back to the raw stored value when it is not in the catalog", () => { + subagentModelResult = { data: "sonnet", isLoading: false }; + + renderSetting(); + + expect(screen.getByRole("combobox")).toHaveTextContent("sonnet"); + }); + + it("disables the select while the stored value is loading", () => { + subagentModelResult = { data: undefined, isLoading: true }; + + renderSetting(); + + expect(screen.getByRole("combobox")).toBeDisabled(); + }); + + it("disables the select while a mutation is in flight", () => { + mutationResult = { mutate: mutateMock, isPending: true, isError: false }; + + renderSetting(); + + expect(screen.getByRole("combobox")).toBeDisabled(); + }); + + it("surfaces a save failure", () => { + mutationResult = { mutate: mutateMock, isPending: false, isError: true }; + + renderSetting(); + + expect( + screen.getByText("Failed to save subagent model"), + ).toBeInTheDocument(); + }); + + it("renders without a model catalog when the preview query has no data", () => { + previewConfigResult = { data: undefined }; + + renderSetting(); + + expect(screen.getByRole("combobox")).toHaveTextContent("Sonnet (default)"); + }); +}); diff --git a/packages/ui/src/features/settings/sections/ClaudeCodeSettings.tsx b/packages/ui/src/features/settings/sections/ClaudeCodeSettings.tsx index 3c41ded1d3..ef0bff3a2f 100644 --- a/packages/ui/src/features/settings/sections/ClaudeCodeSettings.tsx +++ b/packages/ui/src/features/settings/sections/ClaudeCodeSettings.tsx @@ -25,7 +25,7 @@ import { Switch, Text, } from "@radix-ui/themes"; -import { useMutation, useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useCallback, useState } from "react"; function CopyableCommand({ command }: { command: string }) { @@ -83,16 +83,19 @@ function SettingDescription({ const SUBAGENT_MODEL_DEFAULT_VALUE = "__default__"; const SUBAGENT_MODEL_INHERIT_VALUE = "inherit"; -function SubagentModelSetting() { +export function SubagentModelSetting() { const hostTRPC = useHostTRPC(); const glmEnabled = useFeatureFlag(GLM_MODEL_FLAG); const cloudRegion = useAuthStateValue((state) => state.cloudRegion); const apiHost = cloudRegion ? getCloudUrlFromRegion(cloudRegion) : null; - const subagentModel = useQuery( - hostTRPC.agent.getSubagentModel.queryOptions(), + const queryClient = useQueryClient(); + const subagentModelQueryOptions = + hostTRPC.agent.getSubagentModel.queryOptions(); + const { data: storedModelData, isLoading: isStoredModelLoading } = useQuery( + subagentModelQueryOptions, ); - const previewConfig = useQuery({ + const { data: previewConfigOptions } = useQuery({ ...hostTRPC.agent.getPreviewConfigOptions.queryOptions({ apiHost: apiHost ?? "", adapter: "claude", @@ -100,10 +103,34 @@ function SubagentModelSetting() { enabled: apiHost !== null, }); const setSubagentModel = useMutation( - hostTRPC.agent.setSubagentModel.mutationOptions(), + hostTRPC.agent.setSubagentModel.mutationOptions({ + onMutate: async (input) => { + await queryClient.cancelQueries({ + queryKey: subagentModelQueryOptions.queryKey, + }); + const previous = queryClient.getQueryData( + subagentModelQueryOptions.queryKey, + ); + queryClient.setQueryData( + subagentModelQueryOptions.queryKey, + input.model, + ); + return { previous }; + }, + onError: (_error, _input, context) => { + queryClient.setQueryData( + subagentModelQueryOptions.queryKey, + context?.previous ?? null, + ); + }, + onSettled: () => + queryClient.invalidateQueries({ + queryKey: subagentModelQueryOptions.queryKey, + }), + }), ); - const rawModelOption = previewConfig.data?.find( + const rawModelOption = previewConfigOptions?.find( (option) => option.category === "model" && option.type === "select", ); const modelOption = @@ -117,13 +144,14 @@ function SubagentModelSetting() { ? flattenSelectOptions(modelOption.options) : []; - const storedModel = subagentModel.data ?? null; + const storedModel = storedModelData ?? null; const selectValue = storedModel ?? SUBAGENT_MODEL_DEFAULT_VALUE; const storedModelHasItem = !storedModel || storedModel === SUBAGENT_MODEL_INHERIT_VALUE || modelItems.some((item) => item.value === storedModel); + const { mutate: mutateSubagentModel } = setSubagentModel; const handleChange = useCallback( (value: string) => { const next = value === SUBAGENT_MODEL_DEFAULT_VALUE ? null : value; @@ -132,12 +160,9 @@ function SubagentModelSetting() { new_value: next ?? "default", old_value: storedModel ?? "default", }); - setSubagentModel.mutate( - { model: next }, - { onSettled: () => subagentModel.refetch() }, - ); + mutateSubagentModel({ model: next }); }, - [storedModel, setSubagentModel, subagentModel], + [storedModel, mutateSubagentModel], ); return ( @@ -151,7 +176,7 @@ function SubagentModelSetting() { value={selectValue} onValueChange={handleChange} size="1" - disabled={subagentModel.isLoading} + disabled={isStoredModelLoading || setSubagentModel.isPending} > From bab8fa7235638a2a84ab76eade5cc1effbd6dc51 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Wed, 1 Jul 2026 21:17:10 -0700 Subject: [PATCH 3/5] store subagent model in app settings --- .../electron-workspace-settings.ts | 10 ++ apps/code/src/main/services/settingsStore.ts | 14 +++ packages/agent/package.json | 4 - .../agent/src/adapters/claude/claude-agent.ts | 1 + .../adapters/claude/session/options.test.ts | 42 +++++--- .../src/adapters/claude/session/options.ts | 16 +-- .../adapters/claude/session/settings.test.ts | 101 +----------------- .../src/adapters/claude/session/settings.ts | 41 ------- packages/agent/src/adapters/claude/types.ts | 2 + packages/agent/tsup.config.ts | 1 - packages/platform/src/workspace-settings.ts | 2 + .../src/services/agent/agent.test.ts | 57 +++++----- .../src/services/agent/agent.ts | 19 ++-- 13 files changed, 113 insertions(+), 197 deletions(-) diff --git a/apps/code/src/main/platform-adapters/electron-workspace-settings.ts b/apps/code/src/main/platform-adapters/electron-workspace-settings.ts index 4769b46f1d..16d2993b20 100644 --- a/apps/code/src/main/platform-adapters/electron-workspace-settings.ts +++ b/apps/code/src/main/platform-adapters/electron-workspace-settings.ts @@ -6,11 +6,13 @@ import { getAutoSuspendEnabled, getMaxActiveWorktrees, getPreventSleepWhileRunning, + getSubagentModel, getWorktreeLocation, setAutoSuspendAfterDays, setAutoSuspendEnabled, setMaxActiveWorktrees, setPreventSleepWhileRunning, + setSubagentModel, setWorktreeLocation, } from "../services/settingsStore"; @@ -59,4 +61,12 @@ export class ElectronWorkspaceSettings implements IWorkspaceSettings { setPreventSleepWhileRunning(value: boolean): void { setPreventSleepWhileRunning(value); } + + getSubagentModel(): string | null { + return getSubagentModel(); + } + + setSubagentModel(model: string | null): void { + setSubagentModel(model); + } } diff --git a/apps/code/src/main/services/settingsStore.ts b/apps/code/src/main/services/settingsStore.ts index 4ed515d35b..79ef8464e7 100644 --- a/apps/code/src/main/services/settingsStore.ts +++ b/apps/code/src/main/services/settingsStore.ts @@ -14,6 +14,7 @@ interface SettingsSchema { discordPresenceEnabled: boolean; discordPresenceShowTaskTitle: boolean; discordPresenceShowRepoName: boolean; + subagentModel: string | null; } function getDefaultWorktreeLocation(): string { @@ -99,6 +100,10 @@ const schema = { type: "boolean" as const, default: false, }, + subagentModel: { + type: ["string", "null"] as const, + default: null, + }, }; export const settingsStore = new Store({ @@ -114,6 +119,7 @@ export const settingsStore = new Store({ discordPresenceEnabled: false, discordPresenceShowTaskTitle: false, discordPresenceShowRepoName: false, + subagentModel: null, }, }); @@ -192,3 +198,11 @@ export function getPreventSleepWhileRunning(): boolean { export function setPreventSleepWhileRunning(value: boolean): void { settingsStore.set("preventSleepWhileRunning", value); } + +export function getSubagentModel(): string | null { + return settingsStore.get("subagentModel", null); +} + +export function setSubagentModel(model: string | null): void { + settingsStore.set("subagentModel", model); +} diff --git a/packages/agent/package.json b/packages/agent/package.json index ac3bd94fae..1ccc632d6e 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -60,10 +60,6 @@ "types": "./dist/adapters/claude/session/models.d.ts", "import": "./dist/adapters/claude/session/models.js" }, - "./adapters/claude/session/settings": { - "types": "./dist/adapters/claude/session/settings.d.ts", - "import": "./dist/adapters/claude/session/settings.js" - }, "./adapters/reasoning-effort": { "types": "./dist/adapters/reasoning-effort.d.ts", "import": "./dist/adapters/reasoning-effort.js" diff --git a/packages/agent/src/adapters/claude/claude-agent.ts b/packages/agent/src/adapters/claude/claude-agent.ts index 173483077a..47ffae3b37 100644 --- a/packages/agent/src/adapters/claude/claude-agent.ts +++ b/packages/agent/src/adapters/claude/claude-agent.ts @@ -1758,6 +1758,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { onProcessSpawned: this.options?.onProcessSpawned, onProcessExited: this.options?.onProcessExited, effort, + subagentModel: meta?.subagentModel, enrichmentDeps: this.enrichment?.deps, enrichedReadCache: this.enrichedReadCache, cloudMode: cloudRun, diff --git a/packages/agent/src/adapters/claude/session/options.test.ts b/packages/agent/src/adapters/claude/session/options.test.ts index fe15c0ed28..1249a0c35a 100644 --- a/packages/agent/src/adapters/claude/session/options.test.ts +++ b/packages/agent/src/adapters/claude/session/options.test.ts @@ -222,16 +222,29 @@ describe("buildSessionOptions", () => { expect(options.env?.CLAUDE_CODE_SUBAGENT_MODEL).toBe("inherit"); }); - it("prefers the merged settings env value over process env", () => { + it("prefers the host-provided subagent model over process env", () => { process.env.CLAUDE_CODE_SUBAGENT_MODEL = "inherit"; + + const options = buildSessionOptions({ + ...makeParams(), + subagentModel: "claude-haiku-4-5", + }); + + expect(options.env?.CLAUDE_CODE_SUBAGENT_MODEL).toBe("claude-haiku-4-5"); + }); + + it("prefers the merged settings env value over the host-provided model", () => { const params = makeParams(); vi.spyOn(params.settingsManager, "getSettings").mockReturnValue({ - env: { CLAUDE_CODE_SUBAGENT_MODEL: "claude-haiku-4-5" }, + env: { CLAUDE_CODE_SUBAGENT_MODEL: "inherit" }, }); - const options = buildSessionOptions(params); + const options = buildSessionOptions({ + ...params, + subagentModel: "claude-haiku-4-5", + }); - expect(options.env?.CLAUDE_CODE_SUBAGENT_MODEL).toBe("claude-haiku-4-5"); + expect(options.env?.CLAUDE_CODE_SUBAGENT_MODEL).toBe("inherit"); }); it.each([ @@ -240,16 +253,17 @@ describe("buildSessionOptions", () => { ["claude-sonnet-4-6", "sonnet"], ["claude-haiku-4-5", "claude-haiku-4-5"], ["inherit", "inherit"], - ])("maps settings value %s to env value %s", (settingsValue, expected) => { - const params = makeParams(); - vi.spyOn(params.settingsManager, "getSettings").mockReturnValue({ - env: { CLAUDE_CODE_SUBAGENT_MODEL: settingsValue }, - }); - - const options = buildSessionOptions(params); - - expect(options.env?.CLAUDE_CODE_SUBAGENT_MODEL).toBe(expected); - }); + ])( + "maps host-provided value %s to env value %s", + (subagentModel, expected) => { + const options = buildSessionOptions({ + ...makeParams(), + subagentModel, + }); + + expect(options.env?.CLAUDE_CODE_SUBAGENT_MODEL).toBe(expected); + }, + ); }); describe("ANTHROPIC_CUSTOM_HEADERS", () => { diff --git a/packages/agent/src/adapters/claude/session/options.ts b/packages/agent/src/adapters/claude/session/options.ts index c0b1453417..c3d2f2d0f5 100644 --- a/packages/agent/src/adapters/claude/session/options.ts +++ b/packages/agent/src/adapters/claude/session/options.ts @@ -77,6 +77,8 @@ export interface BuildOptionsParams { onProcessSpawned?: (info: ProcessSpawnedInfo) => void; onProcessExited?: (pid: number) => void; effort?: EffortLevel; + /** Host-provided model for spawned subagents; gateway id or CLI alias. */ + subagentModel?: string; enrichmentDeps?: FileEnrichmentDeps; enrichedReadCache?: EnrichedReadCache; /** Records PostHog product usage from MCP exec calls (deduped, session-wide). */ @@ -141,7 +143,7 @@ function buildMcpServers( function buildEnvironment( gateway?: GatewayEnv, - settingsSubagentModel?: string, + subagentModel?: string, ): Record { // Custom HTTP headers reach the model only through the Claude CLI subprocess, // which reads them from this env var (newline-delimited `name: value` lines) @@ -202,11 +204,12 @@ function buildEnvironment( ANTHROPIC_CUSTOM_HEADERS: customHeaders, // Subagents (Task tool, workflow fan-outs) run on a cheaper model than the // main loop by default. The CLI treats this as a hard override for every - // subagent; the merged .claude/settings.json env block (user layer written - // by the Settings UI, stored as a gateway model id) or a pre-set process - // env var wins over the default. + // subagent. A hand-written env entry in the merged .claude/settings.json + // layers wins over the host-provided app setting, mirroring how the + // settings `model` beats the session meta model; a pre-set process env + // var (cloud orchestrator, dev shell) is next, then the default. CLAUDE_CODE_SUBAGENT_MODEL: toSdkModelId( - settingsSubagentModel ?? + subagentModel ?? process.env.CLAUDE_CODE_SUBAGENT_MODEL ?? DEFAULT_SUBAGENT_MODEL, ), @@ -463,7 +466,8 @@ export function buildSessionOptions(params: BuildOptionsParams): Options { ), env: buildEnvironment( params.gatewayEnv, - params.settingsManager.getSettings().env?.CLAUDE_CODE_SUBAGENT_MODEL, + params.settingsManager.getSettings().env?.CLAUDE_CODE_SUBAGENT_MODEL ?? + params.subagentModel, ), hooks: buildHooks( params.userProvidedOptions?.hooks, diff --git a/packages/agent/src/adapters/claude/session/settings.test.ts b/packages/agent/src/adapters/claude/session/settings.test.ts index cd9dba349d..90549d6b1b 100644 --- a/packages/agent/src/adapters/claude/session/settings.test.ts +++ b/packages/agent/src/adapters/claude/session/settings.test.ts @@ -4,12 +4,7 @@ import * as os from "node:os"; import * as path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { resolveMainRepoPath } from "./repo-path"; -import { - getUserSettingsEnvVar, - mergeAvailableModels, - SettingsManager, - setUserSettingsEnvVar, -} from "./settings"; +import { mergeAvailableModels, SettingsManager } from "./settings"; function runGit(cwd: string, args: string[]): void { execFileSync("git", args, { cwd, stdio: ["ignore", "ignore", "pipe"] }); @@ -314,15 +309,10 @@ describe("mergeAvailableModels", () => { }); }); -describe("user settings env vars", () => { +describe("user settings env merge", () => { let configDir: string; let originalConfigDir: string | undefined; - const settingsPath = () => path.join(configDir, "settings.json"); - - const readSettings = async () => - JSON.parse(await fs.promises.readFile(settingsPath(), "utf-8")); - beforeEach(async () => { configDir = await fs.promises.mkdtemp( path.join(os.tmpdir(), "user-settings-env-"), @@ -340,92 +330,11 @@ describe("user settings env vars", () => { await fs.promises.rm(configDir, { recursive: true, force: true }); }); - it("creates the settings file when absent", async () => { - await setUserSettingsEnvVar("CLAUDE_CODE_SUBAGENT_MODEL", "sonnet"); - - expect(await readSettings()).toEqual({ - env: { CLAUDE_CODE_SUBAGENT_MODEL: "sonnet" }, - }); - }); - - it("preserves sibling settings and other env keys", async () => { + it("merges a user-layer env block into settings", async () => { await fs.promises.writeFile( - settingsPath(), - JSON.stringify({ - model: "opus", - permissions: { allow: ["Bash(ls:*)"] }, - env: { FOO: "bar" }, - }), - ); - - await setUserSettingsEnvVar( - "CLAUDE_CODE_SUBAGENT_MODEL", - "claude-haiku-4-5", - ); - - expect(await readSettings()).toEqual({ - model: "opus", - permissions: { allow: ["Bash(ls:*)"] }, - env: { FOO: "bar", CLAUDE_CODE_SUBAGENT_MODEL: "claude-haiku-4-5" }, - }); - }); - - it("deletes the key on undefined and prunes an empty env block", async () => { - await setUserSettingsEnvVar("CLAUDE_CODE_SUBAGENT_MODEL", "sonnet"); - await setUserSettingsEnvVar("CLAUDE_CODE_SUBAGENT_MODEL", undefined); - - expect(await readSettings()).toEqual({}); - }); - - it("keeps other env keys when deleting one", async () => { - await fs.promises.writeFile( - settingsPath(), - JSON.stringify({ env: { FOO: "bar", CLAUDE_CODE_SUBAGENT_MODEL: "x" } }), - ); - - await setUserSettingsEnvVar("CLAUDE_CODE_SUBAGENT_MODEL", undefined); - - expect(await readSettings()).toEqual({ env: { FOO: "bar" } }); - }); - - it("round-trips through getUserSettingsEnvVar", async () => { - expect(await getUserSettingsEnvVar("CLAUDE_CODE_SUBAGENT_MODEL")).toBe( - null, - ); - - await setUserSettingsEnvVar("CLAUDE_CODE_SUBAGENT_MODEL", "inherit"); - - expect(await getUserSettingsEnvVar("CLAUDE_CODE_SUBAGENT_MODEL")).toBe( - "inherit", - ); - }); - - it("refuses to overwrite an unparseable settings file", async () => { - await fs.promises.writeFile(settingsPath(), "{not json"); - - await expect( - setUserSettingsEnvVar("CLAUDE_CODE_SUBAGENT_MODEL", "sonnet"), - ).rejects.toThrow(); - - expect(await fs.promises.readFile(settingsPath(), "utf-8")).toBe( - "{not json", + path.join(configDir, "settings.json"), + JSON.stringify({ env: { CLAUDE_CODE_SUBAGENT_MODEL: "sonnet" } }), ); - }); - - it("serialises concurrent writes without clobbering", async () => { - await Promise.all([ - setUserSettingsEnvVar("A", "1"), - setUserSettingsEnvVar("B", "2"), - setUserSettingsEnvVar("C", "3"), - ]); - - expect(await readSettings()).toEqual({ - env: { A: "1", B: "2", C: "3" }, - }); - }); - - it("merges the written env into SettingsManager settings", async () => { - await setUserSettingsEnvVar("CLAUDE_CODE_SUBAGENT_MODEL", "sonnet"); const manager = new SettingsManager(configDir); await manager.initialize(); diff --git a/packages/agent/src/adapters/claude/session/settings.ts b/packages/agent/src/adapters/claude/session/settings.ts index f6ad41ceee..e3c1b76147 100644 --- a/packages/agent/src/adapters/claude/session/settings.ts +++ b/packages/agent/src/adapters/claude/session/settings.ts @@ -241,47 +241,6 @@ async function updateSettingsFile( } } -const userSettingsWriteMutex = new AsyncMutex(); - -export async function getUserSettingsEnvVar( - key: string, -): Promise { - const settings = await loadSettingsFile(getUserSettingsFilePath()); - return settings.env?.[key] ?? null; -} - -/** - * Sets or clears one key in the `env` block of the user-layer settings.json - * (`/settings.json`, app-scoped since the host sets - * CLAUDE_CONFIG_DIR). `buildSessionOptions` reads the merged env at every - * session spawn, so changes apply to new and resumed sessions without any - * per-session threading. `value === undefined` deletes the key. - */ -export async function setUserSettingsEnvVar( - key: string, - value: string | undefined, -): Promise { - await updateSettingsFile( - userSettingsWriteMutex, - getUserSettingsFilePath(), - (existing) => { - const env = { ...existing.env }; - if (value === undefined) { - delete env[key]; - } else { - env[key] = value; - } - const next: ClaudeCodeSettings = { ...existing }; - if (Object.keys(env).length > 0) { - next.env = env; - } else { - delete next.env; - } - return next; - }, - ); -} - export function getManagedSettingsPath(): string { switch (process.platform) { case "darwin": diff --git a/packages/agent/src/adapters/claude/types.ts b/packages/agent/src/adapters/claude/types.ts index 3c1973125a..7709bf4eca 100644 --- a/packages/agent/src/adapters/claude/types.ts +++ b/packages/agent/src/adapters/claude/types.ts @@ -167,6 +167,8 @@ export type NewSessionMeta = { allowedDomains?: string[]; /** Model ID to use for this session (e.g. "claude-sonnet-4-6") */ model?: string; + /** Model for spawned subagents (Task tool, workflow fan-outs); gateway id or CLI alias. */ + subagentModel?: string; /** Base branch of the task's repo (e.g. "master"), for the signed-git tools. */ baseBranch?: string; /** diff --git a/packages/agent/tsup.config.ts b/packages/agent/tsup.config.ts index 513b70753d..bb5335eb1f 100644 --- a/packages/agent/tsup.config.ts +++ b/packages/agent/tsup.config.ts @@ -120,7 +120,6 @@ export default defineConfig([ "src/adapters/claude/conversion/tool-use-to-acp.ts", "src/adapters/claude/session/jsonl-hydration.ts", "src/adapters/claude/session/models.ts", - "src/adapters/claude/session/settings.ts", "src/adapters/codex/models.ts", "src/adapters/claude/mcp/tool-metadata.ts", "src/adapters/codex/structured-output-mcp-server.ts", diff --git a/packages/platform/src/workspace-settings.ts b/packages/platform/src/workspace-settings.ts index 075b59481c..64372d15e3 100644 --- a/packages/platform/src/workspace-settings.ts +++ b/packages/platform/src/workspace-settings.ts @@ -10,6 +10,8 @@ export interface IWorkspaceSettings { setAutoSuspendAfterDays(value: number): void; getPreventSleepWhileRunning(): boolean; setPreventSleepWhileRunning(value: boolean): void; + getSubagentModel(): string | null; + setSubagentModel(model: string | null): void; } export const WORKSPACE_SETTINGS_SERVICE = Symbol.for( diff --git a/packages/workspace-server/src/services/agent/agent.test.ts b/packages/workspace-server/src/services/agent/agent.test.ts index ea3e346e9e..09a2e163ab 100644 --- a/packages/workspace-server/src/services/agent/agent.test.ts +++ b/packages/workspace-server/src/services/agent/agent.test.ts @@ -2,9 +2,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; // --- Hoisted mocks --- -const mockGetUserSettingsEnvVar = vi.hoisted(() => vi.fn()); -const mockSetUserSettingsEnvVar = vi.hoisted(() => vi.fn()); - const mockApp = vi.hoisted(() => ({ getAppPath: vi.fn(() => "/mock/appPath"), isPackaged: false, @@ -85,11 +82,6 @@ vi.mock("@posthog/agent/adapters/claude/session/jsonl-hydration", () => ({ hydrateSessionJsonl: vi.fn().mockResolvedValue(undefined), })); -vi.mock("@posthog/agent/adapters/claude/session/settings", () => ({ - getUserSettingsEnvVar: mockGetUserSettingsEnvVar, - setUserSettingsEnvVar: mockSetUserSettingsEnvVar, -})); - vi.mock("node:fs", async (importOriginal) => { const original = await importOriginal(); return { @@ -184,6 +176,8 @@ function createMockDependencies() { }, workspaceSettings: { getWorktreeLocation: () => "/mock/worktrees", + getSubagentModel: vi.fn(() => null as string | null), + setSubagentModel: vi.fn(), }, foldersService: { getFolders: vi.fn().mockResolvedValue([]), @@ -244,33 +238,48 @@ describe("AgentService", () => { }); describe("subagent model", () => { - it("reads the persisted value from user settings", async () => { - mockGetUserSettingsEnvVar.mockResolvedValue("claude-haiku-4-5"); - - await expect(service.getSubagentModel()).resolves.toBe( + it("reads the value from workspace settings", () => { + deps.workspaceSettings.getSubagentModel.mockReturnValue( "claude-haiku-4-5", ); - expect(mockGetUserSettingsEnvVar).toHaveBeenCalledWith( - "CLAUDE_CODE_SUBAGENT_MODEL", - ); + + expect(service.getSubagentModel()).toBe("claude-haiku-4-5"); }); - it("persists the chosen model", async () => { - await service.setSubagentModel("claude-sonnet-5"); + it("persists the chosen model to workspace settings", () => { + service.setSubagentModel("claude-sonnet-5"); - expect(mockSetUserSettingsEnvVar).toHaveBeenCalledWith( - "CLAUDE_CODE_SUBAGENT_MODEL", + expect(deps.workspaceSettings.setSubagentModel).toHaveBeenCalledWith( "claude-sonnet-5", ); }); - it("clears the key when set to null", async () => { - await service.setSubagentModel(null); + it("clears the setting on null", () => { + service.setSubagentModel(null); - expect(mockSetUserSettingsEnvVar).toHaveBeenCalledWith( - "CLAUDE_CODE_SUBAGENT_MODEL", - undefined, + expect(deps.workspaceSettings.setSubagentModel).toHaveBeenCalledWith( + null, + ); + }); + + it("threads the stored model into session _meta", async () => { + deps.workspaceSettings.getSubagentModel.mockReturnValue( + "claude-haiku-4-5", ); + + await service.startSession({ ...baseSessionParams, adapter: "codex" }); + + expect(mockNewSession.mock.calls[0][0]._meta).toMatchObject({ + subagentModel: "claude-haiku-4-5", + }); + }); + + it("omits subagentModel from _meta when unset", async () => { + await service.startSession({ ...baseSessionParams, adapter: "codex" }); + + expect( + mockNewSession.mock.calls[0][0]._meta.subagentModel, + ).toBeUndefined(); }); }); diff --git a/packages/workspace-server/src/services/agent/agent.ts b/packages/workspace-server/src/services/agent/agent.ts index ca2eea8b63..c3c403f732 100644 --- a/packages/workspace-server/src/services/agent/agent.ts +++ b/packages/workspace-server/src/services/agent/agent.ts @@ -19,10 +19,6 @@ import { } from "@posthog/agent"; import type { McpToolApprovals } from "@posthog/agent/adapters/claude/mcp/tool-metadata"; import { hydrateSessionJsonl } from "@posthog/agent/adapters/claude/session/jsonl-hydration"; -import { - getUserSettingsEnvVar, - setUserSettingsEnvVar, -} from "@posthog/agent/adapters/claude/session/settings"; import { getReasoningEffortOptions } from "@posthog/agent/adapters/reasoning-effort"; import { Agent } from "@posthog/agent/agent"; import { @@ -909,6 +905,7 @@ If a repository IS genuinely required, attach one in this priority order: plugins, disallowedTools, }); + const subagentModel = this.workspaceSettings.getSubagentModel(); let configOptions: SessionConfigOption[] | undefined; let agentSessionId: string | undefined; @@ -936,6 +933,7 @@ If a repository IS genuinely required, attach one in this priority order: mcpToolApprovals: toolApprovals, ...(permissionMode && { permissionMode }), ...(model != null && { model }), + ...(subagentModel != null && { subagentModel }), ...(jsonSchema && { jsonSchema }), claudeCode: { options: claudeCodeOptions, @@ -1008,6 +1006,7 @@ If a repository IS genuinely required, attach one in this priority order: mcpToolApprovals: toolApprovals, ...(permissionMode && { permissionMode }), ...(model != null && { model }), + ...(subagentModel != null && { subagentModel }), ...(jsonSchema && { jsonSchema }), claudeCode: { options: claudeCodeOptions, @@ -1034,6 +1033,7 @@ If a repository IS genuinely required, attach one in this priority order: mcpToolApprovals: toolApprovals, ...(permissionMode && { permissionMode }), ...(model != null && { model }), + ...(subagentModel != null && { subagentModel }), ...(jsonSchema && { jsonSchema }), claudeCode: { options: claudeCodeOptions, @@ -2131,15 +2131,12 @@ For git operations while detached: }); } - async getSubagentModel(): Promise { - return getUserSettingsEnvVar("CLAUDE_CODE_SUBAGENT_MODEL"); + getSubagentModel(): string | null { + return this.workspaceSettings.getSubagentModel(); } - async setSubagentModel(model: string | null): Promise { - await setUserSettingsEnvVar( - "CLAUDE_CODE_SUBAGENT_MODEL", - model ?? undefined, - ); + setSubagentModel(model: string | null): void { + this.workspaceSettings.setSubagentModel(model); } async getPreviewConfigOptions( From 684241b68f0545c5bccb7b5dd4b032de6987338c Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Wed, 1 Jul 2026 21:26:54 -0700 Subject: [PATCH 4/5] remove comment blocks --- packages/agent/src/adapters/claude/session/models.ts | 2 -- packages/agent/src/adapters/claude/session/options.ts | 6 ------ packages/agent/src/adapters/claude/session/settings.ts | 5 ----- 3 files changed, 13 deletions(-) diff --git a/packages/agent/src/adapters/claude/session/models.ts b/packages/agent/src/adapters/claude/session/models.ts index 685de02ae9..6e05527cf3 100644 --- a/packages/agent/src/adapters/claude/session/models.ts +++ b/packages/agent/src/adapters/claude/session/models.ts @@ -6,8 +6,6 @@ export const DEFAULT_MODEL = "opus"; // at spawn, so this must stay distinct from the alias form used for DEFAULT_MODEL. export const FALLBACK_MODEL = "claude-opus-4-8"; -// Task-tool subagents and workflow fan-out agents default to a cheaper tier -// than the main loop, injected as CLAUDE_CODE_SUBAGENT_MODEL at CLI spawn. export const DEFAULT_SUBAGENT_MODEL = "sonnet"; // Default thinking level when the user hasn't picked one. Adaptive-only models diff --git a/packages/agent/src/adapters/claude/session/options.ts b/packages/agent/src/adapters/claude/session/options.ts index c3d2f2d0f5..c766eeb35c 100644 --- a/packages/agent/src/adapters/claude/session/options.ts +++ b/packages/agent/src/adapters/claude/session/options.ts @@ -202,12 +202,6 @@ function buildEnvironment( MCP_CONNECTION_NONBLOCKING: mcpNonblocking, }), ANTHROPIC_CUSTOM_HEADERS: customHeaders, - // Subagents (Task tool, workflow fan-outs) run on a cheaper model than the - // main loop by default. The CLI treats this as a hard override for every - // subagent. A hand-written env entry in the merged .claude/settings.json - // layers wins over the host-provided app setting, mirroring how the - // settings `model` beats the session meta model; a pre-set process env - // var (cloud orchestrator, dev shell) is next, then the default. CLAUDE_CODE_SUBAGENT_MODEL: toSdkModelId( subagentModel ?? process.env.CLAUDE_CODE_SUBAGENT_MODEL ?? diff --git a/packages/agent/src/adapters/claude/session/settings.ts b/packages/agent/src/adapters/claude/session/settings.ts index e3c1b76147..0229ced304 100644 --- a/packages/agent/src/adapters/claude/session/settings.ts +++ b/packages/agent/src/adapters/claude/session/settings.ts @@ -216,11 +216,6 @@ function getUserSettingsFilePath(): string { return path.join(configDir, "settings.json"); } -/** - * Mutex-serialised read-modify-write of a settings file with an atomic - * temp-file + rename, refusing to overwrite unparseable content. Returning - * `existing` unchanged from `mutate` skips the write. - */ async function updateSettingsFile( mutex: AsyncMutex, filePath: string, From 9a06e92cbd14eee0920b238d8cef4a83ee2b6c0b Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Wed, 1 Jul 2026 21:34:21 -0700 Subject: [PATCH 5/5] revert settings write consolidation --- .../src/adapters/claude/session/settings.ts | 104 ++++++++---------- 1 file changed, 45 insertions(+), 59 deletions(-) diff --git a/packages/agent/src/adapters/claude/session/settings.ts b/packages/agent/src/adapters/claude/session/settings.ts index 0229ced304..b4aa9ab6ed 100644 --- a/packages/agent/src/adapters/claude/session/settings.ts +++ b/packages/agent/src/adapters/claude/session/settings.ts @@ -210,32 +210,6 @@ export interface PermissionCheckResult { source?: "allow" | "deny" | "ask"; } -function getUserSettingsFilePath(): string { - const configDir = - process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude"); - return path.join(configDir, "settings.json"); -} - -async function updateSettingsFile( - mutex: AsyncMutex, - filePath: string, - mutate: (existing: ClaudeCodeSettings) => ClaudeCodeSettings, -): Promise { - await mutex.acquire(); - try { - const existing = await readSettingsFileForUpdate(filePath); - const next = mutate(existing); - if (next === existing) { - return existing; - } - await fs.promises.mkdir(path.dirname(filePath), { recursive: true }); - await writeFileAtomic(filePath, `${JSON.stringify(next, null, 2)}\n`); - return next; - } finally { - mutex.release(); - } -} - export function getManagedSettingsPath(): string { switch (process.platform) { case "darwin": @@ -293,7 +267,9 @@ export class SettingsManager { } private getUserSettingsPath(): string { - return getUserSettingsFilePath(); + const configDir = + process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude"); + return path.join(configDir, "settings.json"); } private getProjectSettingsPath(): string { @@ -444,22 +420,27 @@ export class SettingsManager { async addAllowRules(rules: PermissionRuleValue[]): Promise { if (rules.length === 0) return; if (!this.initialized) await this.initialize(); - this.localSettings = await updateSettingsFile( - this.writeMutex, - this.getLocalSettingsPath(), - (existing) => { - const permissions: PermissionSettings = { - ...(existing.permissions ?? {}), - }; - const current = new Set(permissions.allow ?? []); - for (const rule of rules) { - current.add(formatRule(rule)); - } - permissions.allow = Array.from(current); - return { ...existing, permissions }; - }, - ); - this.mergeAllSettings(); + await this.writeMutex.acquire(); + try { + const filePath = this.getLocalSettingsPath(); + const existing = await readSettingsFileForUpdate(filePath); + const permissions: PermissionSettings = { + ...(existing.permissions ?? {}), + }; + const current = new Set(permissions.allow ?? []); + for (const rule of rules) { + current.add(formatRule(rule)); + } + permissions.allow = Array.from(current); + const next: ClaudeCodeSettings = { ...existing, permissions }; + await fs.promises.mkdir(path.dirname(filePath), { recursive: true }); + await writeFileAtomic(filePath, `${JSON.stringify(next, null, 2)}\n`); + + this.localSettings = next; + this.mergeAllSettings(); + } finally { + this.writeMutex.release(); + } } hasPostHogExecApproval(subTool: string): boolean { @@ -476,22 +457,27 @@ export class SettingsManager { async addPostHogExecApproval(subTool: string): Promise { if (!subTool) return; if (!this.initialized) await this.initialize(); - this.localSettings = await updateSettingsFile( - this.writeMutex, - this.getLocalSettingsPath(), - (existing) => { - const current = new Set(existing.posthogApprovedExecTools ?? []); - if (current.has(subTool)) { - return existing; - } - current.add(subTool); - return { - ...existing, - posthogApprovedExecTools: Array.from(current), - }; - }, - ); - this.mergeAllSettings(); + await this.writeMutex.acquire(); + try { + const filePath = this.getLocalSettingsPath(); + const existing = await readSettingsFileForUpdate(filePath); + const current = new Set(existing.posthogApprovedExecTools ?? []); + if (current.has(subTool)) { + return; + } + current.add(subTool); + const next: ClaudeCodeSettings = { + ...existing, + posthogApprovedExecTools: Array.from(current), + }; + await fs.promises.mkdir(path.dirname(filePath), { recursive: true }); + await writeFileAtomic(filePath, `${JSON.stringify(next, null, 2)}\n`); + + this.localSettings = next; + this.mergeAllSettings(); + } finally { + this.writeMutex.release(); + } } async setCwd(cwd: string): Promise {