diff --git a/src/permissions/mode.ts b/src/permissions/mode.ts index db72b2e..4957316 100644 --- a/src/permissions/mode.ts +++ b/src/permissions/mode.ts @@ -260,7 +260,7 @@ class PermissionModeManager { planFilePathOverride?: string | null, ): "allow" | "deny" | null { const effectiveMode = modeOverride ?? this.currentMode; - const effectivePlanFilePath = + const _effectivePlanFilePath = planFilePathOverride !== undefined ? planFilePathOverride : this.getPlanFilePath(); diff --git a/src/websocket/listener/client.ts b/src/websocket/listener/client.ts index 47f07cc..8538963 100644 --- a/src/websocket/listener/client.ts +++ b/src/websocket/listener/client.ts @@ -48,6 +48,7 @@ import { } from "./interrupts"; import { getConversationPermissionModeState, + loadPersistedPermissionModeMap, setConversationPermissionModeState, } from "./permissionMode"; import { parseServerMessage } from "./protocol-inbound"; @@ -412,7 +413,7 @@ function createRuntime(): ListenerRuntime { reminderState: createSharedReminderState(), bootWorkingDirectory, workingDirectoryByConversation: loadPersistedCwdMap(), - permissionModeByConversation: new Map(), + permissionModeByConversation: loadPersistedPermissionModeMap(), connectionId: null, connectionName: null, conversationRuntimes: new Map(), diff --git a/src/websocket/listener/cwd.ts b/src/websocket/listener/cwd.ts index b907b57..d6a092a 100644 --- a/src/websocket/listener/cwd.ts +++ b/src/websocket/listener/cwd.ts @@ -1,12 +1,8 @@ -import { existsSync } from "node:fs"; -import { mkdir, writeFile } from "node:fs/promises"; -import { homedir } from "node:os"; import path from "node:path"; +import { loadRemoteSettings, saveRemoteSettings } from "./remote-settings"; import { normalizeConversationId, normalizeCwdAgentId } from "./scope"; import type { ListenerRuntime } from "./types"; -const shouldPersistCwd = process.env.PERSIST_CWD === "1"; - export function getWorkingDirectoryScopeKey( agentId?: string | null, conversationId?: string | null, @@ -32,20 +28,23 @@ export function getConversationWorkingDirectory( ); } +/** + * @deprecated - the legacy path is only read for one-time migration in remote-settings.ts + */ export function getCwdCachePath(): string { - return path.join(homedir(), ".letta", "cwd-cache.json"); + return path.join( + process.env.HOME ?? require("node:os").homedir(), + ".letta", + "cwd-cache.json", + ); } export function loadPersistedCwdMap(): Map { - if (!shouldPersistCwd) return new Map(); try { - const cachePath = getCwdCachePath(); - if (!existsSync(cachePath)) return new Map(); - const raw = require("node:fs").readFileSync(cachePath, "utf-8") as string; - const parsed = JSON.parse(raw) as Record; + const settings = loadRemoteSettings(); const map = new Map(); - for (const [key, value] of Object.entries(parsed)) { - if (typeof value === "string" && existsSync(value)) { + if (settings.cwdMap) { + for (const [key, value] of Object.entries(settings.cwdMap)) { map.set(key, value); } } @@ -56,14 +55,7 @@ export function loadPersistedCwdMap(): Map { } export function persistCwdMap(map: Map): void { - if (!shouldPersistCwd) return; - const cachePath = getCwdCachePath(); - const obj: Record = Object.fromEntries(map); - void mkdir(path.dirname(cachePath), { recursive: true }) - .then(() => writeFile(cachePath, JSON.stringify(obj, null, 2))) - .catch(() => { - // Silently ignore write failures. - }); + saveRemoteSettings({ cwdMap: Object.fromEntries(map) }); } export function setConversationWorkingDirectory( diff --git a/src/websocket/listener/permissionMode.ts b/src/websocket/listener/permissionMode.ts index 435c729..7fddda3 100644 --- a/src/websocket/listener/permissionMode.ts +++ b/src/websocket/listener/permissionMode.ts @@ -9,6 +9,7 @@ import type { PermissionMode } from "../../permissions/mode"; import { permissionMode as globalPermissionMode } from "../../permissions/mode"; +import { loadRemoteSettings, saveRemoteSettings } from "./remote-settings"; import { normalizeConversationId, normalizeCwdAgentId } from "./scope"; import type { ListenerRuntime } from "./types"; @@ -62,4 +63,74 @@ export function setConversationPermissionModeState( } else { runtime.permissionModeByConversation.set(scopeKey, { ...state }); } + + persistPermissionModeMap(runtime.permissionModeByConversation); +} + +/** + * Load the persisted permission mode map from remote-settings.json. + * Converts PersistedPermissionModeState → ConversationPermissionModeState, + * restoring planFilePath as null (ephemeral — not persisted across restarts). + * If persisted mode was "plan", restores modeBeforePlan instead. + */ +export function loadPersistedPermissionModeMap(): Map< + string, + ConversationPermissionModeState +> { + try { + const settings = loadRemoteSettings(); + const map = new Map(); + if (!settings.permissionModeMap) { + return map; + } + for (const [key, persisted] of Object.entries(settings.permissionModeMap)) { + // If "plan" was somehow saved, restore to the pre-plan mode. + const restoredMode: PermissionMode = + persisted.mode === "plan" + ? (persisted.modeBeforePlan ?? "default") + : persisted.mode; + map.set(key, { + mode: restoredMode, + planFilePath: null, + modeBeforePlan: null, + }); + } + return map; + } catch { + return new Map(); + } +} + +/** + * Serialize the permission mode map and persist to remote-settings.json. + * Strips planFilePath (ephemeral). Converts "plan" mode to modeBeforePlan. + * Skips entries that are effectively "default" (lean map). + */ +function persistPermissionModeMap( + map: Map, +): void { + const permissionModeMap: Record< + string, + { mode: PermissionMode; modeBeforePlan: PermissionMode | null } + > = {}; + + for (const [key, state] of map) { + // If currently in plan mode, persist the effective mode as modeBeforePlan + // so we don't restore into plan mode (plan file path is ephemeral). + const modeToSave: PermissionMode = + state.mode === "plan" ? (state.modeBeforePlan ?? "default") : state.mode; + + // Skip entries that are just "default" with no context — lean map. + if (modeToSave === "default" && state.modeBeforePlan === null) { + continue; + } + + permissionModeMap[key] = { + mode: modeToSave, + modeBeforePlan: + state.mode === "plan" ? null : (state.modeBeforePlan ?? null), + }; + } + + saveRemoteSettings({ permissionModeMap }); } diff --git a/src/websocket/listener/remote-settings.ts b/src/websocket/listener/remote-settings.ts new file mode 100644 index 0000000..db8a003 --- /dev/null +++ b/src/websocket/listener/remote-settings.ts @@ -0,0 +1,130 @@ +/** + * Persistent remote session settings stored in ~/.letta/remote-settings.json. + * + * Stores per-conversation CWD and permission mode so both survive letta server + * restarts. Mirrors the in-memory Map keys used by cwd.ts and permissionMode.ts. + */ + +import { existsSync, readFileSync } from "node:fs"; +import { mkdir, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import path from "node:path"; +import type { PermissionMode } from "../../permissions/mode"; + +/** + * Persisted permission mode state for a single conversation. + * planFilePath is intentionally excluded — it's ephemeral and tied to a + * specific process run; it should not be restored across restarts. + */ +export interface PersistedPermissionModeState { + mode: PermissionMode; + modeBeforePlan: PermissionMode | null; +} + +export interface RemoteSettings { + cwdMap?: Record; + permissionModeMap?: Record; +} + +// Module-level cache to avoid repeated disk reads and enable cheap merges. +let _cache: RemoteSettings | null = null; + +export function getRemoteSettingsPath(): string { + return path.join(homedir(), ".letta", "remote-settings.json"); +} + +/** + * Load remote settings synchronously from disk (called once at startup). + * Populates the in-memory cache. Returns {} on any read/parse error. + * + * Applies a one-time migration: if cwdMap is absent, tries to load + * the legacy ~/.letta/cwd-cache.json. + */ +export function loadRemoteSettings(): RemoteSettings { + if (_cache !== null) { + return _cache; + } + + let loaded: RemoteSettings = {}; + + try { + const settingsPath = getRemoteSettingsPath(); + if (existsSync(settingsPath)) { + const raw = readFileSync(settingsPath, "utf-8"); + const parsed = JSON.parse(raw) as RemoteSettings; + loaded = parsed; + } + } catch { + // Silently fall back to empty settings. + } + + // Validate cwdMap entries — filter out stale paths. + if (loaded.cwdMap) { + const validCwdMap: Record = {}; + for (const [key, value] of Object.entries(loaded.cwdMap)) { + if (typeof value === "string" && existsSync(value)) { + validCwdMap[key] = value; + } + } + loaded.cwdMap = validCwdMap; + } + + // One-time migration: load legacy cwd-cache.json if cwdMap not present. + if (!loaded.cwdMap) { + loaded.cwdMap = loadLegacyCwdCache(); + } + + _cache = loaded; + return _cache; +} + +/** + * Merge updates into the in-memory cache and persist asynchronously. + * Silently swallows write failures. + */ +export function saveRemoteSettings(updates: Partial): void { + if (_cache === null) { + loadRemoteSettings(); + } + + _cache = { + ..._cache, + ...updates, + }; + + const snapshot = _cache; + const settingsPath = getRemoteSettingsPath(); + void mkdir(path.dirname(settingsPath), { recursive: true }) + .then(() => writeFile(settingsPath, JSON.stringify(snapshot, null, 2))) + .catch(() => { + // Silently ignore write failures. + }); +} + +/** + * Reset the in-memory cache (for testing). + */ +export function resetRemoteSettingsCache(): void { + _cache = null; +} + +/** + * @deprecated - only used for one-time migration from legacy cwd-cache.json + */ +function loadLegacyCwdCache(): Record { + try { + const legacyPath = path.join(homedir(), ".letta", "cwd-cache.json"); + if (!existsSync(legacyPath)) return {}; + const raw = readFileSync(legacyPath, "utf-8"); + const parsed = JSON.parse(raw) as Record; + const result: Record = {}; + for (const [key, value] of Object.entries(parsed)) { + if (typeof value === "string" && existsSync(value)) { + result[key] = value; + } + } + return result; + } catch { + return {}; + } +}