feat(listen): persist CWD and permission mode across restarts [LET-8048] (#1428)

Co-authored-by: Letta Code <noreply@letta.com>
Co-authored-by: cpacker <packercharles@gmail.com>
This commit is contained in:
Shubham Naik
2026-03-17 20:04:00 -07:00
committed by GitHub
parent aee9e5195b
commit b1a87d3848
5 changed files with 217 additions and 23 deletions

View File

@@ -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();

View File

@@ -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(),

View File

@@ -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<string, string> {
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<string, string>;
const settings = loadRemoteSettings();
const map = new Map<string, string>();
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<string, string> {
}
export function persistCwdMap(map: Map<string, string>): void {
if (!shouldPersistCwd) return;
const cachePath = getCwdCachePath();
const obj: Record<string, string> = 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(

View File

@@ -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<string, ConversationPermissionModeState>();
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<string, ConversationPermissionModeState>,
): 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 });
}

View File

@@ -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<string, string>;
permissionModeMap?: Record<string, PersistedPermissionModeState>;
}
// 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<string, string> = {};
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<RemoteSettings>): 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<string, string> {
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<string, unknown>;
const result: Record<string, string> = {};
for (const [key, value] of Object.entries(parsed)) {
if (typeof value === "string" && existsSync(value)) {
result[key] = value;
}
}
return result;
} catch {
return {};
}
}