mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
* feat(ai-vault-search): persist agent-session search consent and retention Two booleans and nothing else: `enabled` and `historyDays`, off by default because building the index reads every transcript on the machine. No `paused` -- the PR 3 indexer is immutable, so every change is close-and-construct. The settings IPC normalizes a write like every other field and hands the change to the index; there is no UI for it until PR 8. * feat(ai-vault-search): hold one indexer and engine pair per host The object that owns a host's live index and the three recipes that change it. The indexer is immutable, so a settings change is close-and-construct, disabling is close with no replacement, and clearing is close, remove the database, construct. The new instance's first sweep purges a narrowed window and admits a widened one, so neither needs a code path. The database sits beside the scanner's parse cache, one file per host. A runtime with no node:sqlite can hold no index at all, which the Node 18 floor on orcad and the relay makes a real case rather than a hypothetical one. * feat(ai-vault): let the scanner child own the session search index The transcript reader runs in that child, so the index consumer has to as well: one read serves both the session list and the index. Three request operations (search, status, reconcile) and one fire-and-forget settings message carry everything a parent needs; main never opens the database file. The init frame becomes a factory because it is read at every spawn, so a respawned child sees current consent rather than the first frame's. A child holding a running index is never idle from the parent's side, so idle retirement is suppressed while the index is on -- retiring it would stop the reconcile loop until some later scan happened to respawn one. Both files this lands in were already at the max-lines ceiling, so three collaborators move to where they belong rather than being disabled around: the invalidation deadline into the class that owns invalidations, call cancellation and the start requeue into the call-state module, and orcad's flag parsing into its own file. * feat(ai-vault-search): register a search service on every host that answers Without a registered service a host answers no-service, which means "this host does not have the feature" rather than "the index is off". All three hosts now answer the second thing. The desktop forwards to the scanner child. orcad and the SSH relay daemon have no such child -- orcad ships only the watcher and daemon entries, and the relay's AI Vault sidecar runs the remote scanner, which publishes nothing to the transcript channel -- so on those two the index lives in the process that would drive its reads, gated on a runtime that has node:sqlite at all. The relay registers with consent off and no way to turn it on: nothing carries a setting to a remote host yet. That is the honest state, and it is still worth registering, because it is what tells a client the difference between off and too old. * test(ai-vault-search): price a warm pass over five thousand transcripts The number the reconcile interval will be revisited against, measured rather than argued: a warm sweep stats every file under every root, a warm cycle stats the newest N per agent, and neither reads what the index already holds. It does not tune the interval. * fix(ai-vault-search): answer the casting gate without assertions main's new type-assertion rule reaches every file this branch touches. All nine sites drop the cast rather than carry a SAFETY: rationale: the operation guard narrows with `in`, the sqlite probe narrows the builtin it loads, the child test keeps the discriminated reply instead of widening it, and the settings resolver takes `unknown` -- which is what it really reads, since a persisted profile can hold a value no version of this code wrote. * fix(ai-vault-search): let a refreshed scan root reach the live index The parent re-resolves scan roots before every policy push, precisely so a WSL distro or extra Codex home that appeared since the child spawned enters the window. The child forwarded only the settings to a live instance and used the roots solely in its `??=` initializer, so those roots were dropped for the child's lifetime. The indexer stays immutable: a structurally different root set closes the pair and constructs a new one, the same way a changed databasePath already does. Compare via `sameSessionSearchRoots` rather than a plain JSON compare, because nothing fixes the key order two producers write; lists are sorted too, since the indexer walks every root and a re-enumeration that reorders is not a change. An unchanged set still never restarts a running index. The orcad and relay in-process hosts resolve roots once at install and never re-apply, so they have no such seam. * fix(ai-vault): restart the scanner child the index is holding Three review items. The hold keeps a child alive for the index, but only a queued call ever started one: `pump()` skipped a hold with an empty queue, so an idle indexing child that crashed, or an `ensureChild()` that failed at start, left indexing stopped until an unrelated request happened to arrive. `pump()` now starts the child the hold requires, which is also the restart callback the fault policy already schedules, so the existing delay and circuit bound the retry exactly as they bound a queued call's start. `updateSessionSearch` goes through the same seam instead of its own `ensureChild` call. A search registers no AbortController, so a cancel sent for a search id was added to the `cancelled` set and never consumed. Nothing can reach that today -- no caller passes a signal and the child answers in milliseconds -- so this is only a leak of ids: consume it when the search settles. The orcad argument doc claimed a `--`-prefixed value stays a flag. The parser takes the next token regardless, and orcad-launch-contract.test.ts pins that, so the doc is what was wrong. Behaviour is unchanged. * fix(ai-vault): recover search indexing and refresh scan roots * fix(ai-vault): defer search refresh policy reads * fix(session-search): stabilize paging and host enablement * fix(session-search): refresh host roots within full sweeps * docs(session-search): clarify initial root fallback
346 lines
14 KiB
TypeScript
346 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 { 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) => {
|
|
const settings = store.getSettings()
|
|
return (
|
|
settings.agentStatusHooksEnabled !== false &&
|
|
!settings.disabledTuiAgents.includes(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)
|
|
})
|
|
}
|