diff --git a/apps/code/src/main/services/agent/schemas.ts b/apps/code/src/main/services/agent/schemas.ts index a4652cc5d..071436736 100644 --- a/apps/code/src/main/services/agent/schemas.ts +++ b/apps/code/src/main/services/agent/schemas.ts @@ -82,7 +82,7 @@ const sessionConfigSelectGroupSchema = z }) .passthrough(); -export const sessionConfigOptionSchema = z +const sessionConfigSelectSchema = z .object({ id: z.string(), name: z.string(), @@ -97,6 +97,23 @@ export const sessionConfigOptionSchema = z }) .passthrough(); +const sessionConfigBooleanSchema = z + .object({ + id: z.string(), + name: z.string(), + type: z.literal("boolean"), + currentValue: z.boolean(), + category: z.string().nullish(), + description: z.string().nullish(), + _meta: z.record(z.string(), z.unknown()).nullish(), + }) + .passthrough(); + +export const sessionConfigOptionSchema = z.union([ + sessionConfigSelectSchema, + sessionConfigBooleanSchema, +]); + export type SessionConfigOption = z.infer; export const sessionResponseSchema = z.object({ diff --git a/apps/code/src/main/services/agent/service.ts b/apps/code/src/main/services/agent/service.ts index 764a898ab..933a815df 100644 --- a/apps/code/src/main/services/agent/service.ts +++ b/apps/code/src/main/services/agent/service.ts @@ -1022,7 +1022,10 @@ export class AgentService extends TypedEventEmitter { const updatedModeOption = session.configOptions?.find( (opt) => opt.category === "mode", ); - if (updatedModeOption) { + if ( + updatedModeOption && + typeof updatedModeOption.currentValue === "string" + ) { session.config.permissionMode = updatedModeOption.currentValue; } diff --git a/apps/code/src/renderer/features/message-editor/components/AttachmentMenu.tsx b/apps/code/src/renderer/features/message-editor/components/AttachmentMenu.tsx index 7f51322b9..b36539ff6 100644 --- a/apps/code/src/renderer/features/message-editor/components/AttachmentMenu.tsx +++ b/apps/code/src/renderer/features/message-editor/components/AttachmentMenu.tsx @@ -147,7 +147,10 @@ export function AttachmentMenu({ ) : (
- +
)} diff --git a/apps/code/src/renderer/features/message-editor/components/ModeIndicatorInput.tsx b/apps/code/src/renderer/features/message-editor/components/ModeIndicatorInput.tsx index d5ae1f1ee..5c6f301bf 100644 --- a/apps/code/src/renderer/features/message-editor/components/ModeIndicatorInput.tsx +++ b/apps/code/src/renderer/features/message-editor/components/ModeIndicatorInput.tsx @@ -2,6 +2,7 @@ import type { SessionConfigOption, SessionConfigSelectGroup, SessionConfigSelectOption, + SessionConfigSelectOptions, } from "@agentclientprotocol/sdk"; import { Circle, @@ -60,7 +61,7 @@ interface ModeIndicatorInputProps { } function flattenOptions( - options: SessionConfigOption["options"], + options: SessionConfigSelectOptions, ): SessionConfigSelectOption[] { if (options.length === 0) return []; if ("group" in options[0]) { @@ -75,7 +76,7 @@ export function ModeIndicatorInput({ modeOption, onCycleMode, }: ModeIndicatorInputProps) { - if (!modeOption) return null; + if (!modeOption || modeOption.type !== "select") return null; const id = modeOption.currentValue; diff --git a/apps/code/src/renderer/features/onboarding/components/TutorialStep.tsx b/apps/code/src/renderer/features/onboarding/components/TutorialStep.tsx index 02056aea4..169ce6b61 100644 --- a/apps/code/src/renderer/features/onboarding/components/TutorialStep.tsx +++ b/apps/code/src/renderer/features/onboarding/components/TutorialStep.tsx @@ -109,7 +109,8 @@ export function TutorialStep({ onComplete, onBack }: TutorialStepProps) { const currentExecutionMode = getCurrentModeFromConfigOptions(modeOption ? [modeOption] : undefined) ?? "plan"; - const currentReasoningLevel = thoughtOption?.currentValue; + const currentReasoningLevel = + thoughtOption?.type === "select" ? thoughtOption.currentValue : undefined; // Task creation — use whatever model the user picked const { isCreatingTask, canSubmit, handleSubmit } = useTaskCreation({ diff --git a/apps/code/src/renderer/features/sessions/components/ModelSelector.tsx b/apps/code/src/renderer/features/sessions/components/ModelSelector.tsx index 8342716a1..0b722d16a 100644 --- a/apps/code/src/renderer/features/sessions/components/ModelSelector.tsx +++ b/apps/code/src/renderer/features/sessions/components/ModelSelector.tsx @@ -24,26 +24,33 @@ export function ModelSelector({ const session = useSessionForTask(taskId); const modelOption = useModelConfigOptionForTask(taskId); - const options = modelOption ? flattenSelectOptions(modelOption.options) : []; + const selectOption = modelOption?.type === "select" ? modelOption : undefined; + const options = selectOption + ? flattenSelectOptions(selectOption.options) + : []; const groupedOptions = useMemo(() => { - if (!modelOption || modelOption.options.length === 0) return []; - if ("group" in modelOption.options[0]) { - return modelOption.options as SessionConfigSelectGroup[]; + if (!selectOption || selectOption.options.length === 0) return []; + if ("group" in selectOption.options[0]) { + return selectOption.options as SessionConfigSelectGroup[]; } return []; - }, [modelOption]); + }, [selectOption]); - if (!modelOption || options.length === 0) return null; + if (!selectOption || options.length === 0) return null; const handleChange = (value: string) => { onModelChange?.(value); if (taskId && session?.status === "connected") { - getSessionService().setSessionConfigOption(taskId, modelOption.id, value); + getSessionService().setSessionConfigOption( + taskId, + selectOption.id, + value, + ); } }; - const currentValue = modelOption.currentValue; + const currentValue = selectOption.currentValue; const currentLabel = options.find((opt) => opt.value === currentValue)?.name ?? currentValue; diff --git a/apps/code/src/renderer/features/sessions/components/ReasoningLevelSelector.tsx b/apps/code/src/renderer/features/sessions/components/ReasoningLevelSelector.tsx index 768ca82d6..5ab809989 100644 --- a/apps/code/src/renderer/features/sessions/components/ReasoningLevelSelector.tsx +++ b/apps/code/src/renderer/features/sessions/components/ReasoningLevelSelector.tsx @@ -20,7 +20,7 @@ export function ReasoningLevelSelector({ const thoughtOption = useThoughtLevelConfigOptionForTask(taskId); const adapter = useAdapterForTask(taskId); - if (!thoughtOption) { + if (!thoughtOption || thoughtOption.type !== "select") { return null; } diff --git a/apps/code/src/renderer/features/sessions/components/UnifiedModelSelector.tsx b/apps/code/src/renderer/features/sessions/components/UnifiedModelSelector.tsx index 09bfdc182..40f041442 100644 --- a/apps/code/src/renderer/features/sessions/components/UnifiedModelSelector.tsx +++ b/apps/code/src/renderer/features/sessions/components/UnifiedModelSelector.tsx @@ -50,16 +50,19 @@ export function UnifiedModelSelector({ const session = useSessionForTask(taskId); const modelOption = useModelConfigOptionForTask(taskId); - const options = modelOption ? flattenSelectOptions(modelOption.options) : []; + const selectOption = modelOption?.type === "select" ? modelOption : undefined; + const options = selectOption + ? flattenSelectOptions(selectOption.options) + : []; const groupedOptions = useMemo(() => { - if (!modelOption || modelOption.options.length === 0) return []; - if ("group" in modelOption.options[0]) { - return modelOption.options as SessionConfigSelectGroup[]; + if (!selectOption || selectOption.options.length === 0) return []; + if ("group" in selectOption.options[0]) { + return selectOption.options as SessionConfigSelectGroup[]; } return []; - }, [modelOption]); + }, [selectOption]); - const currentValue = modelOption?.currentValue; + const currentValue = selectOption?.currentValue; const currentLabel = options.find((opt) => opt.value === currentValue)?.name ?? currentValue; diff --git a/apps/code/src/renderer/features/sessions/service/service.ts b/apps/code/src/renderer/features/sessions/service/service.ts index 5962c8d38..c9c289eed 100644 --- a/apps/code/src/renderer/features/sessions/service/service.ts +++ b/apps/code/src/renderer/features/sessions/service/service.ts @@ -355,10 +355,9 @@ export class SessionService { this.subscribeToChannel(taskRunId); try { - const persistedMode = getConfigOptionByCategory( - persistedConfigOptions, - "mode", - )?.currentValue; + const modeOpt = getConfigOptionByCategory(persistedConfigOptions, "mode"); + const persistedMode = + modeOpt?.type === "select" ? modeOpt.currentValue : undefined; trpcClient.workspace.verify .query({ taskId }) @@ -429,7 +428,7 @@ export class SessionService { .mutate({ sessionId: taskRunId, configId: opt.id, - value: opt.currentValue, + value: String(opt.currentValue), }) .catch((error) => { log.warn( @@ -1636,7 +1635,9 @@ export class SessionService { // Optimistic update const updatedOptions = configOptions.map((opt) => - opt.id === configId ? { ...opt, currentValue: value } : opt, + opt.id === configId + ? ({ ...opt, currentValue: value } as SessionConfigOption) + : opt, ); sessionStoreSetters.updateSession(session.taskRunId, { configOptions: updatedOptions, @@ -1652,7 +1653,9 @@ export class SessionService { } catch (error) { // Rollback on error const rolledBackOptions = configOptions.map((opt) => - opt.id === configId ? { ...opt, currentValue: previousValue } : opt, + opt.id === configId + ? ({ ...opt, currentValue: previousValue } as SessionConfigOption) + : opt, ); sessionStoreSetters.updateSession(session.taskRunId, { configOptions: rolledBackOptions, @@ -1660,7 +1663,7 @@ export class SessionService { updatePersistedConfigOptionValue( session.taskRunId, configId, - previousValue, + String(previousValue), ); log.error("Failed to set session config option", { taskId, @@ -1697,7 +1700,7 @@ export class SessionService { track(ANALYTICS_EVENTS.SESSION_CONFIG_CHANGED, { task_id: taskId, category, - from_value: configOption.currentValue, + from_value: String(configOption.currentValue), to_value: value, }); } diff --git a/apps/code/src/renderer/features/sessions/stores/sessionConfigStore.ts b/apps/code/src/renderer/features/sessions/stores/sessionConfigStore.ts index f73b1b2b7..f59ec00ee 100644 --- a/apps/code/src/renderer/features/sessions/stores/sessionConfigStore.ts +++ b/apps/code/src/renderer/features/sessions/stores/sessionConfigStore.ts @@ -49,7 +49,9 @@ export const useSessionConfigStore = create()( if (!existing) return state; const updated = existing.map((opt) => - opt.id === configId ? { ...opt, currentValue: value } : opt, + opt.id === configId + ? ({ ...opt, currentValue: value } as SessionConfigOption) + : opt, ); return { diff --git a/apps/code/src/renderer/features/sessions/stores/sessionStore.ts b/apps/code/src/renderer/features/sessions/stores/sessionStore.ts index fcef31611..86421dec0 100644 --- a/apps/code/src/renderer/features/sessions/stores/sessionStore.ts +++ b/apps/code/src/renderer/features/sessions/stores/sessionStore.ts @@ -120,8 +120,10 @@ export function mergeConfigOptions( return live.map((liveOpt) => { const persistedOpt = persistedMap.get(liveOpt.id); if (persistedOpt) { - // Use persisted currentValue if available - return { ...liveOpt, currentValue: persistedOpt.currentValue }; + return { + ...liveOpt, + currentValue: persistedOpt.currentValue, + } as SessionConfigOption; } return liveOpt; }); @@ -145,7 +147,7 @@ export function cycleModeOption( modeOption: SessionConfigOption | undefined, allowBypassPermissions: boolean, ): string | undefined { - if (!modeOption) return undefined; + if (!modeOption || modeOption.type !== "select") return undefined; const allOptions = flattenSelectOptions(modeOption.options); const filteredOptions = allowBypassPermissions diff --git a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx index 7cdc05fa2..d5c5fc1e3 100644 --- a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx +++ b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx @@ -140,13 +140,15 @@ export function TaskInput() { // Get current values from preview session config options for task creation. // Defaults ensure values are always passed even before the preview session loads. - const currentModel = modelOption?.currentValue; + const currentModel = + modelOption?.type === "select" ? modelOption.currentValue : undefined; const modeFallback = defaultInitialTaskMode === "last_used" ? lastUsedInitialTaskMode : "plan"; const currentExecutionMode = getCurrentModeFromConfigOptions(modeOption ? [modeOption] : undefined) ?? modeFallback; - const currentReasoningLevel = thoughtOption?.currentValue; + const currentReasoningLevel = + thoughtOption?.type === "select" ? thoughtOption.currentValue : undefined; const branchForTaskCreation = effectiveWorkspaceMode === "worktree" || effectiveWorkspaceMode === "cloud" diff --git a/apps/code/src/vite-env.d.ts b/apps/code/src/vite-env.d.ts index 1f066ab4e..eb0d376a3 100644 --- a/apps/code/src/vite-env.d.ts +++ b/apps/code/src/vite-env.d.ts @@ -13,9 +13,6 @@ interface ImportMetaEnv { readonly VITE_POSTHOG_UI_HOST?: string; } -// This interface is used by TypeScript to type-check `import.meta.env` in Vite. -// It appears unused in this file, but it is intentionally kept for global augmentation. -// biome-ignore lint: noUnusedVariables interface ImportMeta { readonly env: ImportMetaEnv; } diff --git a/packages/agent/package.json b/packages/agent/package.json index 610b75ff7..56f15c2fb 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -87,8 +87,8 @@ "vitest": "^2.1.8" }, "dependencies": { - "@agentclientprotocol/sdk": "0.15.0", - "@anthropic-ai/claude-agent-sdk": "0.2.71", + "@agentclientprotocol/sdk": "0.16.1", + "@anthropic-ai/claude-agent-sdk": "0.2.76", "@anthropic-ai/sdk": "^0.78.0", "@hono/node-server": "^1.19.9", "@opentelemetry/api-logs": "^0.208.0", diff --git a/packages/agent/src/adapters/claude/UPSTREAM.md b/packages/agent/src/adapters/claude/UPSTREAM.md index 60b19fe1d..ffb7fe894 100644 --- a/packages/agent/src/adapters/claude/UPSTREAM.md +++ b/packages/agent/src/adapters/claude/UPSTREAM.md @@ -5,8 +5,8 @@ Fork of `@anthropic-ai/claude-agent-acp`. Upstream repo: https://github.com/anth ## Fork Point - **Forked**: v0.10.9, commit `5411e0f4`, Dec 2 2025 -- **Last sync**: v0.21.0, commit `c13edf3b02ddaf49f8e8b9e11b02cbf17869b57d`, March 9 2026 -- **SDK**: `@anthropic-ai/claude-agent-sdk` 0.2.71, `@agentclientprotocol/sdk` 0.15.0 +- **Last sync**: v0.22.2, commit `07db59e`, March 25 2026 +- **SDK**: `@anthropic-ai/claude-agent-sdk` 0.2.76, `@agentclientprotocol/sdk` 0.16.1 ## File Mapping @@ -49,13 +49,12 @@ Fork of `@anthropic-ai/claude-agent-acp`. Upstream repo: https://github.com/anth | Model resolution | `initializationResult.models` from SDK | `fetchGatewayModels()` from gateway API | Different model backend | | permissionMode | Hardcoded `"default"` | Reads from `meta.permissionMode` | More flexible mode selection | | Session storage | `this.sessions[sessionId]` (multi) | `this.session` (single) | Architectural choice | -| ExitPlanMode denial | `interrupt: true` | `interrupt: false` | Better UX — lets Claude refine plan | | bypassPermissions | `updatedPermissions` with `destination: "session"` | No `updatedPermissions` | Different permission persistence | | Auth methods | Always returns `claude-login` auth method | Returns empty `authMethods` | Auth handled externally | ## Next Sync -1. Check upstream changelog since v0.21.0 +1. Check upstream changelog since v0.22.2 2. Diff upstream source against PostHog Code using the file mapping above 3. Port in phases: bug fixes first, then features 4. After each phase: `pnpm --filter agent typecheck && pnpm --filter agent build && pnpm lint` diff --git a/packages/agent/src/adapters/claude/claude-agent.ts b/packages/agent/src/adapters/claude/claude-agent.ts index 1b5925e69..092f4fdc9 100644 --- a/packages/agent/src/adapters/claude/claude-agent.ts +++ b/packages/agent/src/adapters/claude/claude-agent.ts @@ -66,6 +66,7 @@ import { DEFAULT_MODEL, getDefaultContextWindow, getEffortOptions, + resolveModelPreference, toSdkModelId, } from "./session/models"; import { @@ -89,6 +90,7 @@ import type { const SESSION_VALIDATION_TIMEOUT_MS = 10_000; const MAX_TITLE_LENGTH = 256; +const LOCAL_ONLY_COMMANDS = new Set(["/context", "/heapdump", "/extra-usage"]); function sanitizeTitle(text: string): string { const sanitized = text @@ -141,6 +143,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { list: {}, fork: {}, resume: {}, + close: {}, }, _meta: { posthog: { @@ -195,6 +198,10 @@ export class ClaudeAcpAgent extends BaseAcpAgent { async unstable_resumeSession( params: ResumeSessionRequest, ): Promise { + // Reuse existing session if it matches + const existing = this.getExistingSessionState(params.sessionId); + if (existing) return existing; + const response = await this.createSession( { cwd: params.cwd, @@ -210,6 +217,10 @@ export class ClaudeAcpAgent extends BaseAcpAgent { } async loadSession(params: LoadSessionRequest): Promise { + // Reuse existing session if it matches + const existing = this.getExistingSessionState(params.sessionId); + if (existing) return existing; + const response = await this.createSession( { cwd: params.cwd, @@ -231,7 +242,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { }; } - async unstable_listSessions( + async listSessions( params: ListSessionsRequest, ): Promise { const sdkSessions = await listSessions({ dir: params.cwd ?? undefined }); @@ -251,6 +262,12 @@ export class ClaudeAcpAgent extends BaseAcpAgent { }; } + async unstable_listSessions( + params: ListSessionsRequest, + ): Promise { + return this.listSessions(params); + } + async prompt(params: PromptRequest): Promise { this.session.cancelled = false; this.session.interruptReason = undefined; @@ -262,18 +279,40 @@ export class ClaudeAcpAgent extends BaseAcpAgent { }; const userMessage = promptToClaude(params); + const promptUuid = randomUUID(); + userMessage.uuid = promptUuid; + let promptReplayed = false; + let isLocalOnlyCommand = false; + + // Detect local-only slash commands that return results without model invocation + const msgContent = userMessage.message.content; + let firstTextPart = ""; + if (typeof msgContent === "string") { + firstTextPart = msgContent; + } else if (Array.isArray(msgContent)) { + for (const block of msgContent) { + if ("type" in block && block.type === "text" && "text" in block) { + firstTextPart = block.text as string; + break; + } + } + } + const commandMatch = firstTextPart.match(/^(\/\S+)/); + if (commandMatch && LOCAL_ONLY_COMMANDS.has(commandMatch[1])) { + isLocalOnlyCommand = true; + promptReplayed = true; + } if (this.session.promptRunning) { - const uuid = randomUUID(); - userMessage.uuid = uuid; this.session.input.push(userMessage); const order = this.session.nextPendingOrder++; const cancelled = await new Promise((resolve) => { - this.session.pendingMessages.set(uuid, { resolve, order }); + this.session.pendingMessages.set(promptUuid, { resolve, order }); }); if (cancelled) { return { stopReason: "cancelled" }; } + promptReplayed = true; } else { this.session.input.push(userMessage); } @@ -322,11 +361,24 @@ export class ClaudeAcpAgent extends BaseAcpAgent { case "system": if (message.subtype === "compact_boundary") { lastAssistantTotalUsage = 0; + promptReplayed = true; + } + if (message.subtype === "local_command_output") { + promptReplayed = true; } await handleSystemMessage(message, context); break; case "result": { + // Skip results from background tasks that finished after our prompt started + if (!promptReplayed) { + this.logger.debug( + "Skipping background task result before prompt replay", + { sessionId: params.sessionId }, + ); + break; + } + if (this.session.cancelled) { return { stopReason: "cancelled" }; } @@ -398,6 +450,21 @@ export class ClaudeAcpAgent extends BaseAcpAgent { const result = handleResultMessage(message); if (result.error) throw result.error; + // For local-only commands, forward the result text to the client + if ( + isLocalOnlyCommand && + message.subtype === "success" && + message.result + ) { + await this.client.sessionUpdate({ + sessionId: params.sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: message.result }, + }, + }); + } + return { stopReason: result.stopReason ?? "end_turn", usage }; } @@ -411,8 +478,13 @@ export class ClaudeAcpAgent extends BaseAcpAgent { break; } - // Check for queued prompt replay + // Check for prompt replay (our own message echoed back) if (message.type === "user" && "uuid" in message && message.uuid) { + if (message.uuid === promptUuid) { + promptReplayed = true; + break; + } + const pending = this.session.pendingMessages.get( message.uuid as string, ); @@ -490,6 +562,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { this.logger.error(`Process died: ${msg}`, { sessionId: this.sessionId, }); + this.session.settingsManager.dispose(); this.session.input.end(); throw RequestError.internalError( undefined, @@ -548,43 +621,70 @@ export class ClaudeAcpAgent extends BaseAcpAgent { throw new Error(`Unknown config option: ${params.configId}`); } - const allValues: { value: string }[] = + if (typeof params.value !== "string") { + throw new Error( + `Invalid value type for config option ${params.configId}`, + ); + } + + const allValues: { value: string; name?: string; description?: string }[] = "options" in option && Array.isArray(option.options) ? (option.options as Array>).flatMap((o) => "options" in o && Array.isArray(o.options) - ? (o.options as { value: string }[]) - : [o as { value: string }], + ? (o.options as { + value: string; + name?: string; + description?: string; + }[]) + : [o as { value: string; name?: string; description?: string }], ) : []; - const validValue = allValues.find((o) => o.value === params.value); + let validValue = allValues.find((o) => o.value === params.value); + + // For model options, fall back to alias resolution when exact match fails. + // This lets callers use human-friendly aliases like "opus" or "sonnet" + // instead of full model IDs like "claude-opus-4-6". + if (!validValue && params.configId === "model") { + const resolved = resolveModelPreference(params.value, allValues); + if (resolved) { + validValue = allValues.find((o) => o.value === resolved); + } + } + if (!validValue) { throw new Error( `Invalid value for config option ${params.configId}: ${params.value}`, ); } + // Use the canonical option value so downstream code always receives the + // model ID rather than the caller-supplied alias. + const resolvedValue = validValue.value; + if (params.configId === "mode") { - await this.applySessionMode(params.value); + await this.applySessionMode(resolvedValue); await this.client.sessionUpdate({ sessionId: this.sessionId, update: { sessionUpdate: "current_mode_update", - currentModeId: params.value, + currentModeId: resolvedValue, }, }); } else if (params.configId === "model") { - const sdkModelId = toSdkModelId(params.value); + const sdkModelId = toSdkModelId(resolvedValue); await this.session.query.setModel(sdkModelId); - this.session.modelId = params.value; - this.rebuildEffortConfigOption(params.value); + this.session.modelId = resolvedValue; + this.rebuildEffortConfigOption(resolvedValue); } else if (params.configId === "effort") { - const newEffort = params.value as EffortLevel; + const newEffort = resolvedValue as EffortLevel; this.session.effort = newEffort; this.session.queryOptions.effort = newEffort; } this.session.configOptions = this.session.configOptions.map((o) => - o.id === params.configId ? { ...o, currentValue: params.value } : o, + o.id === params.configId && typeof o.currentValue === "string" + ? { ...o, currentValue: resolvedValue } + : o, ); return { configOptions: this.session.configOptions }; @@ -595,7 +695,9 @@ export class ClaudeAcpAgent extends BaseAcpAgent { value: string, ): Promise { this.session.configOptions = this.session.configOptions.map((o) => - o.id === configId ? { ...o, currentValue: value } : o, + o.id === configId && typeof o.currentValue === "string" + ? { ...o, currentValue: value } + : o, ); await this.client.sessionUpdate({ @@ -691,7 +793,10 @@ export class ClaudeAcpAgent extends BaseAcpAgent { sessionId, isResume, forkSession, - additionalDirectories: meta?.claudeCode?.options?.additionalDirectories, + additionalDirectories: [ + ...(meta?.claudeCode?.options?.additionalDirectories ?? []), + ...(meta?.additionalRoots ?? []), + ], disableBuiltInTools: meta?.disableBuiltInTools, settingsManager, onModeChange: this.createOnModeChange(), @@ -869,6 +974,50 @@ export class ClaudeAcpAgent extends BaseAcpAgent { }; } + private getExistingSessionState( + sessionId: string, + ): NewSessionResponse | null { + if (this.sessionId !== sessionId || !this.session) return null; + + const availableModes = getAvailableModes(); + const modes: SessionModeState = { + currentModeId: this.session.permissionMode, + availableModes: availableModes.map((mode) => ({ + id: mode.id, + name: mode.name, + description: mode.description ?? undefined, + })), + }; + + const modelOptions = this.session.configOptions.find( + (o) => o.id === "model", + ); + const models: SessionModelState = { + currentModelId: this.session.modelId ?? DEFAULT_MODEL, + availableModels: + modelOptions && "options" in modelOptions + ? ( + modelOptions.options as Array<{ + value: string; + name: string; + description?: string; + }> + ).map((opt) => ({ + modelId: opt.value, + name: opt.name, + description: opt.description, + })) + : [], + }; + + return { + sessionId, + modes, + models, + configOptions: this.session.configOptions, + }; + } + private buildConfigOptions( currentModeId: string, modelOptions: { @@ -938,7 +1087,9 @@ export class ClaudeAcpAgent extends BaseAcpAgent { return; } - const currentValue = existingEffort?.currentValue ?? "high"; + const rawCurrentValue = existingEffort?.currentValue; + const currentValue = + typeof rawCurrentValue === "string" ? rawCurrentValue : "high"; const isValidValue = effortOptions.some((o) => o.value === currentValue); const resolvedValue = isValidValue ? currentValue : "high"; diff --git a/packages/agent/src/adapters/claude/conversion/sdk-to-acp.ts b/packages/agent/src/adapters/claude/conversion/sdk-to-acp.ts index 21186da5c..4d0f65d00 100644 --- a/packages/agent/src/adapters/claude/conversion/sdk-to-acp.ts +++ b/packages/agent/src/adapters/claude/conversion/sdk-to-acp.ts @@ -797,30 +797,6 @@ export async function handleUserAssistantMessage( context; if (shouldSkipUserAssistantMessage(message)) { - const content = message.message.content; - - // Handle /context by sending its reply as a regular agent message - if ( - typeof content === "string" && - hasLocalCommandStdout(content) && - content.includes("Context Usage") - ) { - const stripped = content - .replace("", "") - .replace("", ""); - for (const notification of toAcpNotifications( - stripped, - "assistant", - sessionId, - toolUseCache, - fileContentCache, - client, - logger, - )) { - await client.sessionUpdate(notification); - } - } - logSpecialMessages(message, logger); if (isLoginRequiredMessage(message)) { diff --git a/packages/agent/src/adapters/claude/permissions/permission-handlers.ts b/packages/agent/src/adapters/claude/permissions/permission-handlers.ts index a35b68103..cd587005d 100644 --- a/packages/agent/src/adapters/claude/permissions/permission-handlers.ts +++ b/packages/agent/src/adapters/claude/permissions/permission-handlers.ts @@ -34,7 +34,7 @@ export type ToolPermissionResult = | { behavior: "deny"; message: string; - interrupt: boolean; + interrupt?: boolean; }; interface ToolHandlerContext { @@ -164,9 +164,11 @@ async function applyPlanApproval( if ( response.outcome?.outcome === "selected" && (response.outcome.optionId === "default" || - response.outcome.optionId === "acceptEdits") + response.outcome.optionId === "acceptEdits" || + response.outcome.optionId === "bypassPermissions") ) { - session.permissionMode = response.outcome.optionId; + session.permissionMode = response.outcome + .optionId as typeof session.permissionMode; await session.query.setPermissionMode(response.outcome.optionId); await context.client.sessionUpdate({ sessionId: context.sessionId, @@ -261,7 +263,6 @@ async function handleAskUserQuestionTool( return { behavior: "deny", message: "No questions provided", - interrupt: true, }; } @@ -303,7 +304,6 @@ async function handleAskUserQuestionTool( typeof customMessage === "string" ? customMessage : "User cancelled the questions", - interrupt: true, }; } @@ -312,7 +312,6 @@ async function handleAskUserQuestionTool( return { behavior: "deny", message: "User did not provide answers", - interrupt: true, }; } @@ -393,7 +392,6 @@ async function handleDefaultPermissionFlow( return { behavior: "deny", message, - interrupt: true, }; } } diff --git a/packages/agent/src/adapters/claude/permissions/permission-options.ts b/packages/agent/src/adapters/claude/permissions/permission-options.ts index 723353391..551be7c31 100644 --- a/packages/agent/src/adapters/claude/permissions/permission-options.ts +++ b/packages/agent/src/adapters/claude/permissions/permission-options.ts @@ -1,4 +1,5 @@ import type { PermissionUpdate } from "@anthropic-ai/claude-agent-sdk"; +import { IS_ROOT } from "../../../utils/common"; import { BASH_TOOLS, READ_TOOLS, SEARCH_TOOLS, WRITE_TOOLS } from "../tools"; export interface PermissionOption { @@ -91,8 +92,20 @@ export function buildPermissionOptions( return permissionOptions("Yes, always allow"); } +const ALLOW_BYPASS = !IS_ROOT || !!process.env.IS_SANDBOX; + export function buildExitPlanModePermissionOptions(): PermissionOption[] { - return [ + const options: PermissionOption[] = []; + + if (ALLOW_BYPASS) { + options.push({ + kind: "allow_always", + name: "Yes, bypass all permissions", + optionId: "bypassPermissions", + }); + } + + options.push( { kind: "allow_always", name: "Yes, and auto-accept edits", @@ -109,5 +122,7 @@ export function buildExitPlanModePermissionOptions(): PermissionOption[] { optionId: "reject_with_feedback", _meta: { customInput: true }, }, - ]; + ); + + return options; } diff --git a/packages/agent/src/adapters/claude/session/models.ts b/packages/agent/src/adapters/claude/session/models.ts index 22884ebb1..034cf46c1 100644 --- a/packages/agent/src/adapters/claude/session/models.ts +++ b/packages/agent/src/adapters/claude/session/models.ts @@ -61,3 +61,97 @@ export function getEffortOptions(modelId: string): EffortOption[] | null { return options; } + +// Model alias resolution — lets callers use human-friendly aliases like +// "opus" or "sonnet" instead of full model IDs like "claude-opus-4-6". + +const MODEL_CONTEXT_HINT_PATTERN = /\[(\d+m)\]$/i; + +function tokenizeModelPreference(model: string): { + tokens: string[]; + contextHint?: string; +} { + const lower = model.trim().toLowerCase(); + const contextHint = lower + .match(MODEL_CONTEXT_HINT_PATTERN)?.[1] + ?.toLowerCase(); + + const normalized = lower.replace(MODEL_CONTEXT_HINT_PATTERN, " $1 "); + const rawTokens = normalized.split(/[^a-z0-9]+/).filter(Boolean); + const tokens = rawTokens + .map((token) => { + if (token === "opusplan") return "opus"; + if (token === "best" || token === "default") return ""; + return token; + }) + .filter((token) => token && token !== "claude") + .filter((token) => /[a-z]/.test(token) || token.endsWith("m")); + + return { tokens, contextHint }; +} + +interface ModelOption { + value: string; + name?: string; + description?: string; +} + +function scoreModelMatch( + model: ModelOption, + tokens: string[], + contextHint?: string, +): number { + const haystack = `${model.value} ${model.name ?? ""}`.toLowerCase(); + let score = 0; + for (const token of tokens) { + if (haystack.includes(token)) { + score += token === contextHint ? 3 : 1; + } + } + return score; +} + +export function resolveModelPreference( + preference: string, + options: ModelOption[], +): string | null { + const trimmed = preference.trim(); + if (!trimmed) return null; + + const lower = trimmed.toLowerCase(); + + // Exact match on value or display name + const directMatch = options.find( + (o) => + o.value === trimmed || + o.value.toLowerCase() === lower || + (o.name && o.name.toLowerCase() === lower), + ); + if (directMatch) return directMatch.value; + + // Substring match + const includesMatch = options.find((o) => { + const value = o.value.toLowerCase(); + const display = (o.name ?? "").toLowerCase(); + return ( + value.includes(lower) || display.includes(lower) || lower.includes(value) + ); + }); + if (includesMatch) return includesMatch.value; + + // Tokenized matching for aliases like "opus[1m]" + const { tokens, contextHint } = tokenizeModelPreference(trimmed); + if (tokens.length === 0) return null; + + let bestMatch: ModelOption | null = null; + let bestScore = 0; + for (const model of options) { + const score = scoreModelMatch(model, tokens, contextHint); + if (0 < score && (!bestMatch || bestScore < score)) { + bestMatch = model; + bestScore = score; + } + } + + return bestMatch?.value ?? null; +} diff --git a/packages/agent/src/adapters/claude/types.ts b/packages/agent/src/adapters/claude/types.ts index b4ffdd17f..453c1a44f 100644 --- a/packages/agent/src/adapters/claude/types.ts +++ b/packages/agent/src/adapters/claude/types.ts @@ -104,6 +104,7 @@ export type NewSessionMeta = { sessionId?: string; permissionMode?: string; persistence?: { taskId?: string; runId?: string; logUrl?: string }; + additionalRoots?: string[]; claudeCode?: { options?: Options; }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 11c0958f4..87be9b29c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -592,11 +592,11 @@ importers: packages/agent: dependencies: '@agentclientprotocol/sdk': - specifier: 0.15.0 - version: 0.15.0(zod@3.25.76) + specifier: 0.16.1 + version: 0.16.1(zod@3.25.76) '@anthropic-ai/claude-agent-sdk': - specifier: 0.2.71 - version: 0.2.71(zod@3.25.76) + specifier: 0.2.76 + version: 0.2.76(zod@3.25.76) '@anthropic-ai/sdk': specifier: ^0.78.0 version: 0.78.0(zod@3.25.76) @@ -751,8 +751,8 @@ packages: '@adobe/css-tools@4.4.4': resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} - '@agentclientprotocol/sdk@0.15.0': - resolution: {integrity: sha512-TH4utu23Ix8ec34srBHmDD4p3HI0cYleS1jN9lghRczPfhFlMBNrQgZWeBBe12DWy27L11eIrtciY2MXFSEiDg==} + '@agentclientprotocol/sdk@0.16.1': + resolution: {integrity: sha512-1ad+Sc/0sCtZGHthxxvgEUo5Wsbw16I+aF+YwdiLnPwkZG8KAGUEAPK6LM6Pf69lCyJPt1Aomk1d+8oE3C4ZEw==} peerDependencies: zod: ^3.25.0 || ^4.0.0 @@ -764,8 +764,8 @@ packages: resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} - '@anthropic-ai/claude-agent-sdk@0.2.71': - resolution: {integrity: sha512-pIsQJnM7Y+cJHL7aFY6SCCW3FIni218gVEpPqG8XGowfYxboFNBbNssWiUNRwthT8bp9jypcX7q5kx0Xsw14xg==} + '@anthropic-ai/claude-agent-sdk@0.2.76': + resolution: {integrity: sha512-HZxvnT8ZWkzCnQygaYCA0dl8RSUzuVbxE1YG4ecy6vh4nQbTT36CxUxBy+QVdR12pPQluncC0mCOLhI2918Eaw==} engines: {node: '>=18.0.0'} peerDependencies: zod: ^4.0.0 @@ -11028,7 +11028,7 @@ snapshots: '@adobe/css-tools@4.4.4': {} - '@agentclientprotocol/sdk@0.15.0(zod@3.25.76)': + '@agentclientprotocol/sdk@0.16.1(zod@3.25.76)': dependencies: zod: 3.25.76 @@ -11039,7 +11039,7 @@ snapshots: '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 - '@anthropic-ai/claude-agent-sdk@0.2.71(zod@3.25.76)': + '@anthropic-ai/claude-agent-sdk@0.2.76(zod@3.25.76)': dependencies: zod: 3.25.76 optionalDependencies: