From d9e0d9ff0b219549389755341c9ebec0ee615265 Mon Sep 17 00:00:00 2001 From: Serhii Vecherenko Date: Mon, 15 Jun 2026 02:45:30 -0700 Subject: [PATCH] feat(claude): support Claude profile env and model editing - Add settings UI/model for encrypted env rows and profile-specific model presets - Persist Claude profile environment updates via IPC and shared settings files - Merge profile env into Claude spawns and share effort tiers/capabilities --- src/main/ipc/localHandlers.ts | 37 +- src/main/sharedSettingsFile.test.ts | 97 +++- src/main/sharedSettingsFile.ts | 49 ++ .../views/SettingsOverlay/SettingsOverlay.tsx | 7 +- .../parts/ClaudeProfileSettings.test.tsx | 194 ++++++- .../parts/ClaudeProfileSettings.tsx | 530 +++++++++++++++--- .../parts/ClaudeProfileSettingsModel.test.ts | 52 ++ .../parts/ClaudeProfileSettingsModel.ts | 171 ++++++ .../parts/SingleAgentSettings.test.tsx | 25 +- .../parts/SingleAgentSettings.tsx | 32 +- src/shared/agents/claudeEfforts.ts | 9 + src/shared/contracts/agentInstance.ts | 37 ++ src/shared/ipc/procedureMap.ts | 1 + src/shared/ipc/procedures/settings.ts | 10 + src/shared/secretFormat.ts | 14 + src/shared/secretStorage.ts | 11 +- src/supervisor/agents/claude/claude.test.ts | 122 ++++ src/supervisor/agents/claude/detection.ts | 3 +- src/supervisor/agents/claude/index.ts | 121 +++- src/supervisor/agents/claude/probe.ts | 3 +- 20 files changed, 1399 insertions(+), 126 deletions(-) create mode 100644 src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettingsModel.test.ts create mode 100644 src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettingsModel.ts create mode 100644 src/shared/agents/claudeEfforts.ts create mode 100644 src/shared/secretFormat.ts diff --git a/src/main/ipc/localHandlers.ts b/src/main/ipc/localHandlers.ts index 6f8270a7..c24f5378 100644 --- a/src/main/ipc/localHandlers.ts +++ b/src/main/ipc/localHandlers.ts @@ -1,4 +1,5 @@ import { homedir } from "node:os"; +import { dirname } from "node:path"; import { clipboard, dialog, nativeImage, shell, type BrowserWindow } from "electron"; import type { BrowserPanelManager } from "../browser"; import { openMicrophoneSettings } from "../browser/permissions"; @@ -28,7 +29,11 @@ import { saveHandoffContextFile, } from "../attachments/localFiles"; import { createProjectDirectory } from "../projectDirectory"; -import { readSharedSettingsFile, writeSharedSettingsFile } from "../sharedSettingsFile"; +import { + applyClaudeProfileEnvironment, + readSharedSettingsFile, + writeSharedSettingsFile, +} from "../sharedSettingsFile"; import { readKeybindingsFile } from "../keybindingsFile"; import type { AutoUpdaterController } from "../updates/autoUpdater"; import { @@ -36,6 +41,7 @@ import { type MainLocalIpcHandlerMap, type WindowChromePayload, } from "@/shared/ipc"; +import type { AgentInstanceConfig } from "@/shared/contracts"; import type { LightcodePaths } from "@/shared/lightcodePaths"; import { UsageLoginManager } from "../usageLogin/UsageLoginManager"; @@ -141,9 +147,21 @@ export function createLocalIpcHandlers( // doesn't clobber writes made out-of-band by the supervisor. const onDisk = readSharedSettingsFile(settingsPath); const rendererManagedInstances = Object.fromEntries( - Object.entries(settings.agentInstances).filter( - ([, instance]) => instance.driver !== "acp-generic", - ), + Object.entries(settings.agentInstances) + .filter(([, instance]) => instance.driver !== "acp-generic") + .map(([id, instance]): [string, AgentInstanceConfig] => { + // A Claude profile's `environment` is owned by the encrypting + // `setClaudeProfileEnvironment` path. Pin it to disk so the + // renderer's plaintext-capable persist cycle can never write a + // secret in the clear or clear a saved one. Other drivers keep + // their existing renderer-managed behavior. + if (instance.driver !== "claude") return [id, instance]; + const onDiskEnv = onDisk.agentInstances[id]?.environment; + const next: AgentInstanceConfig = { ...instance }; + if (onDiskEnv) next.environment = onDiskEnv; + else delete next.environment; + return [id, next]; + }), ); const supervisorManagedInstances = Object.fromEntries( Object.entries(onDisk.agentInstances).filter( @@ -162,6 +180,17 @@ export function createLocalIpcHandlers( options.updatePowerSaveBlocker(); options.onSharedSettingsChanged?.(); }, + setClaudeProfileEnvironment: (payload) => { + const settingsPath = options.requireLightcodePaths().settingsPath; + const { settings, instance } = applyClaudeProfileEnvironment( + readSharedSettingsFile(settingsPath), + payload, + dirname(settingsPath), + ); + writeSharedSettingsFile(settingsPath, settings); + options.onSharedSettingsChanged?.(); + return instance; + }, setWindowChrome: async (payload: WindowChromePayload) => { const mainWindow = options.getMainWindow(); if (!mainWindow) { diff --git a/src/main/sharedSettingsFile.test.ts b/src/main/sharedSettingsFile.test.ts index d29374bb..9ea6d58f 100644 --- a/src/main/sharedSettingsFile.test.ts +++ b/src/main/sharedSettingsFile.test.ts @@ -2,8 +2,14 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { defaultSharedSettings } from "@/shared/settings"; -import { readSharedSettingsFile, writeSharedSettingsFile } from "./sharedSettingsFile"; +import type { AgentInstanceConfig } from "@/shared/contracts"; +import { isEncryptedSecret } from "@/shared/secretStorage"; +import { defaultSharedSettings, type SharedSettings } from "@/shared/settings"; +import { + applyClaudeProfileEnvironment, + readSharedSettingsFile, + writeSharedSettingsFile, +} from "./sharedSettingsFile"; const tempDirs: string[] = []; @@ -330,3 +336,90 @@ describe("sharedSettingsFile", () => { }); }); }); + +describe("applyClaudeProfileEnvironment", () => { + function claudeProfileSettings(environment?: AgentInstanceConfig["environment"]): SharedSettings { + const instance: AgentInstanceConfig = { + id: "glm", + driver: "claude", + displayName: "GLM", + config: { configDir: "~/.lightcode/claude-profiles/glm" }, + ...(environment ? { environment } : {}), + }; + return { ...defaultSharedSettings, agentInstances: { glm: instance } }; + } + + it("seals sensitive values and stores non-sensitive ones as plaintext", () => { + const { settings, instance } = applyClaudeProfileEnvironment( + claudeProfileSettings(), + { + instanceId: "glm", + environment: { + ANTHROPIC_BASE_URL: { value: "https://api.z.ai/api/anthropic" }, + ANTHROPIC_AUTH_TOKEN: { value: "sk-secret-123", sensitive: true }, + }, + }, + makeTempDir(), + ); + + expect(instance.environment?.ANTHROPIC_BASE_URL).toEqual({ + value: "https://api.z.ai/api/anthropic", + }); + const token = instance.environment?.ANTHROPIC_AUTH_TOKEN; + expect(token?.sensitive).toBe(true); + expect(isEncryptedSecret(token?.value ?? "")).toBe(true); + expect(token?.value).not.toContain("sk-secret-123"); + // The returned instance is the one written into the settings map. + expect(settings.agentInstances.glm).toBe(instance); + }); + + it("round-trips an already-sealed secret without re-sealing it", () => { + const dir = makeTempDir(); + const first = applyClaudeProfileEnvironment( + claudeProfileSettings(), + { instanceId: "glm", environment: { TOKEN: { value: "plain", sensitive: true } } }, + dir, + ); + const sealed = first.instance.environment?.TOKEN?.value ?? ""; + + const second = applyClaudeProfileEnvironment( + claudeProfileSettings(), + { instanceId: "glm", environment: { TOKEN: { value: sealed, sensitive: true } } }, + dir, + ); + expect(second.instance.environment?.TOKEN?.value).toBe(sealed); + }); + + it("drops empty values and removes the environment field when all are empty", () => { + const { instance } = applyClaudeProfileEnvironment( + claudeProfileSettings({ OLD: { value: "x" } }), + { instanceId: "glm", environment: { OLD: { value: "" }, "": { value: "ignored" } } }, + makeTempDir(), + ); + expect(instance.environment).toBeUndefined(); + }); + + it("throws for a missing instance or a non-Claude driver", () => { + expect(() => + applyClaudeProfileEnvironment( + claudeProfileSettings(), + { instanceId: "nope", environment: {} }, + makeTempDir(), + ), + ).toThrow(/not found/i); + + const acpSettings: SharedSettings = { + ...defaultSharedSettings, + agentInstances: { + droid: { id: "droid", driver: "acp-generic", config: { binary: "droid" } }, + }, + }; + expect(() => + applyClaudeProfileEnvironment( + acpSettings, + { instanceId: "droid", environment: {} }, + makeTempDir(), + ), + ).toThrow(/not found/i); + }); +}); diff --git a/src/main/sharedSettingsFile.ts b/src/main/sharedSettingsFile.ts index a736b0b2..f4701a26 100644 --- a/src/main/sharedSettingsFile.ts +++ b/src/main/sharedSettingsFile.ts @@ -1,5 +1,11 @@ import { existsSync, readFileSync } from "node:fs"; import { writeFileAtomic } from "@/shared/atomicFile"; +import type { + AgentInstanceConfig, + AgentInstanceEnvVar, + SetClaudeProfileEnvironmentPayload, +} from "@/shared/contracts"; +import { encryptSecret } from "@/shared/secretStorage"; import { defaultSharedSettings, normalizeSharedSettings, @@ -25,3 +31,46 @@ export function readSharedSettingsFile(settingsPath: string): SharedSettings { export function writeSharedSettingsFile(settingsPath: string, settings: SharedSettings): void { writeFileAtomic(settingsPath, serializeSharedSettings(settings), { encoding: "utf8" }); } + +/** + * Apply a Claude profile's environment edit, sealing any `sensitive` value that + * is not already sealed. `baseDir` is the settings directory (passed through to + * the cipher). Empty values are dropped (delete semantics); an empty result + * removes the `environment` field entirely. Throws if the instance is missing + * or is not a Claude profile. Returns the next settings plus the updated + * instance (with sealed env) for the renderer store to adopt without re-reading. + */ +export function applyClaudeProfileEnvironment( + settings: SharedSettings, + payload: SetClaudeProfileEnvironmentPayload, + baseDir: string, +): { settings: SharedSettings; instance: AgentInstanceConfig } { + const instance = settings.agentInstances[payload.instanceId]; + if (!instance || instance.driver !== "claude") { + throw new Error(`Claude profile not found: ${payload.instanceId}`); + } + + const nextEnv: Record = {}; + for (const [name, variable] of Object.entries(payload.environment)) { + const key = name.trim(); + if (key.length === 0 || variable.value.length === 0) continue; + nextEnv[key] = variable.sensitive + ? { value: encryptSecret(baseDir, variable.value), sensitive: true } + : { value: variable.value }; + } + + const nextInstance: AgentInstanceConfig = { ...instance }; + if (Object.keys(nextEnv).length > 0) { + nextInstance.environment = nextEnv; + } else { + delete nextInstance.environment; + } + + return { + settings: { + ...settings, + agentInstances: { ...settings.agentInstances, [payload.instanceId]: nextInstance }, + }, + instance: nextInstance, + }; +} diff --git a/src/renderer/views/SettingsOverlay/SettingsOverlay.tsx b/src/renderer/views/SettingsOverlay/SettingsOverlay.tsx index cb5c05de..57d37678 100644 --- a/src/renderer/views/SettingsOverlay/SettingsOverlay.tsx +++ b/src/renderer/views/SettingsOverlay/SettingsOverlay.tsx @@ -56,7 +56,12 @@ function renderSection( ); } if (activeSection.startsWith("agents:")) { - return ; + return ( + onSectionChange(`agents:${kind}`)} + /> + ); } return SECTION_VIEWS[activeSection]?.() ?? null; } diff --git a/src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.test.tsx b/src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.test.tsx index 3632438c..461d86d8 100644 --- a/src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.test.tsx +++ b/src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import type { ReactNode } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { AgentInstanceConfig } from "@/shared/contracts"; @@ -8,45 +8,69 @@ const toastMock = vi.hoisted(() => ({ success: vi.fn<(message: string) => void>(), })); -vi.mock("@heroui/react", () => ({ - Button: (props: { - children?: ReactNode; - "aria-label"?: string; - isDisabled?: boolean; - onPress?: () => void; - }) => ( - - ), - toast: toastMock, -})); +vi.mock("@heroui/react", () => { + const Wrapper = (props: { children?: ReactNode }) =>
{props.children}
; + // Always render the popover content so its options are testable. + const Popover = Object.assign(Wrapper, { + Trigger: Wrapper, + Content: Wrapper, + Dialog: Wrapper, + }); + return { + Button: (props: { + children?: ReactNode; + "aria-label"?: string; + "aria-pressed"?: boolean; + isDisabled?: boolean; + onPress?: () => void; + }) => ( + + ), + Popover, + toast: toastMock, + }; +}); vi.mock("@/renderer/components/common", () => ({ Input: (props: { "aria-label"?: string; placeholder?: string; value?: string; + type?: string; onChange?: (event: { target: { value: string } }) => void; + onFocus?: () => void; + onBlur?: (event: unknown) => void; }) => ( ), })); const refreshAgentStatusesMock = vi.hoisted(() => vi.fn<() => Promise>()); +const setClaudeProfileEnvironmentMock = vi.hoisted(() => + vi.fn<(payload: unknown) => Promise>(), +); vi.mock("@/renderer/bridge", () => ({ - readBridge: () => ({ refreshAgentStatuses: refreshAgentStatusesMock }), + readBridge: () => ({ + refreshAgentStatuses: refreshAgentStatusesMock, + setClaudeProfileEnvironment: setClaudeProfileEnvironmentMock, + }), })); vi.mock("@/renderer/utils/acpRegistryAuth", () => ({ @@ -64,7 +88,17 @@ vi.mock("@/renderer/state/sharedSettingsStore", () => ({ selector(settingsState), })); -import { ClaudeProfileSettings } from "./ClaudeProfileSettings"; +import { ClaudeProfileProviderSettings, ClaudeProfileSettings } from "./ClaudeProfileSettings"; + +function claudeProfile(overrides: Partial = {}): AgentInstanceConfig { + return { + id: "glm", + driver: "claude", + displayName: "GLM", + config: { configDir: "~/.lightcode/claude-profiles/glm" }, + ...overrides, + }; +} describe("ClaudeProfileSettings", () => { beforeEach(() => { @@ -72,6 +106,7 @@ describe("ClaudeProfileSettings", () => { settingsState.setAgentInstance.mockReset(); settingsState.removeAgentInstance.mockReset(); refreshAgentStatusesMock.mockReset().mockResolvedValue(); + setClaudeProfileEnvironmentMock.mockReset().mockImplementation(async () => claudeProfile()); toastMock.success.mockReset(); toastMock.danger.mockReset(); }); @@ -144,4 +179,121 @@ describe("ClaudeProfileSettings", () => { fireEvent.click(screen.getByRole("button", { name: /add profile/i })); expect(screen.getByLabelText("New Claude profile name")).toHaveValue(""); }); + + it("opens a profile's own page from its row", () => { + settingsState.agentInstances = { glm: claudeProfile() }; + const onOpenProfile = vi.fn<(kind: string) => void>(); + render(); + + // The list does not embed the editor — env vars live on the profile page. + expect(screen.queryByLabelText("Environment variable name")).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Open GLM" })); + expect(onOpenProfile).toHaveBeenCalledWith("claude:glm"); + }); + + it("opens the new profile's page after adding", () => { + const onOpenProfile = vi.fn<(kind: string) => void>(); + render(); + fireEvent.click(screen.getByRole("button", { name: /add profile/i })); + fireEvent.change(screen.getByLabelText("New Claude profile name"), { + target: { value: "Work" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Add Claude profile" })); + + expect(onOpenProfile).toHaveBeenCalledWith("claude:work"); + }); +}); + +describe("ClaudeProfileProviderSettings", () => { + beforeEach(() => { + settingsState.agentInstances = { glm: claudeProfile() }; + settingsState.setAgentInstance.mockReset(); + refreshAgentStatusesMock.mockReset().mockResolvedValue(); + setClaudeProfileEnvironmentMock.mockReset().mockImplementation(async () => claudeProfile()); + toastMock.success.mockReset(); + toastMock.danger.mockReset(); + }); + + it("renders nothing for an unknown instance id", () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it("saves an added env var through the sealing bridge", async () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: "Add" })); + fireEvent.change(screen.getByLabelText("Environment variable name"), { + target: { value: "ANTHROPIC_BASE_URL" }, + }); + fireEvent.change(screen.getByLabelText("Environment variable value"), { + target: { value: "https://api.z.ai/api/anthropic" }, + }); + + fireEvent.click(screen.getByRole("button", { name: "Save Claude profile" })); + + expect(setClaudeProfileEnvironmentMock).toHaveBeenCalledWith({ + instanceId: "glm", + environment: { ANTHROPIC_BASE_URL: { value: "https://api.z.ai/api/anthropic" } }, + }); + await waitFor(() => expect(settingsState.setAgentInstance).toHaveBeenCalled()); + }); + + it("fills the editor from the GLM preset", () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: /glm preset/i })); + + expect(screen.getByDisplayValue("https://api.z.ai/api/anthropic")).toBeInTheDocument(); + expect(screen.getByDisplayValue("ANTHROPIC_AUTH_TOKEN")).toBeInTheDocument(); + }); + + it("masks an already-sealed secret value", () => { + settingsState.agentInstances = { + glm: claudeProfile({ + environment: { ANTHROPIC_AUTH_TOKEN: { value: "lc-safe:v1:sealed", sensitive: true } }, + }), + }; + render(); + + expect(screen.getByDisplayValue("ANTHROPIC_AUTH_TOKEN")).toBeInTheDocument(); + expect(screen.getByLabelText("Environment variable value")).toHaveValue("••••••••"); + }); + + it("keeps a saved sealed secret when the masked field is focused but not replaced", () => { + settingsState.agentInstances = { + glm: claudeProfile({ + environment: { ANTHROPIC_AUTH_TOKEN: { value: "lc-safe:v1:sealed", sensitive: true } }, + }), + }; + render(); + + fireEvent.focus(screen.getByLabelText("Environment variable value")); + fireEvent.click(screen.getByRole("button", { name: "Save Claude profile" })); + + expect(setClaudeProfileEnvironmentMock).toHaveBeenCalledWith({ + instanceId: "glm", + environment: { + ANTHROPIC_AUTH_TOKEN: { value: "lc-safe:v1:sealed", sensitive: true }, + }, + }); + }); + + it("persists model and effort overrides as profile config", async () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: /add model/i })); + fireEvent.change(screen.getByLabelText("Model id"), { target: { value: "glm-5.2" } }); + fireEvent.click(screen.getByRole("option", { name: /disable low effort/i })); + + fireEvent.click(screen.getByRole("button", { name: "Save Claude profile" })); + + await waitFor(() => expect(settingsState.setAgentInstance).toHaveBeenCalled()); + const saved = settingsState.setAgentInstance.mock.calls.at(-1)?.[0]; + expect(saved?.config).toEqual({ + configDir: "~/.lightcode/claude-profiles/glm", + models: [{ id: "glm-5.2" }], + efforts: ["medium", "high", "xHigh", "max", "ultracode"], + }); + }); }); diff --git a/src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx b/src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx index 3c8a1f7a..9ab58c46 100644 --- a/src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx +++ b/src/renderer/views/SettingsOverlay/parts/ClaudeProfileSettings.tsx @@ -1,40 +1,45 @@ -import { useState } from "react"; -import { Button, toast } from "@heroui/react"; -import { Check, Plus, RefreshCw, Save, Trash2, X } from "lucide-react"; +import { useRef, useState } from "react"; +import { Button, Popover, toast } from "@heroui/react"; +import { + Check, + ChevronDown, + ChevronRight, + Lock, + LockOpen, + Plus, + RefreshCw, + Trash2, + Wand2, + X, +} from "lucide-react"; import { claudeProfileKind, parseClaudeProfileInstanceConfig, type AgentInstanceConfig, + type ClaudeProfileInstanceConfig, } from "@/shared/contracts"; +import { CLAUDE_EFFORT_TIERS } from "@/shared/agents/claudeEfforts"; import { readBridge } from "@/renderer/bridge"; import { Input } from "@/renderer/components/common"; +import { formatEffortLabel } from "@/renderer/components/thread/threadDraftViewHelpers"; import { useSharedSettings } from "@/renderer/state/sharedSettingsStore"; import { currentWslDistros } from "@/renderer/utils/acpRegistryAuth"; - -function slugifyProfileName(value: string): string { - return ( - value - .trim() - .toLowerCase() - .replace(/[^a-z0-9]+/gu, "-") - .replace(/^-+|-+$/gu, "") || "profile" - ); -} - -function defaultConfigDir(name: string): string { - return `~/.lightcode/claude-profiles/${slugifyProfileName(name)}`; -} - -function uniqueProfileId(name: string, existing: Readonly>): string { - const base = slugifyProfileName(name); - let candidate = base; - let index = 2; - while (existing[candidate]) { - candidate = `${base}-${index}`; - index += 1; - } - return candidate; -} +import { + appendGlmPresetRows, + cleanModels, + defaultConfigDir, + effortsConfigFromSelection, + environmentFromRows, + modelsFromConfig, + profileUsesExternalProvider, + rowsFromEnvironment, + SAVED_SECRET_MASK, + selectedEffortsFromConfig, + shouldTreatEnvKeyAsSensitive, + uniqueProfileId, + type EnvRow, + type ModelRow, +} from "./ClaudeProfileSettingsModel"; function refreshClaudeProfile(kind?: string): void { window.setTimeout(() => { @@ -46,52 +51,417 @@ function refreshClaudeProfile(kind?: string): void { }, 50); } -function ClaudeProfileRow(props: { +// ── Effort multiselect dropdown ────────────────────────────────────────────── + +function EffortMultiSelect(props: { selected: Set; onToggle: (tier: string) => void }) { + const [isOpen, setIsOpen] = useState(false); + const selectedTiers = CLAUDE_EFFORT_TIERS.filter((tier) => props.selected.has(tier)); + const summary = + selectedTiers.length === CLAUDE_EFFORT_TIERS.length + ? "All efforts" + : selectedTiers.length === 0 + ? "None" + : selectedTiers.map(formatEffortLabel).join(", "); + + return ( + + + + + + +
+ {CLAUDE_EFFORT_TIERS.map((tier) => { + const active = props.selected.has(tier); + return ( + + ); + })} +
+
+
+
+ ); +} + +// ── Per-profile editor (rendered on the profile's own settings page) ────────── + +/** + * The external-provider editor for one Claude profile. Owns the whole instance + * (name, config dir, env vars, models, effort) so there is a single source of + * truth and a single Save. Reads the instance from the store by id; renders + * nothing for an unknown / non-Claude id. + */ +export function ClaudeProfileProviderSettings(props: { instanceId: string }) { + const instance = useSharedSettings((s) => s.agentInstances?.[props.instanceId]); + if (!instance || instance.driver !== "claude") return null; + let config: ClaudeProfileInstanceConfig; + try { + config = parseClaudeProfileInstanceConfig(instance.config); + } catch { + return null; + } + return ; +} + +function ClaudeProfileEditor(props: { instance: AgentInstanceConfig; - configDir: string; - onSave: (instance: AgentInstanceConfig) => void; - onRemove: (id: string) => void; + config: ClaudeProfileInstanceConfig; }) { + const setAgentInstance = useSharedSettings((s) => s.setAgentInstance); + const rowIdCounter = useRef(0); + const nextRowId = () => `r${(rowIdCounter.current += 1)}`; + + // Local editor state is seeded once from props; the editor is keyed by + // instance id so it re-seeds when a different profile takes its place. The + // save handler re-seeds from the sealed instance it gets back (re-masking + // secrets) — it does not resync to unrelated external store updates, which + // would clobber the user's in-progress edits. const [name, setName] = useState(props.instance.displayName ?? props.instance.id); - const [configDir, setConfigDir] = useState(props.configDir); + const [configDir, setConfigDir] = useState(props.config.configDir); + const [envRows, setEnvRows] = useState(() => + rowsFromEnvironment(props.instance.environment, nextRowId), + ); + const [modelRows, setModelRows] = useState(() => + modelsFromConfig(props.config.models, nextRowId), + ); + const [selectedEfforts, setSelectedEfforts] = useState>(() => + selectedEffortsFromConfig(props.config.efforts), + ); + const [saving, setSaving] = useState(false); + + const displayLabel = props.instance.displayName ?? props.instance.id; const trimmedName = name.trim(); const trimmedConfigDir = configDir.trim(); - const changed = - trimmedName !== (props.instance.displayName ?? props.instance.id) || - trimmedConfigDir !== props.configDir; - const canSave = trimmedName.length > 0 && trimmedConfigDir.length > 0 && changed; + const canSave = trimmedName.length > 0 && trimmedConfigDir.length > 0 && !saving; + + const updateEnvRow = (rowId: string, patch: Partial) => + setEnvRows((rows) => rows.map((row) => (row.rowId === rowId ? { ...row, ...patch } : row))); + + const addEnvRow = () => + setEnvRows((rows) => [ + ...rows, + { rowId: nextRowId(), key: "", value: "", sensitive: false, replacing: false }, + ]); + + const applyGlmPreset = () => setEnvRows((rows) => appendGlmPresetRows(rows, nextRowId)); + + const updateModelRow = (rowId: string, patch: Partial) => + setModelRows((rows) => rows.map((row) => (row.rowId === rowId ? { ...row, ...patch } : row))); + + const toggleEffort = (tier: string) => + setSelectedEfforts((current) => { + const next = new Set(current); + // Keep at least one tier enabled so the picker always has a choice. + if (next.has(tier)) { + if (next.size > 1) next.delete(tier); + } else { + next.add(tier); + } + return next; + }); + + const save = () => { + if (!canSave) return; + setSaving(true); + const environment = environmentFromRows(envRows); + const models = cleanModels(modelRows); + const efforts = effortsConfigFromSelection(selectedEfforts); + const config: ClaudeProfileInstanceConfig = { + configDir: trimmedConfigDir, + ...(models ? { models } : {}), + ...(efforts ? { efforts } : {}), + }; + // Seal sensitive env in main first (returns the instance with sealed env), + // then persist the non-secret config through the store. + void readBridge() + .setClaudeProfileEnvironment({ instanceId: props.instance.id, environment }) + .then((updated) => { + setAgentInstance({ ...updated, displayName: trimmedName, config }); + setEnvRows(rowsFromEnvironment(updated.environment, nextRowId)); + refreshClaudeProfile(claudeProfileKind(props.instance.id)); + toast.success(`Claude ${trimmedName || displayLabel} profile saved.`); + }) + .catch((error) => + toast.danger( + error instanceof Error ? error.message : `Unable to save Claude ${displayLabel} profile.`, + ), + ) + .finally(() => setSaving(false)); + }; return ( -
- setName(event.target.value)} - /> - setConfigDir(event.target.value)} - /> -
+
+
+
+

External provider

+

+ Point this profile at a non-Anthropic provider (GLM, …) with custom env vars, model + names, and effort levels. +

+
+
+ + {/* Profile basics */} +
+
+ Name + setName(event.target.value)} + /> +
+
+ Config directory + setConfigDir(event.target.value)} + /> +
+
+ + {/* Environment variables */} +
+
+

Environment variables

+
+ + +
+
+ {envRows.length === 0 ? ( +

+ Override Claude defaults — e.g. ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN. +

+ ) : null} + {envRows.map((row) => { + const masked = row.sensitive && Boolean(row.sealed) && !row.replacing; + return ( +
+ { + const key = event.target.value; + updateEnvRow(row.rowId, { + key, + // Auto-flag obvious secrets the first time the key is set. + ...(row.value.length === 0 && !row.sealed + ? { sensitive: shouldTreatEnvKeyAsSensitive(key) } + : {}), + }); + }} + /> + { + if (masked) updateEnvRow(row.rowId, { replacing: true, value: "" }); + }} + onBlur={() => { + if (row.sealed && row.value.length === 0) { + updateEnvRow(row.rowId, { replacing: false }); + } + }} + onChange={(event) => updateEnvRow(row.rowId, { value: event.target.value })} + /> + + +
+ ); + })} +
+ + {/* Model names */} +
+
+

Models

+ +
+ {modelRows.length === 0 ? ( +

Using the built-in Claude model list.

+ ) : null} + {modelRows.map((row) => ( +
+ updateModelRow(row.rowId, { id: event.target.value })} + /> + updateModelRow(row.rowId, { label: event.target.value })} + /> + +
+ ))} +
+ + {/* Effort levels */} +
+

Effort levels

+

+ Disable tiers an external provider collapses (e.g. keep only High and Max). +

+
+ +
+
+
+ ); +} + +// ── Simple profile list (rendered on the base "Claude Code" page) ──────────── + +function ClaudeProfileRow(props: { + instance: AgentInstanceConfig; + config: ClaudeProfileInstanceConfig; + onOpen: () => void; + onRemove: (id: string) => void; +}) { + const label = props.instance.displayName ?? props.instance.id; + return ( +
+ +
+
- {props.agentKind === "claude" ? : null} + {props.agentKind === "claude" ? ( + + ) : null} + {isClaudeProfileKind(props.agentKind) ? ( + + ) : null} {hasAuthSettings && (
diff --git a/src/shared/agents/claudeEfforts.ts b/src/shared/agents/claudeEfforts.ts new file mode 100644 index 00000000..4963f97a --- /dev/null +++ b/src/shared/agents/claudeEfforts.ts @@ -0,0 +1,9 @@ +/** + * The effort tiers Claude Code's frontier models accept. Single source of truth + * shared by the supervisor (capability defaults in `claude/detection.ts`) and + * the renderer (the profile effort allow-list editor), so adding a tier is a + * one-line change rather than two hand-synced lists. + */ +export const CLAUDE_EFFORT_TIERS = ["low", "medium", "high", "xHigh", "max", "ultracode"] as const; + +export type ClaudeEffortTier = (typeof CLAUDE_EFFORT_TIERS)[number]; diff --git a/src/shared/contracts/agentInstance.ts b/src/shared/contracts/agentInstance.ts index 11cc11b1..2105a8d4 100644 --- a/src/shared/contracts/agentInstance.ts +++ b/src/shared/contracts/agentInstance.ts @@ -118,12 +118,35 @@ export function parseAcpGenericInstanceConfig(value: unknown): AcpGenericInstanc // ── claude profile driver config ──────────────────────────────────────── +/** + * A model entry advertised by a Claude profile's picker. `id` is sent verbatim + * to the CLI via `--model`; `label` is the display name (falls back to `id`). + * Used to surface an external provider's model names (e.g. GLM) on a profile + * that points Claude Code at a non-Anthropic `ANTHROPIC_BASE_URL`. + */ +export const claudeProfileModelSchema = z.object({ + id: z.string().min(1).max(200), + label: z.string().min(1).max(120).optional(), +}); +export type ClaudeProfileModel = z.infer; + export const claudeProfileInstanceConfigSchema = z.object({ /** * Directory passed to Claude Code as CLAUDE_CONFIG_DIR. A leading "~/" is * resolved against the target runtime environment (native home or WSL home). */ configDir: z.string().min(1), + /** + * Optional extension of the model list the picker shows for this profile. + * When omitted, only the built-in Claude model list is used. + */ + models: z.array(claudeProfileModelSchema).max(50).optional(), + /** + * Optional allow-list of effort tiers for this profile (subset of the + * built-in tiers, e.g. `["high", "max"]`). Tiers outside this set are hidden + * from the picker. When omitted, all built-in tiers are offered. + */ + efforts: z.array(z.string().min(1).max(40)).max(20).optional(), }); export type ClaudeProfileInstanceConfig = z.infer; @@ -131,6 +154,20 @@ export function parseClaudeProfileInstanceConfig(value: unknown): ClaudeProfileI return claudeProfileInstanceConfigSchema.parse(value ?? {}); } +/** + * Payload for the `setClaudeProfileEnvironment` main-local IPC. The renderer + * sends the full desired environment (plaintext for freshly-entered values, + * already-sealed `lc-safe:` blobs round-tripped for unchanged secrets); the + * main process seals any `sensitive` plaintext before writing settings.json. + */ +export const setClaudeProfileEnvironmentPayloadSchema = z.object({ + instanceId: agentInstanceIdSchema, + environment: z.record(z.string().min(1).max(200), agentInstanceEnvVarSchema), +}); +export type SetClaudeProfileEnvironmentPayload = z.infer< + typeof setClaudeProfileEnvironmentPayloadSchema +>; + export function claudeProfileKind(instanceId: string): AgentDriverKind { return `${CLAUDE_PROFILE_KIND_PREFIX}${instanceId}` as AgentDriverKind; } diff --git a/src/shared/ipc/procedureMap.ts b/src/shared/ipc/procedureMap.ts index 2b1857d3..9aa1bd45 100644 --- a/src/shared/ipc/procedureMap.ts +++ b/src/shared/ipc/procedureMap.ts @@ -64,6 +64,7 @@ export const MAIN_LOCAL_PROCEDURE_NAMES = [ "revealProjectEntry", "getSharedSettings", "setSharedSettings", + "setClaudeProfileEnvironment", "setWindowChrome", "dbGetProjects", "dbGetThreads", diff --git a/src/shared/ipc/procedures/settings.ts b/src/shared/ipc/procedures/settings.ts index c0a8667c..8d52d60a 100644 --- a/src/shared/ipc/procedures/settings.ts +++ b/src/shared/ipc/procedures/settings.ts @@ -1,4 +1,6 @@ import { z } from "zod"; +import type { AgentInstanceConfig, SetClaudeProfileEnvironmentPayload } from "../../contracts"; +import { setClaudeProfileEnvironmentPayloadSchema } from "../../contracts"; import type { SharedSettings, SharedSettingsInput } from "../../settings"; import { defineNoArgProcedure, definePayloadProcedure } from "../core"; import { windowChromePayloadSchema, type WindowChromePayload } from "../schemas"; @@ -13,6 +15,14 @@ export const settingsProcedures = { "main-local", z.custom(), ), + // Seals sensitive vars in main before writing settings.json, so a profile's + // ANTHROPIC_AUTH_TOKEN never lands in plaintext via the renderer persist + // cycle. Returns the updated instance (env sealed) for the store to adopt. + setClaudeProfileEnvironment: definePayloadProcedure< + SetClaudeProfileEnvironmentPayload, + AgentInstanceConfig, + "main-local" + >("setClaudeProfileEnvironment", "main-local", setClaudeProfileEnvironmentPayloadSchema), setWindowChrome: definePayloadProcedure( "setWindowChrome", "main-local", diff --git a/src/shared/secretFormat.ts b/src/shared/secretFormat.ts new file mode 100644 index 00000000..fd93c151 --- /dev/null +++ b/src/shared/secretFormat.ts @@ -0,0 +1,14 @@ +/** + * Pure helpers for recognizing a sealed secret value, with NO `node:crypto` + * import — so they are safe to use in the renderer (Vite externalizes + * `node:crypto`, and merely importing `secretStorage.ts` there throws). The + * sealing/unsealing primitives that actually need crypto live in + * `secretStorage.ts`, which re-exports `isEncryptedSecret` for existing callers. + */ + +/** Marker prefix for AES-256-GCM sealed values produced by `encryptSecret`. */ +export const SECRET_PREFIX = "lc-safe:v1:"; + +export function isEncryptedSecret(value: string): boolean { + return value.startsWith(SECRET_PREFIX); +} diff --git a/src/shared/secretStorage.ts b/src/shared/secretStorage.ts index f4a10d1e..e073389b 100644 --- a/src/shared/secretStorage.ts +++ b/src/shared/secretStorage.ts @@ -1,4 +1,9 @@ import { randomBytes, createCipheriv, createDecipheriv } from "node:crypto"; +import { SECRET_PREFIX, isEncryptedSecret } from "./secretFormat"; + +// Re-exported so existing `@/shared/secretStorage` import sites stay stable; +// the prefix check itself is crypto-free (see `secretFormat.ts`). +export { isEncryptedSecret }; /** * Symmetric secret sealing shared by the main and supervisor processes. The key @@ -11,8 +16,6 @@ import { randomBytes, createCipheriv, createDecipheriv } from "node:crypto"; * it runs in either process (and under vitest with an ephemeral fallback key). */ -const SECRET_PREFIX = "lc-safe:v1:"; - let configuredSecretKey: Buffer | undefined; let testFallbackSecretKey: Buffer | undefined; @@ -34,10 +37,6 @@ function readSecretKey(): Buffer { throw new Error("Lightcode secret storage key is not initialized."); } -export function isEncryptedSecret(value: string): boolean { - return value.startsWith(SECRET_PREFIX); -} - export function encryptSecret(_baseDir: string, value: string): string { if (isEncryptedSecret(value)) return value; const iv = randomBytes(12); diff --git a/src/supervisor/agents/claude/claude.test.ts b/src/supervisor/agents/claude/claude.test.ts index 4ebf29e1..a9844ea1 100644 --- a/src/supervisor/agents/claude/claude.test.ts +++ b/src/supervisor/agents/claude/claude.test.ts @@ -203,6 +203,128 @@ describe("createClaudeProfileAdapter", () => { )?.env?.CLAUDE_CONFIG_DIR, ).toBe(expectedConfigDir); }); + + it("merges the instance environment into the spawn env, with CLAUDE_CONFIG_DIR winning", () => { + const adapter = createClaudeProfileAdapter({ + id: "glm", + driver: "claude", + displayName: "GLM", + config: { configDir: "~/.lightcode/claude-profiles/glm" }, + // Values arrive decrypted from the supervisor's settings read. + environment: { + ANTHROPIC_BASE_URL: { value: "https://api.z.ai/api/anthropic" }, + ANTHROPIC_AUTH_TOKEN: { value: "sk-test", sensitive: true }, + // A user override of CLAUDE_CONFIG_DIR must not win over the profile. + CLAUDE_CONFIG_DIR: { value: "/should/be/ignored" }, + }, + }); + + const env = adapter.buildLaunchArgv(projectLocation, { model: "glm-5.2" }, "hello").env; + const expectedConfigDir = path.join(homedir(), ".lightcode/claude-profiles/glm"); + expect(env?.ANTHROPIC_BASE_URL).toBe("https://api.z.ai/api/anthropic"); + expect(env?.ANTHROPIC_AUTH_TOKEN).toBe("sk-test"); + expect(env?.CLAUDE_CONFIG_DIR).toBe(expectedConfigDir); + }); + + it("appends configured models to the built-in list", () => { + const adapter = createClaudeProfileAdapter({ + id: "glm", + driver: "claude", + displayName: "GLM", + config: { + configDir: "~/.lightcode/claude-profiles/glm", + models: [{ id: "glm-5.2", label: "GLM 5.2" }, { id: "glm-4.5-air" }], + }, + }); + + const ids = adapter.capabilities.models.map((model) => model.id); + // Built-in Claude models stay selectable; custom ones are appended. + expect(ids).toEqual([ + ...claudeCapabilities.models.map((model) => model.id), + "glm-5.2", + "glm-4.5-air", + ]); + expect(adapter.capabilities.models).toContainEqual({ id: "glm-5.2", label: "GLM 5.2" }); + expect(adapter.capabilities.models).toContainEqual({ + id: "glm-4.5-air", + label: "glm-4.5-air", + }); + // Built-in per-model maps are preserved (Claude models still work). + expect(adapter.capabilities.modelEfforts).toEqual(claudeCapabilities.modelEfforts); + }); + + it("does not duplicate a configured model that matches a built-in id", () => { + const adapter = createClaudeProfileAdapter({ + id: "glm", + driver: "claude", + displayName: "GLM", + config: { + configDir: "~/.lightcode/claude-profiles/glm", + models: [{ id: "sonnet", label: "Sonnet (custom)" }], + }, + }); + + const sonnetEntries = adapter.capabilities.models.filter((model) => model.id === "sonnet"); + expect(sonnetEntries).toEqual([{ id: "sonnet", label: "Sonnet" }]); + }); + + it("does not duplicate repeated configured model ids", () => { + const adapter = createClaudeProfileAdapter({ + id: "glm", + driver: "claude", + displayName: "GLM", + config: { + configDir: "~/.lightcode/claude-profiles/glm", + models: [{ id: "glm-5.2" }, { id: "glm-5.2", label: "GLM duplicate" }], + }, + }); + + const customEntries = adapter.capabilities.models.filter((model) => model.id === "glm-5.2"); + expect(customEntries).toEqual([{ id: "glm-5.2", label: "glm-5.2" }]); + }); + + it("restricts the effort allow-list and keeps an allowed default", () => { + const adapter = createClaudeProfileAdapter({ + id: "glm", + driver: "claude", + displayName: "GLM", + config: { configDir: "~/.lightcode/claude-profiles/glm", efforts: ["high", "max"] }, + }); + + expect(adapter.capabilities.efforts).toEqual(["high", "max"]); + expect(adapter.capabilities.defaultEffort).toBe("high"); + }); + + it("re-homes the default effort to the first allowed tier when disabled", () => { + const adapter = createClaudeProfileAdapter({ + id: "glm", + driver: "claude", + displayName: "GLM", + config: { configDir: "~/.lightcode/claude-profiles/glm", efforts: ["max", "ultracode"] }, + }); + + expect(adapter.capabilities.efforts).toEqual(["max", "ultracode"]); + expect(adapter.capabilities.defaultEffort).toBe("max"); + }); + + it("leaves the built-in adapter capabilities untouched", () => { + expect(createClaudeAdapter().capabilities.models).toEqual(claudeCapabilities.models); + expect(createClaudeAdapter().capabilities.efforts).toEqual(claudeCapabilities.efforts); + }); + + it("falls back to the built-in efforts when an allow-list has no known tiers", () => { + // Guards a hand-edited config: an all-unknown allow-list must not leave the + // picker with zero effort options. + const adapter = createClaudeProfileAdapter({ + id: "glm", + driver: "claude", + displayName: "GLM", + config: { configDir: "~/.lightcode/claude-profiles/glm", efforts: ["bogus", "nope"] }, + }); + + expect(adapter.capabilities.efforts).toEqual(claudeCapabilities.efforts); + expect(adapter.capabilities.defaultEffort).toBe(claudeCapabilities.defaultEffort); + }); }); describe("parseClaudeAuthStatusJson", () => { diff --git a/src/supervisor/agents/claude/detection.ts b/src/supervisor/agents/claude/detection.ts index 4f2a26f1..f9dc5633 100644 --- a/src/supervisor/agents/claude/detection.ts +++ b/src/supervisor/agents/claude/detection.ts @@ -1,4 +1,5 @@ import { compactAgentProviderMetadata, type AgentCapability } from "@/shared/contracts"; +import { CLAUDE_EFFORT_TIERS } from "@/shared/agents/claudeEfforts"; import { readAgentCommandOutput, type DetectionSpec, type StatusProbeResult } from "../base"; import { getAgentProbeCwd } from "../probeCwd"; import { probeClaudeCapabilities } from "./probe"; @@ -21,7 +22,7 @@ const CLAUDE_BUILT_IN_SLASH_COMMANDS: AgentCapability["slashCommands"] = [ ]; /** Effort tiers shared by the frontier models (Opus 4.7/4.8 and Fable 5). */ -const PREMIUM_EFFORT_TIERS = ["low", "medium", "high", "xHigh", "max", "ultracode"]; +const PREMIUM_EFFORT_TIERS: string[] = [...CLAUDE_EFFORT_TIERS]; /** * Master switch for the Fable 5 model. Flip to `true` to surface it again in the diff --git a/src/supervisor/agents/claude/index.ts b/src/supervisor/agents/claude/index.ts index c30f92f4..d2fb148c 100644 --- a/src/supervisor/agents/claude/index.ts +++ b/src/supervisor/agents/claude/index.ts @@ -2,7 +2,13 @@ import { randomUUID } from "node:crypto"; import { homedir } from "node:os"; import path, { posix as posixPath } from "node:path"; -import type { AgentInstanceConfig, ProjectLocation, PromptSegment } from "@/shared/contracts"; +import type { + AgentCapability, + AgentInstanceConfig, + ClaudeProfileModel, + ProjectLocation, + PromptSegment, +} from "@/shared/contracts"; import { claudeProfileKind, parseClaudeProfileInstanceConfig } from "@/shared/contracts"; import { brailleSpinnerOscTitleHint, @@ -41,6 +47,16 @@ interface ClaudeAdapterOptions { kind?: string; label?: string; configDir?: string; + /** + * Extra environment variables merged into every spawn (PTY/SDK/probe), e.g. + * `ANTHROPIC_BASE_URL` / `ANTHROPIC_AUTH_TOKEN` to point a profile at an + * external provider. `CLAUDE_CONFIG_DIR` still wins (the profile's identity). + */ + customEnv?: Record; + /** Profile-specific picker model list (overrides the built-in Claude list). */ + models?: ClaudeProfileModel[]; + /** Profile-specific effort allow-list (hides built-in tiers outside it). */ + efforts?: string[]; } function resolveTildePath(rawPath: string, location: ProjectLocation): string { @@ -58,19 +74,99 @@ function resolveTildePath(rawPath: string, location: ProjectLocation): string { function profileEnvForLocation( configDir: string | undefined, + customEnv: Record | undefined, location: ProjectLocation, ): Record | undefined { - if (!configDir?.trim()) return undefined; - return { CLAUDE_CONFIG_DIR: resolveTildePath(configDir, location) }; + // customEnv already has its empty keys filtered out (resolveInstanceEnv). + // CLAUDE_CONFIG_DIR is set last so the profile's identity always wins over a + // user-supplied override of the same key. + const env: Record = { ...customEnv }; + if (configDir?.trim()) { + env.CLAUDE_CONFIG_DIR = resolveTildePath(configDir, location); + } + return Object.keys(env).length > 0 ? env : undefined; +} + +/** + * Flatten an instance's `environment` map (values already decrypted by the + * supervisor's settings read) into a plain name→value map for spawning. + */ +function resolveInstanceEnv( + environment: AgentInstanceConfig["environment"], +): Record | undefined { + if (!environment) return undefined; + const resolved: Record = {}; + for (const [name, variable] of Object.entries(environment)) { + if (name.trim().length === 0) continue; + resolved[name] = variable.value; + } + return Object.keys(resolved).length > 0 ? resolved : undefined; +} + +/** + * Apply a profile's optional model additions / effort allow-list on top of the + * built-in Claude capabilities. A no-op when neither override is set (so the + * default adapter is unaffected). Custom models are *appended* to the built-in + * list (the user can still pick the Claude models); they aren't in the built-in + * per-model maps, so the picker falls back to the global effort/context lists. + */ +function overrideProfileCapabilities( + base: AgentCapability, + models: ClaudeProfileModel[] | undefined, + efforts: readonly string[] | undefined, +): AgentCapability { + let caps = base; + + if (efforts && efforts.length > 0) { + const allowed = new Set(efforts); + const keep = (list: readonly string[]) => list.filter((effort) => allowed.has(effort)); + const nextEfforts = keep(caps.efforts); + // If a hand-edited config lists only unknown tier names, the allow-list is + // empty — keep the full built-in list rather than leaving the picker with no + // efforts to choose from. (The UI only ever writes valid tiers.) + if (nextEfforts.length > 0) { + caps = { + ...caps, + efforts: nextEfforts, + defaultEffort: + caps.defaultEffort && allowed.has(caps.defaultEffort) + ? caps.defaultEffort + : nextEfforts[0], + modelEfforts: Object.fromEntries( + Object.entries(caps.modelEfforts).map(([id, list]) => [id, keep(list)]), + ), + }; + } + } + + if (models && models.length > 0) { + const existingIds = new Set(caps.models.map((model) => model.id)); + const additions: AgentCapability["models"] = []; + for (const model of models) { + const id = model.id.trim(); + if (!id || existingIds.has(id)) continue; + existingIds.add(id); + additions.push({ id, label: model.label?.trim() || id }); + } + if (additions.length > 0) { + caps = { ...caps, models: [...caps.models, ...additions] }; + } + } + + return caps; } export function createClaudeProfileAdapter(instance: AgentInstanceConfig): AgentAdapter { const cfg = parseClaudeProfileInstanceConfig(instance.config); const profileLabel = instance.displayName ?? instance.id; + const customEnv = resolveInstanceEnv(instance.environment); return createClaudeAdapter({ kind: claudeProfileKind(instance.id), label: `Claude ${profileLabel}`, configDir: cfg.configDir, + ...(customEnv ? { customEnv } : {}), + ...(cfg.models && cfg.models.length > 0 ? { models: cfg.models } : {}), + ...(cfg.efforts && cfg.efforts.length > 0 ? { efforts: cfg.efforts } : {}), }); } @@ -78,13 +174,18 @@ export function createClaudeAdapter(options: ClaudeAdapterOptions = {}): AgentAd const kind = options.kind ?? "claude"; const label = options.label ?? "Claude Code"; const profileEnv = (location: ProjectLocation) => - profileEnvForLocation(options.configDir, location); + profileEnvForLocation(options.configDir, options.customEnv, location); + const capabilities = overrideProfileCapabilities( + claudeCapabilities, + options.models, + options.efforts, + ); return { kind, label, binary: "claude", - capabilities: claudeCapabilities, + capabilities, ...(claudeDetectionSpec.update ? { update: claudeDetectionSpec.update } : {}), // WSL OAuth flows try to open a browser; no-op it so the PTY doesn't hang. spawnEnv: { wsl: { BROWSER: "/bin/true" } }, @@ -126,7 +227,7 @@ export function createClaudeAdapter(options: ClaudeAdapterOptions = {}): AgentAd ...claudeDetectionSpec, kind, label, - capabilities: claudeCapabilities, + capabilities, statusProbe: (probeCtx: DetectProbeCtx) => { const env = profileEnv(probeCtx.location); return probeClaudeStatus(probeCtx, env ? { env } : undefined); @@ -141,7 +242,13 @@ export function createClaudeAdapter(options: ClaudeAdapterOptions = {}): AgentAd ...status, kind, label, - capabilities: status.capabilities, + // Re-assert the profile overrides on top of whatever the probe returned, + // so the model list / effort allow-list always wins. + capabilities: overrideProfileCapabilities( + status.capabilities, + options.models, + options.efforts, + ), }; }, buildLaunchArgv(location, config, prompt, _sessionRef, _launchOptions) { diff --git a/src/supervisor/agents/claude/probe.ts b/src/supervisor/agents/claude/probe.ts index 21b819d5..ceef3c12 100644 --- a/src/supervisor/agents/claude/probe.ts +++ b/src/supervisor/agents/claude/probe.ts @@ -1,5 +1,6 @@ import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; +import { CLAUDE_EFFORT_TIERS } from "@/shared/agents/claudeEfforts"; import type { AgentCapability, AgentTerminalAuthMethod } from "@/shared/contracts"; import type { SlashCommand } from "@anthropic-ai/claude-agent-sdk"; import { @@ -44,7 +45,7 @@ const BUILTIN_MODELS: AgentCapability["models"] = [ ]; /** Effort tiers shared by the frontier models (Opus 4.7/4.8 and Fable 5). */ -const PREMIUM_EFFORT_TIERS = ["low", "medium", "high", "xHigh", "max", "ultracode"]; +const PREMIUM_EFFORT_TIERS: string[] = [...CLAUDE_EFFORT_TIERS]; const BUILTIN_MODEL_EFFORTS: AgentCapability["modelEfforts"] = { [FABLE_5_MODEL_ID]: PREMIUM_EFFORT_TIERS,