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:
@@ -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();
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
130
src/websocket/listener/remote-settings.ts
Normal file
130
src/websocket/listener/remote-settings.ts
Normal 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 {};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user