-
Notifications
You must be signed in to change notification settings - Fork 0
App Settings Store & Persistence
src/core/app-settings.ts is the app-wide settings layer — not per-project configuration. It defines the AppSettings schema re-export, the canonical defaults, the tolerant normalizer that every read and every write passes through, and the two filesystem entrypoints loadAppSettings / saveAppSettings.
Two design constraints shape the whole module:
-
Electron-free. The header comment states this explicitly so the Server Edition can share the identical file. The module only imports
node:fsandnode:path; thesrc/coreno-electron guard keeps that boundary honest. -
Path-injected. Nothing here knows about
app.getPath('userData'). Every function takes auserDataPathand resolvessettings.jsonbeneath it throughsettingsPath(), so the Electron main process and the Server data dir can both reuse it unchanged.
The consequence of centralizing everything here is that a key which is not carried through normalization is silently erased from disk on the next save — a failure mode the relay sub-normalizer calls out in a comment (app-settings.ts#L114-L119).
| File | Role |
|---|---|
src/core/app-settings.ts |
DEFAULT_APP_SETTINGS, settingsPath, per-domain normalizers, normalizeAppSettings, loadAppSettings, saveAppSettings
|
src/core/app-settings.test.ts |
Round-trip-to-disk tests, garbage-input normalization tests, the "empty input equals defaults" invariant |
src/shared/ipc.ts |
Channel constants app:settings-get / app:settings-set (src/shared/ipc.ts#L12-L13) — the renderer-facing surface |
What the module deliberately does not do: cache, watch, lock, or validate cross-references. Every loadAppSettings call re-reads and re-parses the file, and saveAppSettings performs a full read-modify-write with no lock. There is also no in-memory singleton — callers get a fresh object each time.
DEFAULT_APP_SETTINGS (app-settings.ts#L11-L32) is the complete always-present shape:
| Key | Default | Notes |
|---|---|---|
autoDownloadUpdates |
false |
Auto-update download stays opt-in |
accounts |
[] |
Managed agent accounts; normalization accepts only agentId === 'claude'
|
activeAccountId |
null |
Non-empty string or null; no referential check against accounts
|
dismissedAnnouncementVersion |
null |
Any string passes, including ''
|
a2aPeers |
[] |
Outbound A2A peers (id, label, endpoint, optional token) |
apiProviders |
[] |
OpenAI-compatible providers (id, name, baseUrl) |
theme |
'system' |
Narrowed to light | dark | system
|
enterBehavior |
'queue' |
Any non-empty string survives; not narrowed to a union at runtime |
agentBrowserControl |
false |
CDP facade + /open agent surface, opt-in off by default |
agentA2aServer |
false |
Exposing agent nodes to peers, opt-in off by default |
invertWheelZoom |
false |
Mousewheel zoom direction |
terminalFontFamily |
'Geist Mono, JetBrains Mono, monospace' |
Trimmed, must be non-empty |
terminalProfile |
'' |
Trimmed |
httpProxy |
'' |
Trimmed |
telegram |
{ enabled: false, allowedChatIds: [] } |
Always emitted in full default shape |
Four keys are intentionally absent from the defaults object, because presence itself carries meaning:
| Key | Absent means |
|---|---|
onboardedAt |
First run has not finished — onboarding may show |
browserHomeUrl |
Renderer falls back to DuckDuckGo |
chat |
No chat provider/model/keys configured |
relay |
No relay URL to dial, i.e. relay is unconfigured |
The tested invariant is that normalizeAppSettings(undefined), normalizeAppSettings({}), and normalizeAppSettings({ junk: true }) all deep-equal DEFAULT_APP_SETTINGS (app-settings.test.ts#L27-L28). Any new field must therefore land in both the defaults object and the normalizer, or that equality breaks.
normalizeAppSettings (app-settings.ts#L134-L223) is the only validator. It never throws and never rejects a whole file for one bad field — it narrows field by field and falls back per field. The recurring patterns:
| Pattern | Rule | Fields |
|---|---|---|
| Strict boolean | Kept only when === true; strings like 'yes' become false
|
autoDownloadUpdates, agentBrowserControl, agentA2aServer, invertWheelZoom, telegram.enabled
|
| Enum narrowing | Literal allow-list, else default |
theme, accounts[].permissionMode (via isPermissionMode) |
| Trimmed non-empty string | Trimmed and required non-empty, else default/omitted |
terminalFontFamily, onboardedAt, browserHomeUrl, relay.url
|
| Non-empty / typed string | Length-checked but not trimmed, or only typeof-checked |
activeAccountId, a2aPeers[].token, relay.invite, trustedFingerprint, chat.keys[].key
|
| Defaulted literal | Fallback only on junk/absence |
enterBehavior → 'queue', relay.role → 'host'
|
| Array of narrowed records |
map → filter → map; invalid entries dropped silently |
accounts, a2aPeers, apiProviders, telegram.allowedChatIds, chat.keys
|
| Optional sub-object | Returns undefined when nothing valid was configured; spread into the result only when present |
chat, relay
|
| Presence-marked scalar | Key entirely omitted unless a valid value parses |
onboardedAt, browserHomeUrl
|
Domain-specific normalizers keep nesting out of the main function:
-
normalizeTelegram(#L59-L70) always returns the full default shape so a fresh file equals the defaults; the token is kept only when it is a non-empty string, and chat ids are filtered to non-empty strings. -
normalizeChat(#L76-L112) drops emptykeysarrays and empty override maps, keepspriceOverridesonly whenin/outare both numbers, and returnsundefinedwhen nothing parsed — preserving the default-settings shape for existing consumers. -
normalizeRelay(#L120-L132) returnsundefinedwhen there is no non-empty trimmed URL, then defaultsroleto'host'and carriesinvite/trustedFingerprintthrough.
loadAppSettings(userDataPath) (app-settings.ts#L225-L233):
- Resolve
settingsPath(userDataPath)→<userDataPath>/settings.json. - If the file does not exist, return a spread copy of
DEFAULT_APP_SETTINGS, so callers can never mutate the module-level constant. - Otherwise
readFileSync(..., 'utf8')andJSON.parse, then hand the parsed value tonormalizeAppSettings. - Any throw from read or parse is caught and returns the same defaults copy.
The load path never writes, never migrates in place, and never reports an error upward. A corrupt file is tolerated by discarding it in memory; the next save overwrites it.
saveAppSettings(userDataPath, patch) (app-settings.ts#L235-L240) is a read-modify-write:
-
loadAppSettings(userDataPath)— the current normalized state. - Merge:
{ ...loaded, ...patch }. Shallow merge, so nested objects are replaced wholesale, not deep-merged. -
normalizeAppSettings(...)on the merged object — this both validates the patch and determines what actually gets persisted. -
mkdirSync(userDataPath, { recursive: true })so a first-run save creates the data directory. -
writeFileSync(settingsPath(...), JSON.stringify(next, null, 2) + '\n', 'utf8')— whole-file overwrite with a trailing newline. - Return
next, so the caller receives the canonical persisted value without re-reading.
Two merge subtleties worth knowing:
-
Normalization prunes. Because
normalizeAppSettingsbuilds its result from an explicit allow-list of keys, anything the patch adds that the normalizer doesn't recognize is gone from the merged object and from disk. This is why the relay comment insists normalization must carry new keys through. -
undefinedin a patch deletes a key. The spread overwrites the loaded value withundefined, and normalization omits non-conforming values — sosaveAppSettings(dir, { onboardedAt: undefined })removes the key entirely, which is exactly how "re-run onboarding" is expressed (tested atapp-settings.test.ts#L90-L98).
flowchart TD
RGet["Renderer: read settings"] -->|"IPC app:settings-get"| Load["loadAppSettings(userDataPath)"]
RSet["Renderer: patch settings"] -->|"IPC app:settings-set"| Save["saveAppSettings(userDataPath, patch)"]
subgraph LoadPath["Load path"]
Load --> Path["settingsPath() → userDataPath/settings.json"]
Path --> Exists{"file exists?"}
Exists -->|no| Defs["{ ...DEFAULT_APP_SETTINGS }"]
Exists -->|yes| Parse["JSON.parse(readFileSync)"]
Parse -->|throws| Defs
Parse -->|ok| NormLoad["normalizeAppSettings"]
NormLoad --> Result["AppSettings (canonical)"]
Defs --> Result
end
subgraph SavePath["Save path (read-modify-write)"]
Save --> Merge["{ ...loadAppSettings(), ...patch }"]
Merge --> NormSave["normalizeAppSettings"]
NormSave --> Mkdir["mkdirSync recursive"]
Mkdir --> Write["writeFileSync settings.json<br/>JSON.stringify(next, null, 2) + newline"]
Write --> Ret["returns normalized next"]
end
Key nodes:
-
Two arrows into
normalizeAppSettings. One function is the single validation chokepoint in both directions. There is no separate "validate on write" path, which is what makes patch semantics and key deletion fall out of the same rules. -
loadAppSettingsinside the save path. Saves are partial patches, so the previous state is always read back and merged before writing. Concurrent writers can therefore lose each other's changes; nothing in this module serializes them. -
Defaults on both the missing-file and parse-failure branches. A truncated or hand-edited-broken
settings.jsondegrades to a defaults view rather than an error, and is overwritten on the next save. -
The write is a plain whole-file
writeFileSync. Because the import list only containsexistsSync,mkdirSync,readFileSync, andwriteFileSync, there is no temp-file-plus-rename: a crash mid-write can leave invalid JSON, which the load catch turns into "defaults" — recoverable, but the previous configuration is lost.
-
Unknown or stale keys are dropped. Anything not represented in
normalizeAppSettingsdisappears from disk on the next save. Schema evolution must therefore be additive at the normalizer, not just at the type. -
accountsis Claude-only. Entries missingid,label, orconfigDir, or whoseagentIdis not'claude', are filtered out with no error.permissionModeis attached only when it is one ofdefault/acceptEdits/bypassPermissions. -
activeAccountIdhas no referential integrity. A non-empty string is kept even if no account with that id exists after filtering. -
chat.keys[].keyis onlytypeof-checked. An empty-string API key survives normalization, while an emptyproviderIddoes not. -
enterBehavioris not narrowed. Any non-empty string persists; only absent/empty values fall back to'queue'. -
dismissedAnnouncementVersionaccepts''. The check istypeof === 'string', withnullas the only other outcome. -
normalizeChatandnormalizeRelayeach run twice in the return object spread (#L217-L218) — once as the presence guard, once for the value. Harmless for these small pure functions, but relevant if one grows expensive. -
loadAppSettingsassumes the caller's directory may not exist. It only checks the file; directory creation happens on the save side.
To add a setting:
- Extend
AppSettings(imported as a type from../shared/types,#L7) — the shared domain type is the schema's public face. - If it must always be present, add it to
DEFAULT_APP_SETTINGS; if absence is meaningful (likeonboardedAt/browserHomeUrl/chat/relay), omit it and gate it behind a spread. - Add an explicit normalization branch — including nested domains via a dedicated
normalizeXhelper when the value is a structured object. Skipping this step means the value will not survive a save. - Cross-process access needs no new channel:
app:settings-get/app:settings-setalready carry the whole object, and channel names must come fromIPCconstants insrc/shared/ipc.ts, never string literals. - Only add a dedicated IPC verb when the operation is more than a settings field write — the capability pages use that pattern (
settings:capabilities-get,settings:set-skill-enabled,settings:set-plugin-enabled,settings:reinstall-hooks), since CLI-side config files are not part of this schema. - Add a round-trip test and a garbage-input test in
app-settings.test.ts, using the existingmkdtempSyncscratch-dir pattern and asserting the raw JSON on disk when persistence matters.
The fields here back, among others: auto-update behavior, the first-run onboarding gate, theme selection, canvas wheel-zoom direction, terminal font/profile, the HTTP proxy, the agent browser-control surface and browser home URL, the A2A server and peer list, managed agent accounts, chat provider/model/keys/pricing, the relay client configuration, the Telegram bot, and announcement dismissal.
Sources: src/core/app-settings.ts Sources: src/core/app-settings.test.ts Sources: src/shared/ipc.ts
Generated from termsprawl at 0d4393be54c6200beedd91bb636e5296c30472c5.
App Shell & Platform Foundations
- Electron Main Process & Window Lifecycle
- Preload Bridge & IPC Contract
- Shared Domain Types and File/URL Helpers
- Renderer Bootstrap & App Composition
- Build Targets & TypeScript Configuration
Canvas, Nodes & Renderer State
- Infinite Canvas Surface & Viewport Interaction
- Workspace, Project & Tab State
- Node Links, Edges & Link Inspector
- Sticky, Group, Editor & Diff Nodes
- Keyboard Canvas Navigation & Cross-Panel Requests
- Theme, Accent & Visual Language
- Boot Overlay, Onboarding & Shared UI Kit
Terminals & Session Continuity
- PTY Lifecycle & Terminal Sessions
- tmux Session Naming & Reattach
- Scrollback Snapshots & Cold Replay
- Terminal Node Rendering (xterm.js)
- SSH Remote Projects, Terminals & Files
Persistence, Projects & Files
- Workspace Store & Project File Layout
- Project Scope, Deletion & Worktree Registry
- Workspace Bundle Export/Import
- File Service & File Tree UI
Agent Runtime & Tooling
- Agent Status Model & Hook Normalization
- Hook Server & CLI Hook Installers
- Agent Launch, CLI Probing & Managed Accounts
- Agent Tool Protocol & In-Process Server
- Agent Tool Client, CLI & MCP Entry
- Transcripts, Context Discovery & Context CLI
- Agent Canvas State & Status Badges
Chat Nodes & Model Providers
- Chat Runtime, Conversation & Cost
- Model Provider Adapters & Streaming
- Chat Tool Calling & Project Tools
- Chat Node UI
Git & Source Control
Embedded Browser Nodes
- Browser Manager & Guest Runtime
- CDP Facade & Browser Agent Server
- Browser Navigation Policy & Node UI
Server Edition
- Server Bootstrap & HTTP/WebSocket Entry
- RPC Dispatch, Handlers & Service Bridges
- Renderer Shim & Server Boundary
- Server Auth & Security Boundary
Relay & Remote Access
- Relay Hub & WebSocket Frame Routing
- Relay End-to-End Cryptography
- Relay Auth, Invites, Store & Admin API
- Relay Client, Pairing & Terminal Tunneling
- Relay Trust UI
Integrations & Secondary Surfaces
- Telegram Bot, Commands & Pairing
- A2A Peers: Protocol, Client & Server
- Node Link Engine, Registry & Scheduler
- Cloud Spaces, Snapshots & Sync
Settings, Updates & Maintenance