mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 00:27:39 +00:00
* refactor(agent-hooks): one predicate for whether an agent's status hooks are on "Global switch on and this agent not turned off" was spelled out separately in the startup controls, the settings reconcile, the retained-home reconcile, the WSL preflight RPC, the CLI preflight and the OpenCode plugin selection. They now share one function, in a module light enough for the CLI's per-launch Codex preflight to load. The PTY spawn env derives the Codex flag from the switch and opt-out list it already carries, the same way it does for OpenCode and Pi, instead of receiving a second copy. * fix(codex): launch and resume prep honour Codex's per-agent hook opt-out Turning Codex off in the per-agent hook settings removes Orca's Codex hook entry, but launch prep and session resume read only the global hooks switch, so the next Codex launch or resume wrote the entry straight back into the real ~/.codex or the account's home. Both now read the per-agent predicate, which the PTY spawn env and startup already honoured. * fix(codex): turning Codex off per agent clears the real ~/.codex entry While the real-home lane owns ~/.codex/hooks.json, the legacy system-home sweep stands down. That gate read only the global switch, so turning Codex off per agent ran remove() with the sweep still suppressed and left Orca's entry in the real ~/.codex. The gate now reads the per-agent predicate, the same as turning every hook off. * test(codex): cover the system ~/.codex sweep gate for Codex turned off The gate that lets the legacy system-home sweep run was an inline closure in startup, so reverting it to the global switch left CI green. It is now a pure function beside the gate it feeds, with a table test and a remove() test on a seeded ~/.codex: turning Codex off strips Orca's entry and keeps user hooks; with Codex on the entry stays. * fix(cli): keep the agent-status hooks predicate loadable by the packaged CLI The CLI's prepare-codex handler imported the predicate from src/main, but the Electron build rebuilds out/main from its declared entries only, so the packaged `orca agent hooks` commands could not load it (package jobs and the CLI bundle-parity test were red). The predicate reads only settings, so it now lives in src/shared, which the CLI compiles itself.
341 lines
14 KiB
TypeScript
341 lines
14 KiB
TypeScript
import { app, BrowserWindow, ipcMain, nativeTheme } from 'electron'
|
|
import type { Store } from '../persistence'
|
|
import type { GlobalSettings } from '../../shared/global-settings-types'
|
|
import type { PersistedState } from '../../shared/persisted-state-types'
|
|
import { listSystemFontFamilies } from '../system-fonts'
|
|
import { previewGhosttyImport } from '../ghostty/index'
|
|
import { previewWarpThemeImport } from '../warp-themes'
|
|
import { setMainUiLanguage } from '../i18n/main-i18n'
|
|
import { rebuildAppMenu } from '../menu/register-app-menu'
|
|
import { track } from '../telemetry/client'
|
|
import { SETTINGS_CHANGED_WHITELIST, type SettingsChangedKey } from '../../shared/telemetry-events'
|
|
import type { AgentAwakeService } from '../agent-awake-service'
|
|
import { sanitizeFloatingWorkspaceDirectorySetting } from './floating-workspace-directory'
|
|
import { applyAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls'
|
|
import { isAgentStatusHooksEnabledForAgent } from '../../shared/agent-status-hooks-setting'
|
|
import { recordManagedHookInstallFailure } from '../agent-hooks/install-telemetry'
|
|
import { applyElectronProxySettings } from '../network/proxy-settings'
|
|
import { applyBrowserSessionProxies } from '../browser/browser-session-proxy'
|
|
import { browserSessionRegistry } from '../browser/browser-session-registry'
|
|
import { normalizeProxyBypassRules, normalizeProxyUrl } from '../../shared/network-proxy'
|
|
import { normalizeAppIconId } from '../../shared/app-icon'
|
|
import { normalizeUiLanguage } from '../../shared/ui-language'
|
|
import { applyAppIcon } from '../app-icon'
|
|
import { normalizeTerminalCustomThemes } from '../../shared/terminal-custom-themes'
|
|
import { normalizeDesktopTerminalScrollbackRows } from '../../shared/terminal-scrollback-policy'
|
|
import { normalizeTerminalLineHeight } from '../../shared/terminal-line-height-settings'
|
|
import { prepareLocalWorktreeRootsForRepos } from '../worktree-root-preparation'
|
|
import { scheduleCurrentWorktreeBaseDirectoryWatcherSync } from './worktree-base-directory-watcher'
|
|
import { applyPRBotAuthorOverride } from '../../shared/pr-bot-author-overrides'
|
|
import { resolveEnvironment } from '../../shared/runtime-environment-store'
|
|
import { haveSameDisabledTuiAgents } from '../../shared/tui-agent-selection'
|
|
import {
|
|
normalizeMobilePairingCustomAddress,
|
|
normalizeMobilePairingCustomAddresses
|
|
} from '../../shared/mobile-pairing-custom-address'
|
|
import {
|
|
computerAwakeSettingsForMode,
|
|
normalizeComputerAwakeMode
|
|
} from '../../shared/computer-awake-mode'
|
|
import { resolveAiVaultSearchSettings } from '../../shared/ai-vault-search-settings'
|
|
import { applySessionSearchSettingsChange } from '../ai-vault-search/session-search-enablement'
|
|
|
|
// Why: the whitelist is the source-of-truth for which keys we emit on. Casting
|
|
// to a Set once at module load lets the IPC handler's per-key membership
|
|
// check stay O(1) without re-coercing the readonly tuple on every call.
|
|
const SETTINGS_CHANGED_WHITELIST_SET = new Set<string>(SETTINGS_CHANGED_WHITELIST)
|
|
|
|
type LegacyTerminalScrollbackSettingsUpdate = Partial<GlobalSettings> & {
|
|
terminalScrollbackBytes?: unknown
|
|
}
|
|
|
|
function sanitizeRendererSettingsUpdate(args: Partial<GlobalSettings>): Partial<GlobalSettings> {
|
|
const { terminalScrollbackBytes: _legacyScrollbackBytes, ...sanitizedArgs } =
|
|
args as LegacyTerminalScrollbackSettingsUpdate
|
|
void _legacyScrollbackBytes
|
|
// Plugin consent and enablement are main-owned authority state. Renderer
|
|
// writes must pass the dedicated reviewed-fingerprint handlers.
|
|
delete sanitizedArgs.pluginConsents
|
|
delete sanitizedArgs.disabledPlugins
|
|
return sanitizedArgs
|
|
}
|
|
|
|
// Why: fields that appear in the View > Appearance submenu need the menu
|
|
// rebuilt after any update so the checkbox `checked` state stays in sync
|
|
// with the persisted value. Electron doesn't reactively re-render menu
|
|
// items when the backing state changes.
|
|
const APPEARANCE_MENU_KEYS: readonly (keyof GlobalSettings)[] = [
|
|
'showTasksButton',
|
|
'showAutomationsButton',
|
|
'showMobileButton',
|
|
'showTitlebarAppName'
|
|
]
|
|
|
|
export function registerSettingsHandlers(
|
|
store: Store,
|
|
agentAwakeService?: AgentAwakeService
|
|
): void {
|
|
ipcMain.handle(
|
|
'agentAwake:getStatus',
|
|
() => agentAwakeService?.getStatus() ?? { mode: 'off', active: false }
|
|
)
|
|
agentAwakeService?.subscribe?.((status) => {
|
|
for (const window of BrowserWindow.getAllWindows()) {
|
|
if (!window.isDestroyed()) {
|
|
window.webContents.send('agentAwake:changed', status)
|
|
}
|
|
}
|
|
})
|
|
|
|
store.onSettingsChanged((updates, _settings, originWebContentsId) => {
|
|
for (const window of BrowserWindow.getAllWindows()) {
|
|
const isOrigin =
|
|
originWebContentsId !== undefined && window.webContents.id === originWebContentsId
|
|
if (!window.isDestroyed() && !isOrigin) {
|
|
window.webContents.send('settings:changed', updates)
|
|
}
|
|
}
|
|
})
|
|
|
|
ipcMain.handle('settings:get', () => {
|
|
return store.getSettings()
|
|
})
|
|
|
|
ipcMain.handle(
|
|
'settings:update-pr-bot-author-override',
|
|
(event, args: { author: string; isBot: boolean }) => {
|
|
const current = store.getSettings().prBotAuthorOverrides
|
|
const next = applyPRBotAuthorOverride(current, args.author, args.isBot)
|
|
store.updateSettings(
|
|
{ prBotAuthorOverrides: next },
|
|
{ notifyListeners: true, originWebContentsId: event.sender.id }
|
|
)
|
|
return store.getSettings()
|
|
}
|
|
)
|
|
|
|
// Why: terminal panes can bind PTYs before async settings hydration
|
|
// completes. The side-effect authority kill switch is consulted once at
|
|
// transport creation, so the renderer needs the persisted value
|
|
// synchronously or pre-hydration bindings would always pick main authority
|
|
// (terminal-side-effect-authority.md, migration switch).
|
|
ipcMain.on('settings:get-sync', (event) => {
|
|
event.returnValue = store.getSettings()
|
|
})
|
|
|
|
ipcMain.handle('settings:set', async (event, args: Partial<GlobalSettings>) => {
|
|
const sanitizedArgs = sanitizeRendererSettingsUpdate(args)
|
|
// Why: connection/navigation code receives the generic settings writer; the
|
|
// durable server preference has a dedicated Advanced-control boundary.
|
|
delete sanitizedArgs.activeRuntimeEnvironmentId
|
|
// Why: Floating Workspace grants are trusted only when written by the
|
|
// main-process directory picker, never by renderer-provided settings IPC.
|
|
delete sanitizedArgs.floatingTerminalTrustedCwds
|
|
if ('computerAwakeMode' in sanitizedArgs) {
|
|
Object.assign(
|
|
sanitizedArgs,
|
|
computerAwakeSettingsForMode(
|
|
normalizeComputerAwakeMode(
|
|
sanitizedArgs.computerAwakeMode,
|
|
sanitizedArgs.keepComputerAwakeWhileAgentsRun
|
|
)
|
|
)
|
|
)
|
|
} else if ('keepComputerAwakeWhileAgentsRun' in sanitizedArgs) {
|
|
Object.assign(
|
|
sanitizedArgs,
|
|
computerAwakeSettingsForMode(sanitizedArgs.keepComputerAwakeWhileAgentsRun ? 'auto' : 'off')
|
|
)
|
|
}
|
|
if (typeof args.floatingTerminalCwd === 'string') {
|
|
sanitizedArgs.floatingTerminalCwd = await sanitizeFloatingWorkspaceDirectorySetting(
|
|
store,
|
|
args.floatingTerminalCwd
|
|
)
|
|
}
|
|
if ('httpProxyUrl' in args) {
|
|
const proxyUrl = normalizeProxyUrl(args.httpProxyUrl)
|
|
sanitizedArgs.httpProxyUrl = proxyUrl.ok ? proxyUrl.value : ''
|
|
}
|
|
if ('httpProxyBypassRules' in args) {
|
|
sanitizedArgs.httpProxyBypassRules = normalizeProxyBypassRules(args.httpProxyBypassRules)
|
|
}
|
|
if ('appIcon' in args) {
|
|
sanitizedArgs.appIcon = normalizeAppIconId(args.appIcon)
|
|
}
|
|
if ('aiVaultSearch' in args) {
|
|
sanitizedArgs.aiVaultSearch = resolveAiVaultSearchSettings(args)
|
|
}
|
|
if ('terminalCustomThemes' in args) {
|
|
sanitizedArgs.terminalCustomThemes = normalizeTerminalCustomThemes(args.terminalCustomThemes)
|
|
}
|
|
if ('terminalScrollbackRows' in args) {
|
|
sanitizedArgs.terminalScrollbackRows = normalizeDesktopTerminalScrollbackRows(
|
|
args.terminalScrollbackRows
|
|
)
|
|
}
|
|
if ('terminalLineHeight' in args) {
|
|
sanitizedArgs.terminalLineHeight = normalizeTerminalLineHeight(args.terminalLineHeight)
|
|
}
|
|
if ('uiLanguage' in args) {
|
|
sanitizedArgs.uiLanguage = normalizeUiLanguage(args.uiLanguage)
|
|
}
|
|
if ('mobilePairingCustomAddress' in args) {
|
|
sanitizedArgs.mobilePairingCustomAddress = normalizeMobilePairingCustomAddress(
|
|
args.mobilePairingCustomAddress
|
|
)
|
|
}
|
|
if ('mobilePairingCustomAddresses' in args) {
|
|
sanitizedArgs.mobilePairingCustomAddresses = normalizeMobilePairingCustomAddresses(
|
|
args.mobilePairingCustomAddresses
|
|
)
|
|
}
|
|
if (args.theme) {
|
|
nativeTheme.themeSource = args.theme
|
|
}
|
|
// Why: capture the pre-update value so we only emit when the value
|
|
// actually changes. The settings UI sometimes re-saves the same value
|
|
// (e.g. blur after a no-op edit), and a `settings_changed` event for a
|
|
// no-op flip would inflate the experimental-feature-adoption signal.
|
|
const before = store.getSettings()
|
|
const result = store.updateSettings(sanitizedArgs, {
|
|
notifyListeners: true,
|
|
originWebContentsId: event.sender.id
|
|
})
|
|
const proxySettingsChanged =
|
|
('httpProxyUrl' in sanitizedArgs && before.httpProxyUrl !== result.httpProxyUrl) ||
|
|
('httpProxyBypassRules' in sanitizedArgs &&
|
|
before.httpProxyBypassRules !== result.httpProxyBypassRules)
|
|
if (proxySettingsChanged) {
|
|
// Start both authorities before yielding so requests cannot enter between their barriers.
|
|
const defaultSessionApply = applyElectronProxySettings(result)
|
|
const browserSessionsApply = applyBrowserSessionProxies(
|
|
browserSessionRegistry.listProfiles(),
|
|
result
|
|
)
|
|
const [defaultSessionResult, browserSessionsResult] = await Promise.allSettled([
|
|
defaultSessionApply,
|
|
browserSessionsApply
|
|
])
|
|
if (defaultSessionResult.status === 'rejected') {
|
|
console.warn('[settings] failed to apply network proxy settings')
|
|
}
|
|
if (browserSessionsResult.status === 'rejected') {
|
|
console.warn('[settings] failed to apply network proxy settings to browser sessions')
|
|
}
|
|
}
|
|
if (
|
|
'computerAwakeMode' in sanitizedArgs ||
|
|
'keepComputerAwakeWhileAgentsRun' in sanitizedArgs
|
|
) {
|
|
agentAwakeService?.setMode(
|
|
normalizeComputerAwakeMode(result.computerAwakeMode, result.keepComputerAwakeWhileAgentsRun)
|
|
)
|
|
}
|
|
const hookSettingChanged =
|
|
('agentStatusHooksEnabled' in sanitizedArgs &&
|
|
before.agentStatusHooksEnabled !== result.agentStatusHooksEnabled) ||
|
|
('disabledTuiAgents' in sanitizedArgs &&
|
|
!haveSameDisabledTuiAgents(before.disabledTuiAgents, result.disabledTuiAgents))
|
|
if (hookSettingChanged) {
|
|
try {
|
|
await applyAgentStatusHooksEnabled(result.agentStatusHooksEnabled, result, {
|
|
userInitiated: true,
|
|
shouldHydrateShellPath: app.isPackaged,
|
|
onInstallError: recordManagedHookInstallFailure,
|
|
shouldContinue: (agent) => isAgentStatusHooksEnabledForAgent(store.getSettings(), agent)
|
|
})
|
|
} catch (error) {
|
|
console.warn('[settings] failed to reconcile managed agent hooks:', error)
|
|
}
|
|
}
|
|
if ('uiLanguage' in sanitizedArgs && before.uiLanguage !== result.uiLanguage) {
|
|
await setMainUiLanguage(result.uiLanguage)
|
|
rebuildAppMenu()
|
|
}
|
|
if (
|
|
('workspaceDir' in sanitizedArgs && before.workspaceDir !== result.workspaceDir) ||
|
|
('nestWorkspaces' in sanitizedArgs && before.nestWorkspaces !== result.nestWorkspaces)
|
|
) {
|
|
void prepareLocalWorktreeRootsForRepos(store)
|
|
scheduleCurrentWorktreeBaseDirectoryWatcherSync()
|
|
}
|
|
if (APPEARANCE_MENU_KEYS.some((key) => key in sanitizedArgs)) {
|
|
rebuildAppMenu()
|
|
}
|
|
if ('appIcon' in sanitizedArgs && before.appIcon !== result.appIcon) {
|
|
applyAppIcon(result.appIcon)
|
|
}
|
|
if ('aiVaultSearch' in sanitizedArgs) {
|
|
applySessionSearchSettingsChange(before, result)
|
|
}
|
|
|
|
// Why: telemetry-plan.md§Settings — fire `settings_changed` only for
|
|
// whitelisted keys, with `value_kind` distinguishing booleans from
|
|
// string-enum settings. We deliberately do NOT send the raw value for
|
|
// non-enum settings; the whitelist is currently scoped to experimental
|
|
// toggles, all of which are booleans, so `value_kind === 'bool'` is
|
|
// the path the v1 enum has a slot for. If a non-bool whitelisted
|
|
// setting is ever added, extend the discriminator here at the same
|
|
// time the schema's `value_kind` enum gains the new value.
|
|
for (const key of Object.keys(sanitizedArgs)) {
|
|
if (!SETTINGS_CHANGED_WHITELIST_SET.has(key)) {
|
|
continue
|
|
}
|
|
const beforeValue = (before as Record<string, unknown>)[key]
|
|
const afterValue = (result as Record<string, unknown>)[key]
|
|
if (beforeValue === afterValue) {
|
|
continue
|
|
}
|
|
if (typeof afterValue !== 'boolean') {
|
|
// No non-bool whitelist entries today; skip rather than guess.
|
|
continue
|
|
}
|
|
track('settings_changed', {
|
|
setting_key: key as SettingsChangedKey,
|
|
value_kind: 'bool'
|
|
})
|
|
}
|
|
|
|
return result
|
|
})
|
|
|
|
ipcMain.handle(
|
|
'settings:set-active-runtime-environment-preference',
|
|
(event, args: { environmentId?: unknown }): GlobalSettings => {
|
|
const requestedEnvironmentId = args?.environmentId
|
|
if (requestedEnvironmentId !== null && typeof requestedEnvironmentId !== 'string') {
|
|
throw new Error('Invalid Active Server preference')
|
|
}
|
|
const requestedId = requestedEnvironmentId?.trim() || null
|
|
const environmentId =
|
|
requestedId === null ? null : resolveEnvironment(app.getPath('userData'), requestedId).id
|
|
return store.updateSettings(
|
|
{ activeRuntimeEnvironmentId: environmentId },
|
|
{ notifyListeners: true, originWebContentsId: event.sender.id }
|
|
)
|
|
}
|
|
)
|
|
|
|
ipcMain.handle('settings:listFonts', () => {
|
|
return listSystemFontFamilies()
|
|
})
|
|
|
|
ipcMain.handle('settings:previewGhosttyImport', () => {
|
|
return previewGhosttyImport(store)
|
|
})
|
|
|
|
ipcMain.handle('settings:previewWarpThemeImport', (event, args?: unknown) => {
|
|
const source = args === undefined ? { kind: 'auto' } : args
|
|
return previewWarpThemeImport(store, source, event.sender)
|
|
})
|
|
|
|
ipcMain.handle('cache:getGitHub', () => {
|
|
return store.getGitHubCache()
|
|
})
|
|
|
|
ipcMain.handle('cache:setGitHub', (_event, args: { cache: PersistedState['githubCache'] }) => {
|
|
store.setGitHubCache(args.cache)
|
|
})
|
|
}
|