mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
* feat(native-chat): sync per-agent permission defaults with explicit host support * test: include native permission capability in host settings mock
277 lines
12 KiB
TypeScript
277 lines
12 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,
|
|
newWorkspaceSetupOnly: false,
|
|
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 }
|
|
: {}),
|
|
...(settings.agentNativeChatPermissionModes
|
|
? { agentNativeChatPermissionModes: settings.agentNativeChatPermissionModes }
|
|
: {}),
|
|
// 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
|
|
// Legacy clients can select an agent or a shell, but cannot select None.
|
|
this.store.updateSettings(
|
|
{
|
|
...updates,
|
|
...(updates.nativeChatPermissionMode !== undefined
|
|
? { agentNativeChatPermissionModes: {} }
|
|
: {}),
|
|
...(updates.defaultTuiAgent !== undefined ? { newWorkspaceSetupOnly: false } : {})
|
|
},
|
|
{ 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]] : []
|
|
})
|
|
)
|
|
}
|
|
}
|