# Unified State Architecture ## Overview Jacques uses three storage layers for state persistence: | Layer | Location | Scope | Examples | |-------|----------|-------|---------| | **Server disk** | `~/.jacques/` | Shared across all clients | Preferences, hidden projects, config, secrets | | **Server memory** | Session registry | Ephemeral (server lifetime) | Active sessions, focus state | | **Client localStorage** | Browser per-origin | Per-device UI layout | Sidebar collapsed, log panel height | ## Config Files | File | Schema | Read/Write | |------|--------|-----------| | `~/.jacques/config.json` | See [Unified Config Schema](#unified-config-schema) | `ConfigManager` in `core/src/config/config-manager.ts` | | `~/.jacques/secrets.json` | OAuth tokens, API keys | `ConfigManager` (restricted permissions: 0600 on Unix) | | `~/.jacques/hidden-projects.json` | `string[]` (project names) | `hideProject()` / `unhideProject()` in `core/src/cache/hidden-projects.ts` | | `~/.jacques/approved-users.json` | Tailscale approved devices | `server/src/auth/approved-users.ts` | | `~/.jacques/runner-state.json` | Runner terminal metadata | `server/src/runner/runner-state.ts` | | `~/.jacques/cache/project-cache.json` | Project discovery cache | `server/src/routes/project-routes.ts` | | `~/.jacques/bin/` | Optional bundled binaries (e.g., tmux) | `server/src/terminal/tmux-manager.ts` (`resolveTmuxPath()`) | | `~/.claude/settings.json` | Claude Code settings (autoCompact) | Read by Jacques, owned by Claude Code | | `~/.jacques/server/terminal-state/*.json` | Persisted terminal snapshots (headless xterm mirror state) | `TerminalStateStore` in `server/src/terminal/terminal-state-store.ts` | | `~/.jacques/server/terminal-replay/*.log` | Append-only terminal replay logs (output + resize records) | `TerminalReplayStore` in `server/src/terminal/terminal-replay-store.ts` | ## Terminal State Persistence Managed terminals maintain a headless xterm mirror on the server. For the tmux backend, terminal state is persisted to disk so terminals can survive server restarts. **Two stores:** - **`TerminalStateStore`** (`~/.jacques/server/terminal-state/.json`) — Serialized xterm snapshot, dimensions, replay offset, tmux session name. Written on a 500ms debounce after output. - **`TerminalReplayStore`** (`~/.jacques/server/terminal-replay/.log`) — Binary append-only log of output and resize records. Each record is type-tagged: `O` (output, 5-byte header + data) or `R` (resize, 9 bytes). **Attach/restore flow:** 1. On attach: load persisted snapshot into a fresh headless terminal 2. Replay new output from the replay log starting at the saved offset 3. Serialize the rebuilt terminal and send to the client 4. Subscribe client for live output **Compaction:** When a replay log exceeds 20MB, bytes before the current snapshot offset are trimmed (the snapshot already incorporates that output). **Pruning:** On startup, both stores prune files for terminal IDs that no longer exist in the terminal registry. **Direct/daemon backends:** These backends use the headless mirror in-memory only — no disk persistence (terminals don't survive server restarts for direct, daemon handles its own state). ## ConfigManager (Centralized Config I/O) All access to `~/.jacques/config.json` and `~/.jacques/secrets.json` goes through the singleton `ConfigManager`: ```ts import { getConfigManager } from '@jacques-ai/core/config'; // Read const mgr = getConfigManager(); const config = mgr.getConfig(); // Cached, validated, migrated const secrets = mgr.getSecrets(); // Cached, validated // Write (async, mutex-serialized) await mgr.updateConfig({ skipPermissions: true }); await mgr.mutateConfig(config => { config.preferences!.theme = 'light'; }); // Write (sync, for backward compat) mgr.setConfigSync(config); // Export/Import const blob = mgr.exportSettings(includeSecrets); await mgr.importSettings(blob, 'merge' | 'replace'); ``` **Key features:** - **In-memory cache** — No redundant disk reads within the same process - **Async mutex** — Serialized writes prevent race conditions (4 modules previously wrote independently) - **Atomic file writes** — Write to `.tmp.xxxx` then rename, preventing corruption on crash - **Schema validation** — Zod validates on read, warns on invalid fields, falls back to defaults - **Migrations** — Ordered `v1 → v2 → ...` transforms on startup when config version is outdated - **Secrets separation** — OAuth tokens and API keys stored in `secrets.json` with restrictive permissions - **Cross-platform** — Works on macOS, Linux, Windows (chmod on Unix, ACL inheritance on Windows) **Files:** | File | Purpose | |------|---------| | `core/src/config/types.ts` | Unified `JacquesConfig` and `JacquesSecrets` types | | `core/src/config/schema.ts` | Zod validation schemas | | `core/src/config/migrations.ts` | Version migration functions | | `core/src/config/config-manager.ts` | ConfigManager class + singleton | | `core/src/config/index.ts` | Re-exports | ## Unified Config Schema The canonical `JacquesConfig` type (in `core/src/config/types.ts`) includes all fields: ```ts interface JacquesConfig { version: number; // Schema version (current: 2) rootPath?: string; // Claude projects directory override skipPermissions?: boolean; // --dangerously-skip-permissions archive?: { autoArchive?: boolean }; preferences?: PreferencesConfig; // See SharedPreferences below notifications?: NotificationSettings; // Categories, thresholds, enabled github?: { connected?: boolean; account?: string; configuredAt?: string }; hotkeys?: { enabled: boolean; shortcut?: string; showHelp?: boolean }; ide?: { preferred?: string; detected?: string[]; configuredAt?: string }; sources?: { obsidian?: { enabled?: boolean; vaultPath?: string; configuredAt?: string }; googleDocs?: { enabled?: boolean; connected_email?: string; configured_at?: string }; notion?: { enabled?: boolean; workspace_id?: string; workspace_name?: string; configured_at?: string }; }; } ``` **Secrets** (stored in `~/.jacques/secrets.json`, not in config.json): ```ts interface JacquesSecrets { github?: { token?: string; deviceFlowToken?: string; clientId?: string }; sources?: { googleDocs?: { client_id?: string; client_secret?: string; tokens?: OAuthTokens }; notion?: { client_id?: string; client_secret?: string; tokens?: OAuthTokens }; }; } ``` ### Scope of secrets.json `secrets.json` stores **Jacques' own internal credentials** — OAuth tokens for its source integrations (Google Docs, Notion) and GitHub integration tokens. It is **not** a general-purpose secrets vault for user projects; it does not replace `.env` files or tools like Doppler, 1Password CLI, or HashiCorp Vault. - File permissions are set to `0600` on Unix (owner read/write only); on Windows, ACLs are inherited from the user's home directory - On config migration (v1 → v2), credentials are automatically extracted from `config.json` into `secrets.json` - `ConfigManager` handles all reads and writes — do not edit `secrets.json` manually **Previously** there were 3 separate `JacquesConfig` interfaces (in `core/utils`, `core/sources`, and `server/routes`) that had drifted apart. The unified type in `core/src/config/types.ts` replaces all of them. ## Config Versioning & Migrations Config uses numeric versioning (current: `CURRENT_CONFIG_VERSION = 2`). On first read, if the config version is older than current, migrations run automatically: | From | To | Changes | |------|----|---------| | v1 (or `"1.0.0"`) | v2 | Separates secrets (GitHub tokens, OAuth credentials) from config.json into secrets.json | **Adding a new migration:** 1. Bump `CURRENT_CONFIG_VERSION` in `core/src/config/types.ts` 2. Add migration function in `core/src/config/migrations.ts` keyed by the source version 3. Migration receives `(config, secrets)` and returns both updated ## SharedPreferences (Server-Backed) All behavioral settings flow through the server via the `SharedPreferences` system. ### Type Definition ```ts interface SharedPreferences { selectedProject: string | null; // Currently selected project pinnedProjects: string[]; // Projects pinned in sidebar pinnedProjectsInitialized: boolean; // Whether first-use auto-pin has run skipPermissions: boolean; // Launch with --dangerously-skip-permissions confirmBeforeKillingTerminal: boolean; // Confirm before killing terminal (default: true) minimizedTerminals: string[]; // Terminal IDs currently minimized theme: string; // UI theme (default: 'jacques-dark') githubConnected: boolean; // GitHub integration active (read-only via prefs API) githubAccount: string | null; // GitHub username (read-only via prefs API) runnerFavorites: Record; // Per-project runner script favorites terminalFont: string; // Terminal font family id (default: 'geist-mono') appFont: string; // App UI font family id (default: 'geist-mono') forceDirectPty: boolean; // Force DirectPtyBridge (developer option) } ``` Defined in `core/src/utils/settings.ts` with `DEFAULT_PREFERENCES`. ### API - `GET /api/preferences` — Returns current `SharedPreferences` - `PUT /api/preferences` — Accepts `Partial`, merges, saves, broadcasts ### WebSocket When preferences change, the server broadcasts: ```json { "type": "preferences_changed", "preferences": { "selectedProject": "my-project", "pinnedProjects": [...], ... } } ``` ### Data Flow ``` GUI/CLI → HTTP PUT /api/preferences → Server ├── ConfigManager writes to ~/.jacques/config.json └── WebSocket broadcast: preferences_changed → All clients ``` ### Storage in config.json - `skipPermissions` lives at the root level (legacy compatibility) - `githubConnected`/`githubAccount` map to `config.github.connected`/`config.github.account` (read-only via prefs) - All other fields live under `config.preferences` ### Extending To add a new shared preference: 1. Add the field + default to `SharedPreferences` and `DEFAULT_PREFERENCES` in `core/src/utils/settings.ts` 2. The API, WebSocket broadcast, and hooks pick it up automatically ## Settings Export/Import Two HTTP endpoints for backing up and transferring settings: | Method | Path | Description | |--------|------|-------------| | GET | `/api/settings/export` | Export all settings as JSON blob (strips secrets by default) | | GET | `/api/settings/export?secrets=true` | Export including secrets | | POST | `/api/settings/import` | Import settings (merge mode) | | POST | `/api/settings/import?mode=replace` | Import settings (replace mode) | The export blob includes: - `config` — full config.json content - `hiddenProjects` — hidden projects list - `secrets` — only if `?secrets=true` is specified - `_exportVersion`, `_exportedAt`, `_platform` — metadata ## Configuration Routes Additional server config endpoints: | Endpoint | Method | Purpose | Persists to | |----------|--------|---------|-------------| | `/api/config/root-path` | GET/POST | Claude projects directory | config.json | | `/api/config/hotkeys` | GET/POST | Global hotkey daemon config | config.json | | `/api/config/ide` | GET/POST | Preferred IDE | config.json | | `/api/config/ide/detect` | POST | Auto-detect installed IDEs | config.json | ## Notification Settings Notification settings are stored in `config.json` under the `notifications` key and managed by `NotificationService`: | Endpoint | Method | Purpose | |----------|--------|---------| | `/api/notifications/settings` | GET | Get notification settings | | `/api/notifications/settings` | PUT | Update notification settings | | `/api/notifications` | GET | Get notification history | | `/api/notifications/test` | POST | Send test notification | ## What Stays in localStorage (Per-Device UI State) These keys use the `jacques:` prefix and remain client-local: | Key | Type | Purpose | |-----|------|---------| | `sidebarCollapsed` | boolean | Sidebar expand/collapse | | `sidebarCollapsedV2` | boolean | V2 sidebar state | | `showLogs` | boolean | Log panel visibility | | `logPanelHeight` | number | Log panel resize | | `catalogCollapsed` | boolean | Catalog section | | `openSessions` | string[] | Currently open session tabs | | `notificationsPanelOpen` | boolean | Notification center | | `expandedProjects` | string[] | Expanded sidebar projects | | `collapsedProjects` | string[] | Collapsed sidebar projects | | `changesPanelOpen` | boolean | Changes panel | | `runnerPanelOpen` | boolean | Runner panel | Additionally, some localStorage keys use non-standard prefixes (known inconsistency): - `jacques-notification-history` — Notification history (50 items max) - `jacques-open-sessions` / `jacques-open-sessions:{projectSlug}` — Open session tabs - `jacques_google_oauth` — Google OAuth state (underscore prefix) - `jacques-artifacts-view` — Artifacts panel state ## What's Server-Authoritative All behavioral settings that affect how sessions are launched, what's visible, or what notifications fire are server-backed: - **User preferences** (`SharedPreferences`): all 13 fields above - **Notification settings**: Categories, enabled state — via `GET/PUT /api/notifications/settings` - **Hidden projects**: Via `DELETE /api/projects/:name` - **Archive settings**: autoArchive — in `config.json` - **Source configs**: Obsidian, Google Docs, Notion — in `config.json` + `secrets.json` - **GitHub integration**: Connected status, account — in `config.json` + `secrets.json` - **Hotkey config**: Enabled, shortcut — in `config.json` - **IDE preference**: Preferred IDE, detected IDEs — in `config.json` - **Runner state**: Terminal metadata — in `runner-state.json` - **Approved devices**: Tailscale users — in `approved-users.json`