mirror of
https://github.com/stablyai/orca.git
synced 2026-11-01 17:47:40 +00:00
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.
263 lines
11 KiB
TypeScript
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]] : []
|
|
})
|
|
)
|
|
}
|
|
}
|