feat: improve --resume and --continue CLI flags (#555)

Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
Charles Packer
2026-01-15 16:37:26 -08:00
committed by GitHub
parent 4907449c01
commit 1192e88849
7 changed files with 222 additions and 48 deletions

View File

@@ -3910,8 +3910,9 @@ export default function App({
return { submitted: true };
}
// Special handling for /clear command - start new conversation
if (msg.trim() === "/clear") {
// Special handling for /clear and /new commands - start new conversation
// (/new used to create a new agent, now it's just an alias for /clear)
if (msg.trim() === "/clear" || msg.trim() === "/new") {
const cmdId = uid("cmd");
buffersRef.current.byId.set(cmdId, {
kind: "command",
@@ -4553,11 +4554,6 @@ export default function App({
}
// Special handling for /new command - create new agent dialog
if (msg.trim() === "/new") {
setActiveOverlay("new");
return { submitted: true };
}
// Special handling for /pin command - pin current agent to project (or globally with -g)
if (msg.trim() === "/pin" || msg.trim().startsWith("/pin ")) {
const argsStr = msg.trim().slice(4).trim();
@@ -7407,6 +7403,10 @@ Plan file path: ${planFilePath}`;
await handleAgentSelect(id);
}}
onCancel={closeOverlay}
onCreateNewAgent={() => {
closeOverlay();
setActiveOverlay("new");
}}
/>
)}

View File

@@ -96,11 +96,11 @@ export const commands: Record<string, Command> = {
// === Page 2: Agent management (order 20-29) ===
"/new": {
desc: "Create a new agent and switch to it",
desc: "Start a new conversation (same as /clear)",
order: 20,
handler: () => {
// Handled specially in App.tsx
return "Creating new agent...";
// Handled specially in App.tsx - same as /clear
return "Starting new conversation...";
},
},
"/pin": {

View File

@@ -16,6 +16,8 @@ interface AgentSelectorProps {
currentAgentId: string;
onSelect: (agentId: string) => void;
onCancel: () => void;
/** Called when user presses N to create a new agent */
onCreateNewAgent?: () => void;
/** The command that triggered this selector (e.g., "/agents" or "/resume") */
command?: string;
}
@@ -111,6 +113,7 @@ export function AgentSelector({
currentAgentId,
onSelect,
onCancel,
onCreateNewAgent,
command = "/agents",
}: AgentSelectorProps) {
const terminalWidth = useTerminalWidth();
@@ -557,6 +560,9 @@ export function AgentSelector({
}
loadPinnedAgents();
}
} else if (input === "n" || input === "N") {
// Create new agent
onCreateNewAgent?.();
} else if (activeTab !== "pinned" && input && !key.ctrl && !key.meta) {
// Type to search (list tabs only)
setSearchInput((prev) => prev + input);
@@ -794,7 +800,7 @@ export function AgentSelector({
: activeTab === "letta-code"
? `Page ${lettaCodePage + 1}${lettaCodeHasMore ? "+" : `/${lettaCodeTotalPages || 1}`}${lettaCodeLoadingMore ? " (loading...)" : ""}`
: `Page ${allPage + 1}${allHasMore ? "+" : `/${allTotalPages || 1}`}${allLoadingMore ? " (loading...)" : ""}`;
const hintsText = `Enter select · ↑↓ navigate · ←→ page · Tab switch${activeTab === "pinned" ? " · P unpin" : " · Type to search"} · Esc cancel`;
const hintsText = `Enter select · ↑↓ navigate · ←→ page · Tab switch${activeTab === "pinned" ? " · P unpin" : " · Type to search"}${onCreateNewAgent ? " · N new" : ""} · Esc cancel`;
return (
<Box flexDirection="column">

View File

@@ -1,16 +1,22 @@
import { Box, Text, useInput } from "ink";
import { useState } from "react";
import { DEFAULT_AGENT_NAME } from "../../constants";
import { useTerminalWidth } from "../hooks/useTerminalWidth";
import { colors } from "./colors";
import { PasteAwareTextInput } from "./PasteAwareTextInput";
import { validateAgentName } from "./PinDialog";
// Horizontal line character (matches other selectors)
const SOLID_LINE = "─";
interface NewAgentDialogProps {
onSubmit: (name: string) => void;
onCancel: () => void;
}
export function NewAgentDialog({ onSubmit, onCancel }: NewAgentDialogProps) {
const terminalWidth = useTerminalWidth();
const solidLine = SOLID_LINE.repeat(Math.max(terminalWidth, 10));
const [nameInput, setNameInput] = useState("");
const [error, setError] = useState("");
@@ -45,25 +51,37 @@ export function NewAgentDialog({ onSubmit, onCancel }: NewAgentDialogProps) {
};
return (
<Box flexDirection="column" paddingY={1}>
<Box marginBottom={1}>
<Text color={colors.approval.header} bold>
Create new agent
</Text>
</Box>
<Box flexDirection="column">
{/* Command header */}
<Text dimColor>{"> /agents"}</Text>
<Text dimColor>{solidLine}</Text>
<Box marginBottom={1}>
<Text dimColor>
<Box height={1} />
{/* Title */}
<Text bold color={colors.selector.title}>
Create new agent
</Text>
<Box height={1} />
{/* Description */}
<Box paddingLeft={2}>
<Text>
Enter a name for your new agent, or press Enter for default.
</Text>
</Box>
<Box flexDirection="column" marginBottom={1}>
<Box marginBottom={1}>
<Box height={1} />
{/* Input field */}
<Box flexDirection="column">
<Box paddingLeft={2}>
<Text>Agent name:</Text>
</Box>
<Box>
<Text color={colors.approval.header}>&gt; </Text>
<Text color={colors.selector.itemHighlighted}>{">"}</Text>
<Text> </Text>
<PasteAwareTextInput
value={nameInput}
onChange={(val) => {
@@ -77,13 +95,16 @@ export function NewAgentDialog({ onSubmit, onCancel }: NewAgentDialogProps) {
</Box>
{error && (
<Box marginBottom={1}>
<Box paddingLeft={2} marginTop={1}>
<Text color="red">{error}</Text>
</Box>
)}
<Box>
<Text dimColor>Press Enter to create Esc to cancel</Text>
<Box height={1} />
{/* Footer hints */}
<Box paddingLeft={2}>
<Text dimColor>Enter create · Esc cancel</Text>
</Box>
</Box>
);

View File

@@ -66,6 +66,8 @@ export async function handleHeadlessCommand(
options: {
// Flags used in headless mode
continue: { type: "boolean", short: "c" },
resume: { type: "boolean", short: "r" },
conversation: { type: "string" },
new: { type: "boolean" },
agent: { type: "string", short: "a" },
model: { type: "string", short: "m" },
@@ -167,9 +169,21 @@ export async function handleHeadlessCommand(
const client = await getClient();
// Check for --resume flag (interactive only)
if (values.resume) {
console.error(
"Error: --resume is for interactive mode only (opens conversation selector).\n" +
"In headless mode, use:\n" +
" --continue Resume the last session (agent + conversation)\n" +
" --conversation <id> Resume a specific conversation by ID",
);
process.exit(1);
}
// Resolve agent (same logic as interactive mode)
let agent: AgentState | null = null;
const specifiedAgentId = values.agent as string | undefined;
const specifiedConversationId = values.conversation as string | undefined;
const shouldContinue = values.continue as boolean | undefined;
const forceNew = values.new as boolean | undefined;
const systemPromptPreset = values.system as string | undefined;
@@ -444,13 +458,55 @@ export async function handleHeadlessCommand(
}
}
// Always create a new conversation on startup for headless mode too
// This ensures isolated message history per CLI invocation
const conversation = await client.conversations.create({
agent_id: agent.id,
isolated_block_labels: [...ISOLATED_BLOCK_LABELS],
});
const conversationId = conversation.id;
// Determine which conversation to use
let conversationId: string;
if (specifiedConversationId) {
// User specified a conversation to resume
try {
await client.conversations.retrieve(specifiedConversationId);
conversationId = specifiedConversationId;
} catch {
console.error(`Error: Conversation ${specifiedConversationId} not found`);
process.exit(1);
}
} else if (shouldContinue) {
// Try to resume the last conversation for this agent
await settingsManager.loadLocalProjectSettings();
const lastSession =
settingsManager.getLocalLastSession(process.cwd()) ??
settingsManager.getGlobalLastSession();
if (lastSession && lastSession.agentId === agent.id) {
// Verify the conversation still exists
try {
await client.conversations.retrieve(lastSession.conversationId);
conversationId = lastSession.conversationId;
} catch {
// Conversation no longer exists, create new
const conversation = await client.conversations.create({
agent_id: agent.id,
isolated_block_labels: [...ISOLATED_BLOCK_LABELS],
});
conversationId = conversation.id;
}
} else {
// No matching session, create new conversation
const conversation = await client.conversations.create({
agent_id: agent.id,
isolated_block_labels: [...ISOLATED_BLOCK_LABELS],
});
conversationId = conversation.id;
}
} else {
// Default: create a new conversation
// This ensures isolated message history per CLI invocation
const conversation = await client.conversations.create({
agent_id: agent.id,
isolated_block_labels: [...ISOLATED_BLOCK_LABELS],
});
conversationId = conversation.id;
}
// Save session (agent + conversation) to both project and global settings
await settingsManager.loadLocalProjectSettings();
@@ -546,6 +602,7 @@ export async function handleHeadlessCommand(
subtype: "init",
session_id: sessionId,
agent_id: agent.id,
conversation_id: conversationId,
model: agent.llm_config?.model ?? "",
tools:
agent.tools?.map((t) => t.name).filter((n): n is string => !!n) || [],
@@ -1360,6 +1417,7 @@ export async function handleHeadlessCommand(
num_turns: stats.usage.stepCount,
result: resultText,
agent_id: agent.id,
conversation_id: conversationId,
usage: {
prompt_tokens: stats.usage.promptTokens,
completion_tokens: stats.usage.completionTokens,
@@ -1395,6 +1453,7 @@ export async function handleHeadlessCommand(
num_turns: stats.usage.stepCount,
result: resultText,
agent_id: agent.id,
conversation_id: conversationId,
run_ids: Array.from(allRunIds),
usage: {
prompt_tokens: stats.usage.promptTokens,
@@ -1435,6 +1494,7 @@ async function runBidirectionalMode(
subtype: "init",
session_id: sessionId,
agent_id: agent.id,
conversation_id: conversationId,
model: agent.llm_config?.model,
tools: agent.tools?.map((t) => t.name) || [],
cwd: process.cwd(),
@@ -1898,6 +1958,7 @@ async function runBidirectionalMode(
num_turns: numTurns,
result: resultText,
agent_id: agent.id,
conversation_id: conversationId,
run_ids: [],
usage: null,
uuid: `result-${agent.id}-${Date.now()}`,

View File

@@ -9,6 +9,7 @@ import { initializeLoadedSkillsFlag, setAgentContext } from "./agent/context";
import type { AgentProvenance } from "./agent/create";
import { ISOLATED_BLOCK_LABELS } from "./agent/memory";
import { LETTA_CLOUD_API_URL } from "./auth/oauth";
import { ConversationSelector } from "./cli/components/ConversationSelector";
import type { ApprovalRequest } from "./cli/helpers/stream";
import { ProfileSelectionInline } from "./cli/profile-selection";
import { permissionMode } from "./permissions/mode";
@@ -30,6 +31,8 @@ Letta Code is a general purpose CLI for interacting with Letta agents
USAGE
# interactive TUI
letta Resume from profile or create new agent (shows selector)
letta --continue Resume last session (agent + conversation) directly
letta --resume Open agent selector UI to pick agent/conversation
letta --new Create a new agent directly (skip profile selector)
letta --agent <id> Open a specific agent by ID
@@ -43,6 +46,8 @@ OPTIONS
-h, --help Show this help and exit
-v, --version Print version and exit
--info Show current directory, skills, and pinned agents
--continue Resume last session (agent + conversation) directly
-r, --resume Open agent selector UI after loading
--new Create new agent directly (skip profile selection)
--init-blocks <list> Comma-separated memory blocks to initialize when using --new (e.g., "persona,skills")
--base-tools <list> Comma-separated base tools to attach when using --new (e.g., "memory,web_search,conversation_search")
@@ -415,16 +420,10 @@ async function main(): Promise<void> {
process.exit(result.success ? 0 : 1);
}
// Check for deprecated --continue flag
if (values.continue) {
console.error(
"Error: --continue is deprecated. Did you mean --resume (-r)?\n" +
" --resume resumes your last session (agent + conversation)",
);
process.exit(1);
}
const shouldResume = (values.resume as boolean | undefined) ?? false; // Resume last session
// --continue: Resume last session (agent + conversation) automatically
const shouldContinue = (values.continue as boolean | undefined) ?? false;
// --resume: Open agent selector UI after loading
const shouldResume = (values.resume as boolean | undefined) ?? false;
const specifiedConversationId =
(values.conversation as string | undefined) ?? null; // Specific conversation to resume
const forceNew = (values.new as boolean | undefined) ?? false;
@@ -818,6 +817,7 @@ async function main(): Promise<void> {
const [loadingState, setLoadingState] = useState<
| "selecting"
| "selecting_global"
| "selecting_conversation"
| "assembling"
| "importing"
| "initializing"
@@ -836,6 +836,11 @@ async function main(): Promise<void> {
const [selectedGlobalAgentId, setSelectedGlobalAgentId] = useState<
string | null
>(null);
// Track agent and conversation for conversation selector (--resume flag)
const [resumeAgentId, setResumeAgentId] = useState<string | null>(null);
const [selectedConversationId, setSelectedConversationId] = useState<
string | null
>(null);
// Track when user explicitly requested new agent from selector (not via --new flag)
const [userRequestedNewAgent, setUserRequestedNewAgent] = useState(false);
@@ -953,6 +958,28 @@ async function main(): Promise<void> {
);
}
// Handle --resume flag: show conversation selector directly
if (shouldResume) {
// Find the last-used agent for this project
const lastSession =
settingsManager.getLocalLastSession(process.cwd()) ??
settingsManager.getGlobalLastSession();
const lastAgentId = lastSession?.agentId ?? localSettings.lastAgent;
if (lastAgentId) {
// Verify agent exists
try {
await client.agents.retrieve(lastAgentId);
setResumeAgentId(lastAgentId);
setLoadingState("selecting_conversation");
return;
} catch {
// Agent doesn't exist, fall through to normal flow
}
}
// No valid agent found, fall through to normal startup
}
// Show selector if:
// 1. No lastAgent in this project (fresh directory)
// 2. No explicit flags that bypass selection (--new, --agent, --from-af, --continue)
@@ -967,7 +994,7 @@ async function main(): Promise<void> {
setLoadingState("assembling");
}
checkAndStart();
}, [forceNew, agentIdArg, fromAfFile, continueSession]);
}, [forceNew, agentIdArg, fromAfFile, continueSession, shouldResume]);
// Main initialization effect - runs after profile selection
useEffect(() => {
@@ -989,6 +1016,11 @@ async function main(): Promise<void> {
}
}
// Priority 1.5: Use agent from conversation selector (--resume flag)
if (!resumingAgentId && resumeAgentId) {
resumingAgentId = resumeAgentId;
}
// Priority 2: Use agent selected from global selector (user just picked one)
// This takes precedence over stale LRU since user explicitly chose it
const shouldCreateNew = forceNew || userRequestedNewAgent;
@@ -1283,6 +1315,7 @@ async function main(): Promise<void> {
// Debug: log resume flag status
if (process.env.DEBUG) {
console.log(`[DEBUG] shouldContinue=${shouldContinue}`);
console.log(`[DEBUG] shouldResume=${shouldResume}`);
console.log(
`[DEBUG] specifiedConversationId=${specifiedConversationId}`,
@@ -1318,7 +1351,7 @@ async function main(): Promise<void> {
}
throw error;
}
} else if (shouldResume) {
} else if (shouldContinue) {
// Try to load the last session for this agent
const lastSession =
settingsManager.getLocalLastSession(process.cwd()) ??
@@ -1372,6 +1405,29 @@ async function main(): Promise<void> {
});
conversationIdToUse = conversation.id;
}
} else if (selectedConversationId) {
// User selected a specific conversation from the --resume selector
try {
setLoadingState("checking");
const freshAgent = await client.agents.retrieve(agent.id);
const data = await getResumeData(
client,
freshAgent,
selectedConversationId,
);
conversationIdToUse = selectedConversationId;
setResumedExistingConversation(true);
setResumeData(data);
} catch (error) {
if (
error instanceof APIError &&
(error.status === 404 || error.status === 422)
) {
console.error(`Conversation ${selectedConversationId} not found`);
process.exit(1);
}
throw error;
}
} else {
// Default: create a new conversation on startup
// This ensures each CLI session has isolated message history
@@ -1418,7 +1474,9 @@ async function main(): Promise<void> {
fromAfFile,
loadingState,
selectedGlobalAgentId,
shouldResume,
shouldContinue,
resumeAgentId,
selectedConversationId,
]);
// Wait for keybinding auto-install to complete before showing UI
@@ -1431,6 +1489,25 @@ async function main(): Promise<void> {
return null;
}
// Show conversation selector for --resume flag
if (loadingState === "selecting_conversation" && resumeAgentId) {
return React.createElement(ConversationSelector, {
agentId: resumeAgentId,
currentConversationId: "", // No current conversation yet
onSelect: (conversationId: string) => {
setSelectedConversationId(conversationId);
setLoadingState("assembling");
},
onNewConversation: () => {
// Start with a new conversation for this agent
setLoadingState("assembling");
},
onCancel: () => {
process.exit(0);
},
});
}
// Show global agent selector in fresh repos with global pinned agents
if (loadingState === "selecting_global") {
return React.createElement(ProfileSelectionInline, {
@@ -1451,11 +1528,18 @@ async function main(): Promise<void> {
});
}
// At this point, loadingState is not "selecting", "selecting_global", or "selecting_conversation"
// (those are handled above), so it's safe to pass to App
const appLoadingState = loadingState as Exclude<
typeof loadingState,
"selecting" | "selecting_global" | "selecting_conversation"
>;
if (!agentId || !conversationId) {
return React.createElement(App, {
agentId: "loading",
conversationId: "loading",
loadingState,
loadingState: appLoadingState,
continueSession: isResumingSession,
startupApproval: resumeData?.pendingApproval ?? null,
startupApprovals: resumeData?.pendingApprovals ?? EMPTY_APPROVAL_ARRAY,
@@ -1470,7 +1554,7 @@ async function main(): Promise<void> {
agentId,
agentState,
conversationId,
loadingState,
loadingState: appLoadingState,
continueSession: isResumingSession,
startupApproval: resumeData?.pendingApproval ?? null,
startupApprovals: resumeData?.pendingApprovals ?? EMPTY_APPROVAL_ARRAY,
@@ -1483,7 +1567,7 @@ async function main(): Promise<void> {
render(
React.createElement(LoadingApp, {
continueSession: shouldResume,
continueSession: shouldContinue,
forceNew: forceNew,
initBlocks: initBlocks,
baseTools: baseTools,

View File

@@ -81,6 +81,7 @@ export interface SystemInitMessage extends MessageEnvelope {
type: "system";
subtype: "init";
agent_id: string;
conversation_id: string;
model: string;
tools: string[];
cwd: string;
@@ -217,6 +218,7 @@ export interface ResultMessage extends MessageEnvelope {
type: "result";
subtype: ResultSubtype;
agent_id: string;
conversation_id: string;
duration_ms: number;
duration_api_ms: number;
num_turns: number;