Files
orca/src/main/runtime/runtime-client-settings.ts
T
Brennan Benson 2233fd3669 feat(native-chat): each Claude/Codex chat gets its own permission mode (#26116)
Each native Claude and Codex chat now has its own permission mode (Ask for approval, Accept edits for Claude, Approve for me, Full access), shown and changed from a button right after "+" in the composer. A change takes effect before the next message: the agent restarts quietly and resumes the same conversation, never interrupting a reply or background work. New chats start from Settings → Chat → Permissions, which is seeded once from Agent Permissions. The mode is stored as an ordinary chat option, and the restart reuses the existing "replace the agent before the next message" step.
2026-10-12 15:36:45 -07:00

263 lines
11 KiB
TypeScript

import { getAppEnvironment } from '../../shared/app-environment'
import { isArtifactSharingEnabled } from '../../shared/artifact-sharing-gate'
import { isAgentSkillSharingEnabled } from '../../shared/agent-skill-sharing-gate'
import { applyPRBotAuthorOverride } from '../../shared/pr-bot-author-overrides'
import { TASK_PROVIDERS } from '../../shared/task-providers'
import {
applyTerminalQuickCommandMutation,
MAX_QUICK_COMMANDS,
type TerminalQuickCommandMutation
} from '../../shared/terminal-quick-commands'
import { haveSameDisabledTuiAgents } from '../../shared/tui-agent-selection'
import { normalizeSourceControlAiSettings } from '../../shared/source-control-ai'
import { SOURCE_CONTROL_LAUNCH_ACTION_IDS } from '../../shared/source-control-ai-actions'
import type { GlobalSettings } from '../../shared/global-settings-types'
import { applyNativeChatSessionOptionSettingsMutation } from '../../shared/native-chat-session-option-defaults'
import type { NativeChatSessionOptionSettingsMutation } from '../../shared/native-chat-session-options'
import { getHostDisplayLabelOverrides } from '../../shared/host-setting-overrides'
import type { TerminalQuickCommand } from '../../shared/terminal-quick-command-types'
import { recordManagedHookInstallFailure } from '../agent-hooks/install-telemetry'
import { applyAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls'
import { isAgentStatusHooksEnabledForAgent } from '../../shared/agent-status-hooks-setting'
import type { RuntimeStore } from './runtime-store-contract'
import {
agentLaunchSettingsMutationUpdates,
projectAgentLaunchSettings,
type AgentLaunchSettings,
type AgentLaunchSettingsMutation
} from '../../shared/agent-launch-settings'
import { agentLaunchSettingsRollbackUpdates } from './agent-launch-settings-rollback'
import type {
RuntimeClientSettings,
RuntimeClientSettingsUpdate,
RuntimeClientSourceControlAi,
RuntimeHostDisplayLabelOverrides
} from './runtime-client-settings-shape'
export class RuntimeClientSettingsController {
private reconciliationGeneration = 0
private reconciliationTail: Promise<void> = Promise.resolve()
constructor(
private readonly store: Pick<
RuntimeStore,
'getSettings' | 'updateSettings' | 'runDurableMutation'
> | null,
private readonly notifyReposChanged: (() => void) | undefined = undefined
) {}
get(): RuntimeClientSettings {
if (!this.store?.getSettings) {
throw new Error('runtime_unavailable')
}
const settings = this.store.getSettings()
return {
defaultTuiAgent: settings.defaultTuiAgent ?? null,
disabledTuiAgents: settings.disabledTuiAgents ?? [],
agentCmdOverrides: settings.agentCmdOverrides ?? {},
agentDefaultArgs: settings.agentDefaultArgs ?? {},
agentDefaultEnv: settings.agentDefaultEnv ?? {},
agentStatusHooksEnabled: settings.agentStatusHooksEnabled !== false,
// Why projected: mobile's terminal Copy honours this, and a host predating
// the setting sends no key, which the client reads as on (#19770).
terminalCopyTrimsGutter: settings.terminalCopyTrimsGutter !== false,
defaultTaskSource: settings.defaultTaskSource ?? 'github',
defaultTaskViewPreset: settings.defaultTaskViewPreset ?? 'issues',
visibleTaskProviders: settings.visibleTaskProviders ?? [...TASK_PROVIDERS],
defaultRepoSelection: settings.defaultRepoSelection ?? null,
// Persisted settings can violate the paired client's string-array contract.
defaultLinearTeamSelection: Array.isArray(settings.defaultLinearTeamSelection)
? settings.defaultLinearTeamSelection.filter((id): id is string => typeof id === 'string')
: null,
githubProjects: settings.githubProjects,
experimentalNewWorktreeCardStyle: settings.experimentalNewWorktreeCardStyle === true,
experimentalNativeChat: settings.experimentalNativeChat === true,
// Older clients use this key to fall back to terminal-backed chat. Keep their default terminal.
openAgentTabsInChatByDefault: false,
experimentalStructuredNativeChat: settings.experimentalNativeChat === true,
compactWorktreeCards: settings.compactWorktreeCards === true,
minimaxGroupId: settings.minimaxGroupId ?? '',
minimaxUsageModels: settings.minimaxUsageModels ?? 'general',
minimaxEndpoint: settings.minimaxEndpoint ?? 'overseas',
zcodePlanSite: settings.zcodePlanSite ?? 'zai',
prBotAuthorOverrides: settings.prBotAuthorOverrides ?? [],
artifactSharingEnabled: isArtifactSharingEnabled(settings),
worktreeVisibilityDefaults: settings.worktreeVisibilityDefaults ?? { external: 'hide' },
agentSkillSharingEnabled: isAgentSkillSharingEnabled(settings),
machineName: settings.machineName ?? '',
// Why projected: a web client's Settings → Chat edits this host's new-chat default.
...(settings.nativeChatPermissionMode
? { nativeChatPermissionMode: settings.nativeChatPermissionMode }
: {}),
// Why projected: a paired client's AI buttons start these actions' agents, and must honour
// the agent saved for each one as the desktop does. Absent on older hosts.
sourceControlAi: projectSourceControlLaunchRecipes(settings),
hostSettingOverrides: Object.fromEntries(
[
...getHostDisplayLabelOverrides({ hostSettingOverrides: settings.hostSettingOverrides })
].map(([hostId, displayLabel]) => [hostId, { displayLabel }])
) as RuntimeHostDisplayLabelOverrides
}
}
async update(updates: RuntimeClientSettingsUpdate): Promise<RuntimeClientSettings> {
if (!this.store?.getSettings || !this.store.updateSettings) {
throw new Error('runtime_unavailable')
}
const beforeSettings = this.store.getSettings()
const before = beforeSettings.agentStatusHooksEnabled !== false
this.store.updateSettings(updates, { notifyListeners: true })
const settings = this.store.getSettings()
if (updates.worktreeVisibilityDefaults !== undefined) {
this.notifyReposChanged?.()
}
if (
(typeof updates.agentStatusHooksEnabled === 'boolean' &&
before !== updates.agentStatusHooksEnabled) ||
(updates.disabledTuiAgents !== undefined &&
!haveSameDisabledTuiAgents(beforeSettings.disabledTuiAgents, settings.disabledTuiAgents))
) {
await this.reconcileManagedAgentHooks()
}
return this.get()
}
getTerminalQuickCommands(): TerminalQuickCommand[] {
if (!this.store?.getSettings) {
throw new Error('runtime_unavailable')
}
return this.store.getSettings().terminalQuickCommands ?? []
}
getAgentLaunch(): AgentLaunchSettings {
if (!this.store?.getSettings) {
throw new Error('runtime_unavailable')
}
return projectAgentLaunchSettings(this.store.getSettings())
}
async mutateAgentLaunch(mutation: AgentLaunchSettingsMutation): Promise<AgentLaunchSettings> {
const store = this.store
if (!store?.getSettings || !store.updateSettings || !store.runDurableMutation) {
throw new Error('runtime_unavailable')
}
const applySettings = store.updateSettings.bind(store)
const committed = await store.runDurableMutation(() => {
const before = store.getSettings()
const updates = agentLaunchSettingsMutationUpdates(before, mutation, process.platform)
applySettings(updates, { notifyListeners: true })
const applied = store.getSettings()
return {
value: {
settings: projectAgentLaunchSettings(applied),
reconcileHooks:
updates.disabledTuiAgents !== undefined &&
!haveSameDisabledTuiAgents(before.disabledTuiAgents, applied.disabledTuiAgents)
},
rollback: () => {
const rollback = agentLaunchSettingsRollbackUpdates(
before,
applied,
store.getSettings(),
updates
)
if (Object.keys(rollback).length > 0) {
applySettings(rollback, { notifyListeners: true })
}
}
}
})
if (committed.reconcileHooks) {
void this.reconcileManagedAgentHooks().catch((error) => {
console.error(
'[agent-hooks] Failed to reconcile managed hooks after saving launch settings:',
error
)
})
}
return committed.settings
}
updateTerminalQuickCommands(mutation: TerminalQuickCommandMutation): TerminalQuickCommand[] {
if (!this.store?.getSettings || !this.store.updateSettings) {
throw new Error('runtime_unavailable')
}
const current = this.getTerminalQuickCommands()
if (
mutation.type === 'upsert' &&
!current.some((command) => command.id === mutation.command.id) &&
current.length >= MAX_QUICK_COMMANDS
) {
throw new Error('Quick command limit reached')
}
const next = applyTerminalQuickCommandMutation(current, mutation)
this.store.updateSettings({ terminalQuickCommands: next }, { notifyListeners: true })
return this.getTerminalQuickCommands()
}
updatePRBotAuthorOverride(args: { author: string; isBot: boolean }): RuntimeClientSettings {
if (!this.store?.getSettings || !this.store.updateSettings) {
throw new Error('runtime_unavailable')
}
const current = this.store.getSettings().prBotAuthorOverrides
this.store.updateSettings(
{ prBotAuthorOverrides: applyPRBotAuthorOverride(current, args.author, args.isBot) },
{ notifyListeners: true }
)
return this.get()
}
updateNativeChatSessionOptions(mutation: NativeChatSessionOptionSettingsMutation): void {
if (!this.store?.getSettings || !this.store.updateSettings) {
throw new Error('runtime_unavailable')
}
const next = applyNativeChatSessionOptionSettingsMutation(
this.store.getSettings().nativeChatSessionOptions,
mutation
)
if (next) {
this.store.updateSettings({ nativeChatSessionOptions: next }, { notifyListeners: true })
}
}
private reconcileManagedAgentHooks(): Promise<void> {
const generation = ++this.reconciliationGeneration
const reconciliation = this.reconciliationTail.then(async () => {
if (generation !== this.reconciliationGeneration) {
return
}
const settings = this.store?.getSettings()
if (!settings) {
return
}
await applyAgentStatusHooksEnabled(settings.agentStatusHooksEnabled !== false, settings, {
shouldHydrateShellPath: getAppEnvironment().isPackaged(),
onInstallError: recordManagedHookInstallFailure,
shouldContinue: (agent) => {
const current = this.store?.getSettings()
return current !== undefined && isAgentStatusHooksEnabledForAgent(current, agent)
}
})
})
this.reconciliationTail = reconciliation.catch(() => {})
return reconciliation
}
}
function projectSourceControlLaunchRecipes(
settings: Partial<Pick<GlobalSettings, 'sourceControlAi' | 'commitMessageAi'>>
): RuntimeClientSourceControlAi {
const { actions } = normalizeSourceControlAiSettings(
settings.sourceControlAi,
settings.commitMessageAi
)
return {
actions: Object.fromEntries(
SOURCE_CONTROL_LAUNCH_ACTION_IDS.flatMap((actionId) => {
const recipe = actions?.[actionId]
return recipe ? [[actionId, recipe]] : []
})
)
}
}