feat: add project-level agent persistence with auto-resume (#17)

Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
Charles Packer
2025-10-28 14:38:42 -07:00
committed by GitHub
parent 200243bd4f
commit af2597ac86
5 changed files with 174 additions and 24 deletions

10
.gitignore vendored
View File

@@ -1,10 +1,14 @@
# Letta Code local settings
.letta/settings.local.json
.letta
.idea
node_modules
bun.lockb
bin/
letta.js
letta.js.map
.DS_Store
.letta
# Logs
logs
@@ -145,7 +149,3 @@ dist
# Vite logs files
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
# Letta Code local settings
.letta/settings.local.json
.idea

View File

@@ -18,6 +18,7 @@ export async function handleHeadlessCommand(argv: string[]) {
args: argv,
options: {
continue: { type: "boolean", short: "c" },
new: { type: "boolean" },
agent: { type: "string", short: "a" },
"output-format": { type: "string" },
},
@@ -51,7 +52,9 @@ export async function handleHeadlessCommand(argv: string[]) {
let agent: Letta.AgentState | null = null;
const specifiedAgentId = values.agent as string | undefined;
const shouldContinue = values.continue as boolean | undefined;
const forceNew = values.new as boolean | undefined;
// Priority 1: Try to use --agent specified ID
if (specifiedAgentId) {
try {
agent = await client.agents.retrieve(specifiedAgentId);
@@ -60,6 +63,27 @@ export async function handleHeadlessCommand(argv: string[]) {
}
}
// Priority 2: Check if --new flag was passed (skip all resume logic)
if (!agent && forceNew) {
agent = await createAgent();
}
// Priority 3: Try to resume from project settings (.letta/settings.local.json)
if (!agent) {
const { loadProjectSettings } = await import("./settings");
const projectSettings = await loadProjectSettings();
if (projectSettings?.lastAgent) {
try {
agent = await client.agents.retrieve(projectSettings.lastAgent);
} catch (_error) {
console.error(
`Project agent ${projectSettings.lastAgent} not found, creating new one...`,
);
}
}
}
// Priority 4: Try to reuse global lastAgent if --continue flag is passed
if (!agent && shouldContinue && settings.lastAgent) {
try {
agent = await client.agents.retrieve(settings.lastAgent);
@@ -70,11 +94,16 @@ export async function handleHeadlessCommand(argv: string[]) {
}
}
// Priority 5: Create a new agent
if (!agent) {
agent = await createAgent();
await updateSettings({ lastAgent: agent.id });
}
// Save agent ID to both project and global settings
const { updateProjectSettings } = await import("./settings");
await updateProjectSettings({ lastAgent: agent.id });
await updateSettings({ lastAgent: agent.id });
// Validate output format
const outputFormat =
(values["output-format"] as string | undefined) || "text";

View File

@@ -14,26 +14,32 @@ Letta Code is a general purpose CLI for interacting with Letta agents
USAGE
# interactive TUI
letta Start a new agent session
letta --continue Resume the last agent session
letta Auto-resume project agent (from .letta/settings.local.json)
letta --new Force create a new agent
letta --continue Resume global last agent (deprecated, use project-based)
letta --agent <id> Open a specific agent by ID
# headless
letta --prompt One-off prompt in headless mode (no TTY UI)
letta -p "..." One-off prompt in headless mode (no TTY UI)
OPTIONS
-h, --help Show this help and exit
-v, --version Print version and exit
-c, --continue Resume previous session (uses settings.lastAgent)
--new Force create new agent (skip auto-resume)
-c, --continue Resume previous session (uses global lastAgent, deprecated)
-a, --agent <id> Use a specific agent ID
-p, --prompt Headless prompt mode
--output-format <fmt> Output format for headless mode (text, json, stream-json)
Default: text
BEHAVIOR
By default, letta auto-resumes the last agent used in the current directory
(stored in .letta/settings.local.json). Use --new to force a new agent.
EXAMPLES
# when installed as an executable
letta --help
letta --continue
letta # Auto-resume project agent or create new
letta --new # Force new agent
letta --agent agent_123
# headless with JSON output (includes stats)
@@ -57,6 +63,7 @@ async function main() {
help: { type: "boolean", short: "h" },
version: { type: "boolean", short: "v" },
continue: { type: "boolean", short: "c" },
new: { type: "boolean" },
agent: { type: "string", short: "a" },
prompt: { type: "boolean", short: "p" },
run: { type: "boolean" },
@@ -100,6 +107,7 @@ async function main() {
}
const shouldContinue = (values.continue as boolean | undefined) ?? false;
const forceNew = (values.new as boolean | undefined) ?? false;
const specifiedAgentId = (values.agent as string | undefined) ?? null;
const isHeadless = values.prompt || values.run || !process.stdin.isTTY;
@@ -179,9 +187,11 @@ async function main() {
function LoadingApp({
continueSession,
forceNew,
agentIdArg,
}: {
continueSession: boolean;
forceNew: boolean;
agentIdArg: string | null;
}) {
const [loadingState, setLoadingState] = useState<
@@ -190,6 +200,7 @@ async function main() {
const [agentId, setAgentId] = useState<string | null>(null);
const [agentState, setAgentState] = useState<Letta.AgentState | null>(null);
const [resumeData, setResumeData] = useState<ResumeData | null>(null);
const [isResumingSession, setIsResumingSession] = useState(false);
useEffect(() => {
async function init() {
@@ -202,7 +213,8 @@ async function main() {
setLoadingState("initializing");
const { createAgent } = await import("./agent/create");
const { updateSettings } = await import("./settings");
const { updateSettings, loadProjectSettings, updateProjectSettings } =
await import("./settings");
let agent: Letta.AgentState | null = null;
@@ -218,7 +230,28 @@ async function main() {
}
}
// Priority 2: Try to reuse lastAgent if --continue flag is passed
// Priority 2: Check if --new flag was passed (skip all resume logic)
if (!agent && forceNew) {
// Create new agent, don't check any lastAgent fields
agent = await createAgent();
}
// Priority 3: Try to resume from project settings (.letta/settings.local.json)
if (!agent) {
const projectSettings = await loadProjectSettings();
if (projectSettings?.lastAgent) {
try {
agent = await client.agents.retrieve(projectSettings.lastAgent);
// console.log(`Resuming project agent ${projectSettings.lastAgent}...`);
} catch (error) {
console.error(
`Project agent ${projectSettings.lastAgent} not found (error: ${JSON.stringify(error)}), creating new one...`,
);
}
}
}
// Priority 4: Try to reuse global lastAgent if --continue flag is passed
if (!agent && continueSession && settings.lastAgent) {
try {
agent = await client.agents.retrieve(settings.lastAgent);
@@ -230,15 +263,26 @@ async function main() {
}
}
// Priority 3: Create a new agent
// Priority 5: Create a new agent
if (!agent) {
agent = await createAgent();
// Save the new agent ID to settings
await updateSettings({ lastAgent: agent.id });
}
// Get resume data (pending approval + message history) if continuing session or using specific agent
if (continueSession || agentIdArg) {
// Save agent ID to both project and global settings
await updateProjectSettings({ lastAgent: agent.id });
await updateSettings({ lastAgent: agent.id });
// Check if we're resuming an existing agent
const projectSettings = await loadProjectSettings();
const isResumingProject =
!forceNew &&
projectSettings?.lastAgent &&
agent.id === projectSettings.lastAgent;
const resuming = continueSession || !!agentIdArg || isResumingProject;
setIsResumingSession(resuming);
// Get resume data (pending approval + message history) if resuming
if (resuming) {
setLoadingState("checking");
const data = await getResumeData(client, agent.id);
setResumeData(data);
@@ -250,9 +294,7 @@ async function main() {
}
init();
}, [continueSession, agentIdArg]);
const isResumingSession = continueSession || !!agentIdArg;
}, [continueSession, forceNew, agentIdArg]);
if (!agentId) {
return React.createElement(App, {
@@ -279,6 +321,7 @@ async function main() {
render(
React.createElement(LoadingApp, {
continueSession: shouldContinue,
forceNew: forceNew,
agentIdArg: specifiedAgentId,
}),
{

View File

@@ -1,10 +1,10 @@
// src/settings.ts
// Manages user settings stored in ~/.letta/settings.json
// Manages user settings stored in ~/.letta/settings.json and project settings in ./.letta/settings.local.json
import { homedir } from "node:os";
import { join } from "node:path";
import type { PermissionRules } from "./permissions/types";
import { exists, readFile, writeFile } from "./utils/fs.js";
import { exists, mkdir, readFile, writeFile } from "./utils/fs.js";
export type UIMode = "simple" | "rich";
@@ -17,6 +17,11 @@ export interface Settings {
env?: Record<string, string>;
}
export interface ProjectSettings {
lastAgent: string | null;
permissions?: PermissionRules;
}
const DEFAULT_SETTINGS: Settings = {
uiMode: "simple",
lastAgent: null,
@@ -90,3 +95,66 @@ export async function getSetting<K extends keyof Settings>(
const settings = await loadSettings();
return settings[key];
}
/**
* Get project settings path (./.letta/settings.local.json)
*/
function getProjectSettingsPath(): string {
return join(process.cwd(), ".letta", "settings.local.json");
}
/**
* Load project settings from ./.letta/settings.local.json
* Returns null if file doesn't exist
*/
export async function loadProjectSettings(): Promise<ProjectSettings | null> {
const settingsPath = getProjectSettingsPath();
try {
if (!exists(settingsPath)) {
return null;
}
const content = await readFile(settingsPath);
const settings = JSON.parse(content) as ProjectSettings;
return settings;
} catch (error) {
console.error("Error loading project settings:", error);
return null;
}
}
/**
* Save project settings to ./.letta/settings.local.json
* Creates .letta directory if it doesn't exist
*/
export async function saveProjectSettings(
settings: ProjectSettings,
): Promise<void> {
const settingsPath = getProjectSettingsPath();
const dirPath = join(process.cwd(), ".letta");
try {
// Create .letta directory if it doesn't exist
if (!exists(dirPath)) {
await mkdir(dirPath, { recursive: true });
}
await writeFile(settingsPath, JSON.stringify(settings, null, 2));
} catch (error) {
console.error("Error saving project settings:", error);
throw error;
}
}
/**
* Update project settings fields
*/
export async function updateProjectSettings(
updates: Partial<ProjectSettings>,
): Promise<ProjectSettings> {
const currentSettings = (await loadProjectSettings()) || { lastAgent: null };
const newSettings = { ...currentSettings, ...updates };
await saveProjectSettings(newSettings);
return newSettings;
}

View File

@@ -35,3 +35,13 @@ export async function writeFile(path: string, content: string): Promise<void> {
export function exists(path: string): boolean {
return existsSync(path);
}
/**
* Create a directory, including parent directories
*/
export async function mkdir(
path: string,
options?: { recursive?: boolean },
): Promise<void> {
mkdirSync(path, options);
}