- headless: reflection + transcript in bidirectional; conscience env vars route to persistent agent (falls back clean) - manager: prefer llm_config.handle directly — stops the 500 on self-hosted - create: optionstags typo fixed, lettabot tag exclusion added Aster is persistent now. She has a conscience.
2090 lines
73 KiB
TypeScript
Executable File
2090 lines
73 KiB
TypeScript
Executable File
#!/usr/bin/env bun
|
|
import { APIError } from "@letta-ai/letta-client/core/error";
|
|
import type { AgentState } from "@letta-ai/letta-client/resources/agents/agents";
|
|
import type { Message } from "@letta-ai/letta-client/resources/agents/messages";
|
|
import { getResumeData, type ResumeData } from "./agent/check-approval";
|
|
import { getClient } from "./agent/client";
|
|
import {
|
|
setAgentContext,
|
|
setConversationId as setContextConversationId,
|
|
} from "./agent/context";
|
|
import type { AgentProvenance } from "./agent/create";
|
|
import { getLettaCodeHeaders } from "./agent/http-headers";
|
|
import { ISOLATED_BLOCK_LABELS } from "./agent/memory";
|
|
import {
|
|
getModelPresetUpdateForAgent,
|
|
getModelUpdateArgs,
|
|
getResumeRefreshArgs,
|
|
resolveModel,
|
|
} from "./agent/model";
|
|
import { updateAgentLLMConfig, updateAgentSystemPrompt } from "./agent/modify";
|
|
import { resolveSkillSourcesSelection } from "./agent/skillSources";
|
|
import { LETTA_CLOUD_API_URL } from "./auth/oauth";
|
|
import {
|
|
type ParsedCliArgs,
|
|
parseCliArgs,
|
|
preprocessCliArgs,
|
|
renderCliOptionsHelp,
|
|
} from "./cli/args";
|
|
import { ConversationSelector } from "./cli/components/ConversationSelector";
|
|
import {
|
|
normalizeConversationShorthandFlags,
|
|
parseCsvListFlag,
|
|
parseJsonArrayFlag,
|
|
resolveImportFlagAlias,
|
|
} from "./cli/flagUtils";
|
|
import { formatErrorDetails } from "./cli/helpers/errorFormatter";
|
|
import { ensureFileIndex } from "./cli/helpers/fileIndex";
|
|
import type { ApprovalRequest } from "./cli/helpers/stream";
|
|
import { ProfileSelectionInline } from "./cli/profile-selection";
|
|
import {
|
|
validateConversationDefaultRequiresAgent,
|
|
validateFlagConflicts,
|
|
validateRegistryHandleOrThrow,
|
|
} from "./cli/startupFlagValidation";
|
|
import { runSubcommand } from "./cli/subcommands/router";
|
|
import { permissionMode } from "./permissions/mode";
|
|
import { settingsManager } from "./settings-manager";
|
|
import { startStartupAutoUpdateCheck } from "./startup-auto-update";
|
|
import { telemetry } from "./telemetry";
|
|
import { loadTools } from "./tools/manager";
|
|
import { clearPersistedClientToolRules } from "./tools/toolset";
|
|
import { debugLog, debugWarn, isDebugEnabled } from "./utils/debug";
|
|
import { markMilestone } from "./utils/timing";
|
|
|
|
// Stable empty array constants to prevent new references on every render
|
|
// These are used as fallbacks when resumeData is null, avoiding the React
|
|
// anti-pattern of creating new [] on every render which triggers useEffect re-runs
|
|
const EMPTY_APPROVAL_ARRAY: ApprovalRequest[] = [];
|
|
const EMPTY_MESSAGE_ARRAY: Message[] = [];
|
|
void ensureFileIndex();
|
|
|
|
function printHelp() {
|
|
// Keep this plaintext (no colors) so output pipes cleanly
|
|
const usage = `
|
|
Letta Code is a general purpose CLI for interacting with Letta agents
|
|
|
|
USAGE
|
|
# interactive TUI
|
|
letta Resume last conversation for this project
|
|
letta --new Create a new conversation (for concurrent sessions)
|
|
letta --resume Open agent selector UI to pick agent/conversation
|
|
letta --new-agent Create a new agent directly (skip profile selector)
|
|
letta --agent <id> Open a specific agent by ID
|
|
|
|
# headless
|
|
letta -p "..." One-off prompt in headless mode (no TTY UI)
|
|
|
|
# maintenance
|
|
letta update Manually check for updates and install if available
|
|
letta memfs ... Memory filesystem subcommands (JSON-only)
|
|
letta agents ... Agents subcommands (JSON-only)
|
|
letta messages ... Messages subcommands (JSON-only)
|
|
letta blocks ... Blocks subcommands (JSON-only)
|
|
letta connect ... Connect providers from terminal
|
|
|
|
OPTIONS
|
|
${renderCliOptionsHelp()}
|
|
|
|
SUBCOMMANDS (JSON-only)
|
|
letta memfs status --agent <id>
|
|
letta memfs diff --agent <id>
|
|
letta memfs resolve --agent <id> --resolutions '<JSON>'
|
|
letta memfs backup --agent <id>
|
|
letta memfs backups --agent <id>
|
|
letta memfs restore --agent <id> --from <backup> --force
|
|
letta memfs export --agent <id> --out <dir>
|
|
letta agents list [--query <text> | --name <name> | --tags <tags>]
|
|
letta messages search --query <text> [--all-agents]
|
|
letta messages list [--agent <id>]
|
|
letta blocks list --agent <id>
|
|
letta blocks copy --block-id <id> [--label <label>] [--agent <id>] [--override]
|
|
letta blocks attach --block-id <id> [--agent <id>] [--read-only] [--override]
|
|
letta connect <provider> [options]
|
|
|
|
BEHAVIOR
|
|
On startup, Letta Code checks for saved profiles:
|
|
- If profiles exist, you'll be prompted to select one or create a new agent
|
|
- Profiles can be "pinned" to specific projects for quick access
|
|
- Use /profile save <name> to bookmark your current agent
|
|
|
|
Profiles are stored in:
|
|
- Global: ~/.letta/settings.json (available everywhere)
|
|
- Local: .letta/settings.local.json (pinned to project)
|
|
|
|
If no credentials are configured, you'll be prompted to authenticate via
|
|
Letta Cloud OAuth on first run.
|
|
|
|
EXAMPLES
|
|
# when installed as an executable
|
|
letta # Show profile selector or create new
|
|
letta --new # Create new conversation
|
|
letta --agent agent_123 # Open specific agent
|
|
|
|
# inside the interactive session
|
|
/profile save MyAgent # Save current agent as profile
|
|
/profiles # Open profile selector
|
|
/pin # Pin current profile to project
|
|
/unpin # Unpin profile from project
|
|
/logout # Clear credentials and exit
|
|
|
|
# headless with JSON output (includes stats)
|
|
letta -p "hello" --output-format json
|
|
|
|
`.trim();
|
|
|
|
console.log(usage);
|
|
}
|
|
|
|
/**
|
|
* Print info about current directory, skills, and pinned agents
|
|
*/
|
|
async function printInfo() {
|
|
const { join } = await import("node:path");
|
|
const { getVersion } = await import("./version");
|
|
const { SKILLS_DIR } = await import("./agent/skills");
|
|
const { exists } = await import("./utils/fs");
|
|
|
|
const cwd = process.cwd();
|
|
const skillsDir = join(cwd, SKILLS_DIR);
|
|
const skillsExist = exists(skillsDir);
|
|
|
|
// Load local project settings first
|
|
await settingsManager.loadLocalProjectSettings(cwd);
|
|
|
|
// Get pinned agents
|
|
const localPinned = settingsManager.getLocalPinnedAgents(cwd);
|
|
const globalPinned = settingsManager.getGlobalPinnedAgents();
|
|
const localSettings = settingsManager.getLocalProjectSettings(cwd);
|
|
const lastAgent = localSettings.lastAgent;
|
|
|
|
// Try to fetch agent names from API (if authenticated)
|
|
const agentNames: Record<string, string> = {};
|
|
const allAgentIds = [
|
|
...new Set([
|
|
...localPinned,
|
|
...globalPinned,
|
|
...(lastAgent ? [lastAgent] : []),
|
|
]),
|
|
];
|
|
|
|
if (allAgentIds.length > 0) {
|
|
try {
|
|
const client = await getClient();
|
|
// Fetch each agent individually to get accurate names
|
|
await Promise.all(
|
|
allAgentIds.map(async (id) => {
|
|
try {
|
|
const agent = await client.agents.retrieve(id);
|
|
agentNames[id] = agent.name;
|
|
} catch {
|
|
// Agent not found or error - leave as not found
|
|
}
|
|
}),
|
|
);
|
|
} catch {
|
|
// Not authenticated or API error - just show IDs
|
|
}
|
|
}
|
|
|
|
const formatAgent = (id: string) => {
|
|
const name = agentNames[id];
|
|
return name ? `${id} (${name})` : `${id} (not found)`;
|
|
};
|
|
|
|
console.log(`Letta Code ${getVersion()}\n`);
|
|
console.log(`Current directory: ${cwd}`);
|
|
console.log(
|
|
`Skills directory: ${skillsDir}${skillsExist ? "" : " (not found)"}`,
|
|
);
|
|
|
|
console.log("");
|
|
|
|
// Show which agent will be resumed
|
|
if (lastAgent) {
|
|
console.log(`Will resume: ${formatAgent(lastAgent)}`);
|
|
} else if (localPinned.length > 0 || globalPinned.length > 0) {
|
|
console.log("Will resume: (will show selector)");
|
|
} else {
|
|
console.log("Will resume: (will create new agent)");
|
|
}
|
|
|
|
console.log("");
|
|
|
|
// Locally pinned agents
|
|
if (localPinned.length > 0) {
|
|
console.log("Locally pinned agents (this project):");
|
|
for (const id of localPinned) {
|
|
const isLast = id === lastAgent;
|
|
const prefix = isLast ? "→ " : " ";
|
|
const suffix = isLast ? " (last used)" : "";
|
|
console.log(` ${prefix}${formatAgent(id)}${suffix}`);
|
|
}
|
|
} else {
|
|
console.log("Locally pinned agents: (none)");
|
|
}
|
|
|
|
console.log("");
|
|
|
|
// Globally pinned agents
|
|
if (globalPinned.length > 0) {
|
|
console.log("Globally pinned agents:");
|
|
for (const id of globalPinned) {
|
|
const isLocal = localPinned.includes(id);
|
|
console.log(` ${formatAgent(id)}${isLocal ? " (also local)" : ""}`);
|
|
}
|
|
} else {
|
|
console.log("Globally pinned agents: (none)");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Helper to determine which model identifier to pass to loadTools()
|
|
* based on user's model and/or toolset preferences.
|
|
*/
|
|
function getModelForToolLoading(
|
|
specifiedModel?: string,
|
|
specifiedToolset?: "auto" | "codex" | "default" | "gemini",
|
|
): string | undefined {
|
|
// If toolset is explicitly specified, use a dummy model from that provider
|
|
// to trigger the correct toolset loading logic
|
|
if (specifiedToolset === "codex") {
|
|
return "openai/gpt-4";
|
|
}
|
|
if (specifiedToolset === "gemini") {
|
|
return "google_ai/gemini-3.1-pro-preview";
|
|
}
|
|
if (specifiedToolset === "default") {
|
|
return "anthropic/claude-sonnet-4";
|
|
}
|
|
// Otherwise, use the specified model (or undefined for auto-detection)
|
|
return specifiedModel;
|
|
}
|
|
|
|
/**
|
|
* Resolve an agent ID by name from pinned agents.
|
|
* Case-insensitive exact match. If multiple matches, picks the most recently used.
|
|
*/
|
|
async function resolveAgentByName(
|
|
name: string,
|
|
): Promise<{ id: string; name: string; agent: AgentState } | null> {
|
|
const client = await getClient();
|
|
|
|
// Get all pinned agents (local first, then global, deduplicated)
|
|
const localPinned = settingsManager.getLocalPinnedAgents();
|
|
const globalPinned = settingsManager.getGlobalPinnedAgents();
|
|
const allPinned = [...new Set([...localPinned, ...globalPinned])];
|
|
|
|
if (allPinned.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
// Fetch names for all pinned agents and find matches
|
|
const matches: { id: string; name: string; agent: AgentState }[] = [];
|
|
const normalizedSearchName = name.toLowerCase();
|
|
|
|
await Promise.all(
|
|
allPinned.map(async (id) => {
|
|
try {
|
|
const agent = await client.agents.retrieve(id);
|
|
if (agent.name?.toLowerCase() === normalizedSearchName) {
|
|
matches.push({ id, name: agent.name, agent });
|
|
}
|
|
} catch {
|
|
// Agent not found or error, skip
|
|
}
|
|
}),
|
|
);
|
|
|
|
if (matches.length === 0) return null;
|
|
if (matches.length === 1) return matches[0] ?? null;
|
|
|
|
// Multiple matches - pick most recently used
|
|
// Check local LRU first
|
|
const localSettings = settingsManager.getLocalProjectSettings();
|
|
const localMatch = matches.find((m) => m.id === localSettings.lastAgent);
|
|
if (localMatch) return localMatch;
|
|
|
|
// Then global LRU
|
|
const settings = settingsManager.getSettings();
|
|
const globalMatch = matches.find((m) => m.id === settings.lastAgent);
|
|
if (globalMatch) return globalMatch;
|
|
|
|
// Fallback to first match (preserves local pinned order)
|
|
return matches[0] ?? null;
|
|
}
|
|
|
|
/**
|
|
* Get all pinned agent names for error messages
|
|
*/
|
|
async function getPinnedAgentNames(): Promise<{ id: string; name: string }[]> {
|
|
const client = await getClient();
|
|
const localPinned = settingsManager.getLocalPinnedAgents();
|
|
const globalPinned = settingsManager.getGlobalPinnedAgents();
|
|
const allPinned = [...new Set([...localPinned, ...globalPinned])];
|
|
|
|
const agents: { id: string; name: string }[] = [];
|
|
await Promise.all(
|
|
allPinned.map(async (id) => {
|
|
try {
|
|
const agent = await client.agents.retrieve(id);
|
|
agents.push({ id, name: agent.name || "(unnamed)" });
|
|
} catch {
|
|
// Agent not found, skip
|
|
}
|
|
}),
|
|
);
|
|
return agents;
|
|
}
|
|
|
|
async function main(): Promise<void> {
|
|
markMilestone("CLI_START");
|
|
|
|
// Initialize terminal theme detection (OSC 11 query with fallback)
|
|
const { initTerminalTheme } = await import("./cli/helpers/terminalTheme");
|
|
await initTerminalTheme();
|
|
|
|
// Initialize settings manager (loads settings once into memory)
|
|
await settingsManager.initialize();
|
|
const settings = await settingsManager.getSettingsWithSecureTokens();
|
|
markMilestone("SETTINGS_LOADED");
|
|
|
|
// Handle CLI subcommands (e.g., `letta memfs ...`) before parsing global flags
|
|
const subcommandResult = await runSubcommand(process.argv.slice(2));
|
|
if (subcommandResult !== null) {
|
|
process.exit(subcommandResult);
|
|
}
|
|
|
|
// Initialize LSP infrastructure for type checking
|
|
if (process.env.LETTA_ENABLE_LSP) {
|
|
try {
|
|
const { lspManager } = await import("./lsp/manager.js");
|
|
await lspManager.initialize(process.cwd());
|
|
} catch (error) {
|
|
console.error("[LSP] Failed to initialize:", error);
|
|
}
|
|
}
|
|
|
|
// Initialize telemetry (enabled by default, opt-out via LETTA_CODE_TELEM=0)
|
|
telemetry.init();
|
|
|
|
// Check for updates on startup (non-blocking)
|
|
const { checkAndAutoUpdate } = await import("./updater/auto-update");
|
|
const autoUpdatePromise = startStartupAutoUpdateCheck(checkAndAutoUpdate);
|
|
|
|
// Check Docker version for self-hosted users (non-blocking)
|
|
const { startDockerVersionCheck } = await import("./startup-docker-check");
|
|
startDockerVersionCheck().catch(() => {});
|
|
|
|
// Clean up old overflow files (non-blocking, 24h retention)
|
|
const { cleanupOldOverflowFiles } = await import("./tools/impl/overflow");
|
|
Promise.resolve().then(() => {
|
|
try {
|
|
cleanupOldOverflowFiles(process.cwd());
|
|
} catch {
|
|
// Silently ignore cleanup failures
|
|
}
|
|
});
|
|
|
|
// Parse command-line arguments from a shared schema used by both TUI and headless flows.
|
|
// Preprocess args to support --conv as an alias for --conversation.
|
|
const processedArgs = preprocessCliArgs(process.argv);
|
|
|
|
let values: ParsedCliArgs["values"];
|
|
let positionals: ParsedCliArgs["positionals"];
|
|
try {
|
|
const parsed = parseCliArgs(processedArgs, true);
|
|
values = parsed.values;
|
|
positionals = parsed.positionals;
|
|
} catch (error) {
|
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
// Improve error message for common mistakes
|
|
if (errorMsg.includes("Unknown option")) {
|
|
console.error(`Error: ${errorMsg}`);
|
|
console.error(
|
|
"\nNote: Flags should use double dashes for full names (e.g., --yolo, not -yolo)",
|
|
);
|
|
} else {
|
|
console.error(`Error: ${errorMsg}`);
|
|
}
|
|
console.error("Run 'letta --help' for usage information.");
|
|
process.exit(1);
|
|
}
|
|
|
|
// Check for subcommands
|
|
const command = positionals[2]; // First positional after node and script
|
|
|
|
// Handle help flag first
|
|
if (values.help) {
|
|
printHelp();
|
|
|
|
// Test-only hook to keep process alive briefly so startup auto-update can run.
|
|
const helpDelayMs = Number.parseInt(
|
|
process.env.LETTA_TEST_HELP_EXIT_DELAY_MS ?? "",
|
|
10,
|
|
);
|
|
if (Number.isFinite(helpDelayMs) && helpDelayMs > 0) {
|
|
await new Promise((resolve) => setTimeout(resolve, helpDelayMs));
|
|
}
|
|
|
|
process.exit(0);
|
|
}
|
|
|
|
// Handle version flag
|
|
if (values.version) {
|
|
const { getVersion } = await import("./version");
|
|
console.log(`${getVersion()} (Letta Code)`);
|
|
process.exit(0);
|
|
}
|
|
|
|
// Handle info flag
|
|
if (values.info) {
|
|
await printInfo();
|
|
process.exit(0);
|
|
}
|
|
|
|
// Handle update command
|
|
if (command === "update") {
|
|
const { manualUpdate } = await import("./updater/auto-update");
|
|
const result = await manualUpdate();
|
|
console.log(result.message);
|
|
process.exit(result.success ? 0 : 1);
|
|
}
|
|
|
|
// --resume: Open agent selector UI after loading
|
|
const shouldResume = values.resume ?? false;
|
|
let specifiedConversationId = values.conversation ?? null; // Specific conversation to resume
|
|
const forceNew = values["new-agent"] ?? false;
|
|
|
|
// --new: Create a new conversation (for concurrent sessions)
|
|
const forceNewConversation = values.new ?? false;
|
|
|
|
const initBlocksRaw = values["init-blocks"];
|
|
const baseToolsRaw = values["base-tools"];
|
|
let specifiedAgentId = values.agent ?? null;
|
|
try {
|
|
const normalized = normalizeConversationShorthandFlags({
|
|
specifiedConversationId,
|
|
specifiedAgentId,
|
|
});
|
|
specifiedConversationId = normalized.specifiedConversationId ?? null;
|
|
specifiedAgentId = normalized.specifiedAgentId ?? null;
|
|
} catch (error) {
|
|
console.error(
|
|
error instanceof Error ? `Error: ${error.message}` : String(error),
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
// Validate --conv default requires --agent (unless --new-agent will create one)
|
|
try {
|
|
validateConversationDefaultRequiresAgent({
|
|
specifiedConversationId,
|
|
specifiedAgentId,
|
|
forceNew,
|
|
});
|
|
} catch (error) {
|
|
console.error(
|
|
error instanceof Error ? `Error: ${error.message}` : String(error),
|
|
);
|
|
console.error("Usage: letta --agent agent-xyz --conv default");
|
|
console.error(" or: letta --conv agent-xyz (shorthand)");
|
|
process.exit(1);
|
|
}
|
|
|
|
const specifiedAgentName = values.name ?? null;
|
|
const specifiedModel = values.model ?? undefined;
|
|
const systemPromptPreset = values.system ?? undefined;
|
|
const systemCustom = values["system-custom"] ?? undefined;
|
|
const memoryBlocksJson = values["memory-blocks"] ?? undefined;
|
|
const specifiedToolset = values.toolset ?? undefined;
|
|
const skillsDirectory = values.skills ?? undefined;
|
|
const memfsFlag = values.memfs;
|
|
const noMemfsFlag = values["no-memfs"];
|
|
const requestedMemoryPromptMode: "memfs" | "standard" | undefined = memfsFlag
|
|
? "memfs"
|
|
: noMemfsFlag
|
|
? "standard"
|
|
: undefined;
|
|
const shouldAutoEnableMemfsForNewAgent = !memfsFlag && !noMemfsFlag;
|
|
const noSkillsFlag = values["no-skills"];
|
|
const noBundledSkillsFlag = values["no-bundled-skills"];
|
|
const skillSourcesRaw = values["skill-sources"];
|
|
const noSystemInfoReminderFlag = values["no-system-info-reminder"];
|
|
const resolvedSkillSources = (() => {
|
|
try {
|
|
return resolveSkillSourcesSelection({
|
|
skillSourcesRaw,
|
|
noSkills: noSkillsFlag,
|
|
noBundledSkills: noBundledSkillsFlag,
|
|
});
|
|
} catch (error) {
|
|
console.error(
|
|
error instanceof Error ? `Error: ${error.message}` : String(error),
|
|
);
|
|
process.exit(1);
|
|
}
|
|
})();
|
|
const fromAfFile = resolveImportFlagAlias({
|
|
importFlagValue: values.import,
|
|
fromAfFlagValue: values["from-af"],
|
|
});
|
|
const isHeadless = values.prompt || values.run || !process.stdin.isTTY;
|
|
|
|
// Fail if an unknown command/argument is passed (and we're not in headless mode where it might be a prompt)
|
|
if (command && !isHeadless) {
|
|
console.error(`Error: Unknown command or argument "${command}"`);
|
|
console.error("Run 'letta --help' for usage information.");
|
|
process.exit(1);
|
|
}
|
|
|
|
// --init-blocks only makes sense when creating a brand new agent
|
|
if (initBlocksRaw && !forceNew) {
|
|
console.error(
|
|
"Error: --init-blocks can only be used together with --new to control initial memory blocks.",
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
const initBlocks = parseCsvListFlag(initBlocksRaw);
|
|
|
|
// --base-tools only makes sense when creating a brand new agent
|
|
if (baseToolsRaw && !forceNew) {
|
|
console.error(
|
|
"Error: --base-tools can only be used together with --new to control initial base tools.",
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
const baseTools = parseCsvListFlag(baseToolsRaw);
|
|
|
|
// Validate toolset if provided
|
|
if (
|
|
specifiedToolset &&
|
|
specifiedToolset !== "codex" &&
|
|
specifiedToolset !== "default" &&
|
|
specifiedToolset !== "gemini" &&
|
|
specifiedToolset !== "auto"
|
|
) {
|
|
console.error(
|
|
`Error: Invalid toolset "${specifiedToolset}". Must be "auto", "codex", "default", or "gemini".`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
// Validate system prompt options (--system and --system-custom are mutually exclusive)
|
|
if (systemPromptPreset && systemCustom) {
|
|
console.error(
|
|
"Error: --system and --system-custom are mutually exclusive. Use one or the other.",
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
// Validate system prompt preset if provided.
|
|
// Known preset IDs are always accepted. Subagent names are only accepted
|
|
// for internal subagent launches (LETTA_CODE_AGENT_ROLE=subagent).
|
|
if (systemPromptPreset) {
|
|
const { validateSystemPromptPreset } = await import("./agent/promptAssets");
|
|
const allowSubagentNames = process.env.LETTA_CODE_AGENT_ROLE === "subagent";
|
|
try {
|
|
await validateSystemPromptPreset(systemPromptPreset, {
|
|
allowSubagentNames,
|
|
});
|
|
} catch (err) {
|
|
console.error(
|
|
`Error: ${err instanceof Error ? err.message : String(err)}`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
// Parse memory blocks JSON if provided
|
|
let memoryBlocks:
|
|
| Array<{ label: string; value: string; description?: string }>
|
|
| undefined;
|
|
if (memoryBlocksJson) {
|
|
try {
|
|
memoryBlocks = parseJsonArrayFlag(
|
|
memoryBlocksJson,
|
|
"memory-blocks",
|
|
) as Array<{ label: string; value: string; description?: string }>;
|
|
// Validate each block has required fields
|
|
for (const block of memoryBlocks) {
|
|
if (
|
|
typeof block.label !== "string" ||
|
|
typeof block.value !== "string"
|
|
) {
|
|
throw new Error(
|
|
"Each memory block must have 'label' and 'value' string fields",
|
|
);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error(
|
|
`Error: ${error instanceof Error ? error.message : String(error)}`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
// Validate shared mutual-exclusion rules for startup flags.
|
|
try {
|
|
validateFlagConflicts({
|
|
guard: specifiedConversationId && specifiedConversationId !== "default",
|
|
checks: [
|
|
{
|
|
when: specifiedAgentId,
|
|
message: "--conversation cannot be used with --agent",
|
|
},
|
|
{
|
|
when: specifiedAgentName,
|
|
message: "--conversation cannot be used with --name",
|
|
},
|
|
{
|
|
when: forceNew,
|
|
message: "--conversation cannot be used with --new-agent",
|
|
},
|
|
{
|
|
when: fromAfFile,
|
|
message: "--conversation cannot be used with --import",
|
|
},
|
|
{
|
|
when: shouldResume,
|
|
message: "--conversation cannot be used with --resume",
|
|
},
|
|
],
|
|
});
|
|
|
|
validateFlagConflicts({
|
|
guard: forceNewConversation,
|
|
checks: [
|
|
{
|
|
when: specifiedConversationId,
|
|
message: "--new cannot be used with --conversation",
|
|
},
|
|
{ when: shouldResume, message: "--new cannot be used with --resume" },
|
|
],
|
|
});
|
|
} catch (error) {
|
|
console.error(
|
|
error instanceof Error ? `Error: ${error.message}` : String(error),
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
// Validate --import flag (also accepts legacy --from-af)
|
|
// Detect if it's a registry handle (e.g., @author/name) or a local file path
|
|
let isRegistryImport = false;
|
|
if (fromAfFile) {
|
|
try {
|
|
validateFlagConflicts({
|
|
guard: fromAfFile,
|
|
checks: [
|
|
{
|
|
when: specifiedAgentId,
|
|
message: "--import cannot be used with --agent",
|
|
},
|
|
{
|
|
when: specifiedAgentName,
|
|
message: "--import cannot be used with --name",
|
|
},
|
|
{
|
|
when: shouldResume,
|
|
message: "--import cannot be used with --resume",
|
|
},
|
|
{
|
|
when: forceNew,
|
|
message: "--import cannot be used with --new-agent",
|
|
},
|
|
],
|
|
});
|
|
} catch (error) {
|
|
console.error(
|
|
error instanceof Error ? `Error: ${error.message}` : String(error),
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
// Check if this looks like a registry handle (@author/name)
|
|
if (fromAfFile.startsWith("@")) {
|
|
// Definitely a registry handle
|
|
isRegistryImport = true;
|
|
// Validate handle format
|
|
try {
|
|
validateRegistryHandleOrThrow(fromAfFile);
|
|
} catch {
|
|
console.error(
|
|
`Error: Invalid registry handle "${fromAfFile}". Use format: letta --import @author/agentname`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
} else {
|
|
// Local file - verify it exists
|
|
const { resolve } = await import("node:path");
|
|
const { existsSync } = await import("node:fs");
|
|
const resolvedPath = resolve(fromAfFile);
|
|
if (!existsSync(resolvedPath)) {
|
|
console.error(`Error: AgentFile not found: ${resolvedPath}`);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Validate --name flag
|
|
let nameResolvedAgent: AgentState | null = null;
|
|
if (specifiedAgentName) {
|
|
if (specifiedAgentId) {
|
|
console.error("Error: --name cannot be used with --agent");
|
|
process.exit(1);
|
|
}
|
|
if (forceNew) {
|
|
console.error("Error: --name cannot be used with --new-agent");
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
// Check if API key is configured
|
|
const apiKey = process.env.LETTA_API_KEY || settings.env?.LETTA_API_KEY;
|
|
const baseURL =
|
|
process.env.LETTA_BASE_URL ||
|
|
settings.env?.LETTA_BASE_URL ||
|
|
LETTA_CLOUD_API_URL;
|
|
|
|
// Check if refresh token is missing for Letta Cloud (only when not using env var)
|
|
// Skip this check if we already have an API key from env
|
|
if (
|
|
!isHeadless &&
|
|
baseURL === LETTA_CLOUD_API_URL &&
|
|
!settings.refreshToken &&
|
|
!apiKey
|
|
) {
|
|
// For interactive mode, show setup flow
|
|
const { runSetup } = await import("./auth/setup");
|
|
await runSetup();
|
|
// After setup, restart main flow
|
|
return main().catch((err: unknown) => {
|
|
// Handle top-level errors gracefully without raw stack traces
|
|
const message =
|
|
err instanceof Error ? err.message : "An unexpected error occurred";
|
|
console.error(`\nError: ${message}`);
|
|
if (isDebugEnabled()) {
|
|
console.error(err);
|
|
}
|
|
process.exit(1);
|
|
});
|
|
}
|
|
|
|
if (!apiKey && baseURL === LETTA_CLOUD_API_URL) {
|
|
// For headless mode, error out (assume automation context)
|
|
if (isHeadless) {
|
|
console.error("Missing LETTA_API_KEY");
|
|
console.error(
|
|
"Run 'letta' in interactive mode to authenticate or export the missing environment variable",
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
// For interactive mode, show setup flow
|
|
console.log("No credentials found. Let's get you set up!\n");
|
|
const { runSetup } = await import("./auth/setup");
|
|
await runSetup();
|
|
// After setup, restart main flow
|
|
return main();
|
|
}
|
|
|
|
// Validate credentials by checking health endpoint
|
|
const { validateCredentials } = await import("./auth/oauth");
|
|
const isValid = await validateCredentials(baseURL, apiKey ?? "");
|
|
markMilestone("CREDENTIALS_VALIDATED");
|
|
|
|
if (!isValid) {
|
|
// For headless mode, error out with helpful message
|
|
if (isHeadless) {
|
|
console.error("Failed to connect to Letta server");
|
|
console.error(`Base URL: ${baseURL}`);
|
|
console.error(
|
|
"Your credentials may be invalid or the server may be unreachable.",
|
|
);
|
|
console.error(
|
|
"Delete ~/.letta/settings.json then run 'letta' to re-authenticate",
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
// For interactive mode, show setup flow
|
|
console.log("Failed to connect to Letta server.");
|
|
console.log(`Base URL: ${baseURL}\n`);
|
|
console.log(
|
|
"Your credentials may be invalid or the server may be unreachable.",
|
|
);
|
|
console.log("Let's reconfigure your setup.\n");
|
|
const { runSetup } = await import("./auth/setup");
|
|
await runSetup();
|
|
// After setup, restart main flow
|
|
return main();
|
|
}
|
|
|
|
// Resolve --name to agent ID if provided
|
|
if (specifiedAgentName) {
|
|
// Load local settings for LRU priority
|
|
await settingsManager.loadLocalProjectSettings();
|
|
|
|
const resolved = await resolveAgentByName(specifiedAgentName);
|
|
if (!resolved) {
|
|
console.error(
|
|
`Error: No pinned agent found with name "${specifiedAgentName}"`,
|
|
);
|
|
console.error("");
|
|
const pinnedAgents = await getPinnedAgentNames();
|
|
if (pinnedAgents.length > 0) {
|
|
console.error("Available pinned agents:");
|
|
for (const agent of pinnedAgents) {
|
|
console.error(` - "${agent.name}" (${agent.id})`);
|
|
}
|
|
} else {
|
|
console.error(
|
|
"No pinned agents available. Use /pin to pin an agent first.",
|
|
);
|
|
}
|
|
process.exit(1);
|
|
}
|
|
specifiedAgentId = resolved.id;
|
|
nameResolvedAgent = resolved.agent;
|
|
}
|
|
|
|
// Set tool filter if provided (controls which tools are loaded)
|
|
if (values.tools !== undefined) {
|
|
const { toolFilter } = await import("./tools/filter");
|
|
toolFilter.setEnabledTools(values.tools);
|
|
}
|
|
|
|
// Set CLI permission overrides if provided
|
|
if (values.allowedTools || values.disallowedTools) {
|
|
const { cliPermissions } = await import("./permissions/cli");
|
|
if (values.allowedTools) {
|
|
cliPermissions.setAllowedTools(values.allowedTools);
|
|
}
|
|
if (values.disallowedTools) {
|
|
cliPermissions.setDisallowedTools(values.disallowedTools);
|
|
}
|
|
}
|
|
|
|
// Set permission mode if provided (or via --yolo alias)
|
|
const permissionModeValue = values["permission-mode"];
|
|
const yoloMode = values.yolo;
|
|
|
|
if (yoloMode || permissionModeValue) {
|
|
if (yoloMode) {
|
|
// --yolo is an alias for --permission-mode bypassPermissions
|
|
permissionMode.setMode("bypassPermissions");
|
|
} else if (permissionModeValue) {
|
|
const mode = permissionModeValue;
|
|
const validModes = [
|
|
"default",
|
|
"acceptEdits",
|
|
"plan",
|
|
"bypassPermissions",
|
|
] as const;
|
|
|
|
if (validModes.includes(mode as (typeof validModes)[number])) {
|
|
permissionMode.setMode(mode as (typeof validModes)[number]);
|
|
} else {
|
|
console.error(
|
|
`Invalid permission mode: ${mode}. Valid modes: ${validModes.join(", ")}`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (isHeadless) {
|
|
markMilestone("HEADLESS_MODE_START");
|
|
// For headless mode, load tools synchronously (respecting model/toolset when provided)
|
|
const modelForTools = getModelForToolLoading(
|
|
specifiedModel,
|
|
specifiedToolset as "auto" | "codex" | "default" | "gemini" | undefined,
|
|
);
|
|
// Exclude interactive-only tools that can't function without a live user session
|
|
await loadTools(modelForTools, { exclude: ["AskUserQuestion"] });
|
|
markMilestone("TOOLS_LOADED");
|
|
|
|
// Keep headless startup in sync with interactive name resolution.
|
|
// If --name resolved to an agent ID, pass that through as --agent.
|
|
const headlessValues =
|
|
specifiedAgentId && values.agent !== specifiedAgentId
|
|
? { ...values, agent: specifiedAgentId }
|
|
: values;
|
|
|
|
const { handleHeadlessCommand } = await import("./headless");
|
|
await handleHeadlessCommand(
|
|
{ values: headlessValues, positionals },
|
|
specifiedModel,
|
|
skillsDirectory,
|
|
resolvedSkillSources,
|
|
!noSystemInfoReminderFlag,
|
|
);
|
|
return;
|
|
}
|
|
|
|
markMilestone("TUI_MODE_START");
|
|
|
|
// Enable enhanced key reporting (Shift+Enter, etc.) BEFORE Ink initializes.
|
|
// In VS Code/xterm.js this typically requires a short handshake (query + enable).
|
|
try {
|
|
const { detectAndEnableKittyProtocol } = await import(
|
|
"./cli/utils/kittyProtocolDetector"
|
|
);
|
|
await detectAndEnableKittyProtocol();
|
|
} catch {
|
|
// Best-effort: if this fails, the app still runs (Option+Enter remains supported).
|
|
}
|
|
|
|
// Interactive: lazy-load React/Ink + App
|
|
markMilestone("REACT_IMPORT_START");
|
|
const React = await import("react");
|
|
const { render } = await import("ink");
|
|
const { useState, useEffect } = React;
|
|
const AppModule = await import("./cli/App");
|
|
const App = AppModule.default;
|
|
|
|
function LoadingApp({
|
|
forceNew,
|
|
initBlocks,
|
|
baseTools,
|
|
agentIdArg,
|
|
preResolvedAgent,
|
|
model,
|
|
systemPromptPreset,
|
|
toolset,
|
|
skillsDirectory,
|
|
fromAfFile,
|
|
isRegistryImport,
|
|
}: {
|
|
forceNew: boolean;
|
|
initBlocks?: string[];
|
|
baseTools?: string[];
|
|
agentIdArg: string | null;
|
|
preResolvedAgent?: AgentState | null;
|
|
model?: string;
|
|
systemPromptPreset?: string;
|
|
toolset?: "auto" | "codex" | "default" | "gemini";
|
|
skillsDirectory?: string;
|
|
fromAfFile?: string;
|
|
isRegistryImport?: boolean;
|
|
}) {
|
|
const [showKeybindingSetup, setShowKeybindingSetup] = useState<
|
|
boolean | null
|
|
>(null);
|
|
const [loadingState, setLoadingState] = useState<
|
|
| "selecting"
|
|
| "selecting_global"
|
|
| "selecting_conversation"
|
|
| "assembling"
|
|
| "importing"
|
|
| "initializing"
|
|
| "checking"
|
|
| "ready"
|
|
>("selecting");
|
|
const [agentId, setAgentId] = useState<string | null>(null);
|
|
const [agentState, setAgentState] = useState<AgentState | null>(null);
|
|
const [conversationId, setConversationId] = useState<string | null>(null);
|
|
const [resumeData, setResumeData] = useState<ResumeData | null>(null);
|
|
const [isResumingSession, setIsResumingSession] = useState(false);
|
|
const [resumedExistingConversation, setResumedExistingConversation] =
|
|
useState(false);
|
|
const [agentProvenance, setAgentProvenance] =
|
|
useState<AgentProvenance | null>(null);
|
|
const [selectedGlobalAgentId, setSelectedGlobalAgentId] = useState<
|
|
string | null
|
|
>(null);
|
|
// Cache agent object from Phase 1 validation to avoid redundant re-fetch in Phase 2
|
|
const [validatedAgent, setValidatedAgent] = useState<AgentState | null>(
|
|
preResolvedAgent ?? null,
|
|
);
|
|
// Track agent and conversation for conversation selector (--resume flag)
|
|
const [resumeAgentId, setResumeAgentId] = useState<string | null>(null);
|
|
const [resumeAgentName, setResumeAgentName] = 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);
|
|
// Message to show when LRU/selected agent failed to load
|
|
const [failedAgentMessage, setFailedAgentMessage] = useState<string | null>(
|
|
null,
|
|
);
|
|
// For self-hosted: available model handles from server and user's selection
|
|
const [availableServerModels, setAvailableServerModels] = useState<
|
|
string[]
|
|
>([]);
|
|
const [selectedServerModel, setSelectedServerModel] = useState<
|
|
string | null
|
|
>(null);
|
|
const [selfHostedDefaultModel, setSelfHostedDefaultModel] = useState<
|
|
string | null
|
|
>(null);
|
|
const [selfHostedBaseUrl, setSelfHostedBaseUrl] = useState<string | null>(
|
|
null,
|
|
);
|
|
|
|
// Release notes to display (checked once on mount)
|
|
const [releaseNotes, setReleaseNotes] = useState<string | null>(null);
|
|
|
|
// Update notification: set when auto-update applied a significant new version
|
|
const [updateNotification, setUpdateNotification] = useState<string | null>(
|
|
null,
|
|
);
|
|
useEffect(() => {
|
|
autoUpdatePromise
|
|
.then((result) => {
|
|
if (result?.latestVersion) {
|
|
setUpdateNotification(result.latestVersion);
|
|
}
|
|
})
|
|
.catch(() => {});
|
|
}, []);
|
|
|
|
// Auto-install Shift+Enter keybinding for VS Code/Cursor/Windsurf (silent, no prompt)
|
|
useEffect(() => {
|
|
async function autoInstallKeybinding() {
|
|
const {
|
|
detectTerminalType,
|
|
getKeybindingsPath,
|
|
keybindingExists,
|
|
installKeybinding,
|
|
} = await import("./cli/utils/terminalKeybindingInstaller");
|
|
const { loadSettings, updateSettings } = await import("./settings");
|
|
|
|
const terminal = detectTerminalType();
|
|
if (!terminal) {
|
|
setShowKeybindingSetup(false);
|
|
return;
|
|
}
|
|
|
|
const settings = await loadSettings();
|
|
const keybindingsPath = getKeybindingsPath(terminal);
|
|
|
|
// Skip if already installed or no valid path
|
|
if (!keybindingsPath || settings.shiftEnterKeybindingInstalled) {
|
|
setShowKeybindingSetup(false);
|
|
return;
|
|
}
|
|
|
|
// Check if keybinding already exists (user might have added it manually)
|
|
if (keybindingExists(keybindingsPath)) {
|
|
await updateSettings({ shiftEnterKeybindingInstalled: true });
|
|
setShowKeybindingSetup(false);
|
|
return;
|
|
}
|
|
|
|
// Silently install keybinding (no prompt, just like Claude Code)
|
|
const result = installKeybinding(keybindingsPath);
|
|
if (result.success) {
|
|
await updateSettings({ shiftEnterKeybindingInstalled: true });
|
|
}
|
|
|
|
setShowKeybindingSetup(false);
|
|
}
|
|
|
|
async function autoInstallWezTermFix() {
|
|
const {
|
|
isWezTerm,
|
|
wezTermDeleteFixExists,
|
|
getWezTermConfigPath,
|
|
installWezTermDeleteFix,
|
|
} = await import("./cli/utils/terminalKeybindingInstaller");
|
|
const { loadSettings, updateSettings } = await import("./settings");
|
|
|
|
if (!isWezTerm()) return;
|
|
|
|
const settings = await loadSettings();
|
|
if (settings.wezTermDeleteFixInstalled) return;
|
|
|
|
const configPath = getWezTermConfigPath();
|
|
if (wezTermDeleteFixExists(configPath)) {
|
|
await updateSettings({ wezTermDeleteFixInstalled: true });
|
|
return;
|
|
}
|
|
|
|
// Silently install the fix
|
|
const result = installWezTermDeleteFix();
|
|
if (result.success) {
|
|
await updateSettings({ wezTermDeleteFixInstalled: true });
|
|
}
|
|
}
|
|
|
|
autoInstallKeybinding();
|
|
autoInstallWezTermFix();
|
|
}, []);
|
|
|
|
// Check for release notes to display (runs once on mount)
|
|
useEffect(() => {
|
|
async function checkNotes() {
|
|
const { checkReleaseNotes } = await import("./release-notes");
|
|
const notes = await checkReleaseNotes();
|
|
setReleaseNotes(notes);
|
|
}
|
|
checkNotes();
|
|
}, []);
|
|
|
|
// Initialize on mount - check if we should show global agent selector
|
|
useEffect(() => {
|
|
async function checkAndStart() {
|
|
// Load settings
|
|
await settingsManager.loadLocalProjectSettings();
|
|
const localSettings = settingsManager.getLocalProjectSettings();
|
|
const client = await getClient();
|
|
|
|
// For self-hosted servers, pre-fetch available models
|
|
// This is needed so ProfileSelectionInline can show model picker
|
|
// if the default model isn't available
|
|
const baseURL =
|
|
process.env.LETTA_BASE_URL ||
|
|
settings.env?.LETTA_BASE_URL ||
|
|
LETTA_CLOUD_API_URL;
|
|
const isSelfHosted = !baseURL.includes("api.letta.com");
|
|
|
|
// Track whether we need model picker (for skipping ensureDefaultAgents)
|
|
let needsModelPicker = false;
|
|
|
|
if (isSelfHosted) {
|
|
setSelfHostedBaseUrl(baseURL);
|
|
try {
|
|
const { getDefaultModel } = await import("./agent/model");
|
|
const defaultModel = getDefaultModel();
|
|
setSelfHostedDefaultModel(defaultModel);
|
|
const modelsList = await client.models.list();
|
|
const handles = modelsList
|
|
.map((m) => m.handle)
|
|
.filter((h): h is string => typeof h === "string");
|
|
|
|
// Only set if default model isn't available
|
|
if (!handles.includes(defaultModel)) {
|
|
setAvailableServerModels(handles);
|
|
needsModelPicker = true;
|
|
}
|
|
} catch {
|
|
// Ignore errors - will fail naturally during agent creation if needed
|
|
}
|
|
}
|
|
|
|
// =====================================================================
|
|
// TOP-LEVEL PATH: --conversation <id>
|
|
// Conversation ID is unique, so we can derive the agent from it
|
|
// (except for "default" which requires --agent flag, validated above)
|
|
// =====================================================================
|
|
if (specifiedConversationId) {
|
|
if (specifiedConversationId === "default") {
|
|
// "default" requires --agent (validated in flag preprocessing above)
|
|
// Use the specified agent directly, skip conversation validation
|
|
// TypeScript can't see the validation above, but specifiedAgentId is guaranteed
|
|
if (!specifiedAgentId) {
|
|
throw new Error("Unreachable: --conv default requires --agent");
|
|
}
|
|
setSelectedGlobalAgentId(specifiedAgentId);
|
|
setSelectedConversationId("default");
|
|
setLoadingState("assembling");
|
|
return;
|
|
}
|
|
|
|
// For explicit conversations, derive agent from conversation
|
|
try {
|
|
debugLog(
|
|
"conversations",
|
|
`retrieve(${specifiedConversationId}) [TUI conv→agent lookup]`,
|
|
);
|
|
const conversation = await client.conversations.retrieve(
|
|
specifiedConversationId,
|
|
);
|
|
// Use the agent that owns this conversation
|
|
setSelectedGlobalAgentId(conversation.agent_id);
|
|
setSelectedConversationId(specifiedConversationId);
|
|
setLoadingState("assembling");
|
|
return;
|
|
} catch (error) {
|
|
if (
|
|
error instanceof APIError &&
|
|
(error.status === 404 || error.status === 422)
|
|
) {
|
|
console.error(
|
|
`Conversation ${specifiedConversationId} not found`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// =====================================================================
|
|
// TOP-LEVEL PATH: --resume
|
|
// Show conversation selector for last-used agent (local → global fallback)
|
|
// =====================================================================
|
|
if (shouldResume) {
|
|
const localSession = settingsManager.getLocalLastSession(
|
|
process.cwd(),
|
|
);
|
|
const localAgentId = localSession?.agentId ?? localSettings.lastAgent;
|
|
|
|
// Try local LRU first
|
|
if (localAgentId) {
|
|
try {
|
|
const agent = await client.agents.retrieve(localAgentId);
|
|
setResumeAgentId(localAgentId);
|
|
setResumeAgentName(agent.name ?? null);
|
|
setLoadingState("selecting_conversation");
|
|
return;
|
|
} catch {
|
|
// Local agent doesn't exist, try global
|
|
setFailedAgentMessage(
|
|
`Unable to locate agent ${localAgentId} in .letta/, checking global (~/.letta)`,
|
|
);
|
|
}
|
|
} else {
|
|
// No recent agent locally, silently fall through to global
|
|
}
|
|
|
|
// Try global LRU
|
|
const globalSession = settingsManager.getGlobalLastSession();
|
|
const globalAgentId = globalSession?.agentId;
|
|
if (globalAgentId) {
|
|
try {
|
|
const agent = await client.agents.retrieve(globalAgentId);
|
|
setResumeAgentId(globalAgentId);
|
|
setResumeAgentName(agent.name ?? null);
|
|
setLoadingState("selecting_conversation");
|
|
return;
|
|
} catch {
|
|
// Global agent also doesn't exist
|
|
}
|
|
}
|
|
|
|
// No valid agent found anywhere
|
|
console.error("No recent session found in .letta/ or ~/.letta.");
|
|
console.error("Run 'letta' to get started.");
|
|
process.exit(1);
|
|
}
|
|
|
|
// =====================================================================
|
|
// DEFAULT PATH: No special flags
|
|
// Check local LRU → global LRU → selector → create default
|
|
// =====================================================================
|
|
|
|
// Short-circuit: flags handled by init() skip resolution entirely
|
|
if (forceNew || agentIdArg || fromAfFile) {
|
|
setLoadingState("assembling");
|
|
return;
|
|
}
|
|
|
|
// Step 1: Check local project LRU (session helpers centralize legacy fallback)
|
|
// Cache the retrieved agent to avoid redundant re-fetch in init()
|
|
const localAgentId = settingsManager.getLocalLastAgentId(process.cwd());
|
|
const globalAgentId = settingsManager.getGlobalLastAgentId();
|
|
|
|
// Fetch local + global LRU agents in parallel
|
|
let localAgentExists = false;
|
|
let globalAgentExists = false;
|
|
let cachedAgent: AgentState | null = null;
|
|
|
|
if (globalAgentId && globalAgentId === localAgentId) {
|
|
// Same agent — only need one fetch
|
|
if (localAgentId) {
|
|
try {
|
|
cachedAgent = await client.agents.retrieve(localAgentId);
|
|
localAgentExists = true;
|
|
} catch {
|
|
setFailedAgentMessage(
|
|
`Unable to locate recently used agent ${localAgentId}`,
|
|
);
|
|
}
|
|
}
|
|
globalAgentExists = localAgentExists;
|
|
} else {
|
|
// Different agents — fetch in parallel
|
|
const [localResult, globalResult] = await Promise.allSettled([
|
|
localAgentId
|
|
? client.agents.retrieve(localAgentId)
|
|
: Promise.reject(new Error("no local")),
|
|
globalAgentId
|
|
? client.agents.retrieve(globalAgentId)
|
|
: Promise.reject(new Error("no global")),
|
|
]);
|
|
|
|
if (localResult.status === "fulfilled") {
|
|
localAgentExists = true;
|
|
cachedAgent = localResult.value;
|
|
} else if (localAgentId) {
|
|
setFailedAgentMessage(
|
|
`Unable to locate recently used agent ${localAgentId}`,
|
|
);
|
|
}
|
|
|
|
if (globalResult.status === "fulfilled") {
|
|
globalAgentExists = true;
|
|
cachedAgent = globalResult.value;
|
|
}
|
|
}
|
|
|
|
// Step 3: Resolve startup target using pure decision logic
|
|
const mergedPinned = settingsManager.getMergedPinnedAgents(
|
|
process.cwd(),
|
|
);
|
|
const { resolveStartupTarget } = await import(
|
|
"./agent/resolve-startup-agent"
|
|
);
|
|
const localSession = settingsManager.getLocalLastSession(process.cwd());
|
|
const target = resolveStartupTarget({
|
|
localAgentId,
|
|
localConversationId: localSession?.conversationId ?? null,
|
|
localAgentExists,
|
|
globalAgentId,
|
|
globalAgentExists,
|
|
mergedPinnedCount: mergedPinned.length,
|
|
forceNew: false, // forceNew short-circuited above
|
|
needsModelPicker,
|
|
});
|
|
|
|
switch (target.action) {
|
|
case "resume":
|
|
setSelectedGlobalAgentId(target.agentId);
|
|
if (cachedAgent && cachedAgent.id === target.agentId) {
|
|
setValidatedAgent(cachedAgent);
|
|
}
|
|
if (target.conversationId && !forceNewConversation) {
|
|
setSelectedConversationId(target.conversationId);
|
|
}
|
|
setLoadingState("assembling");
|
|
return;
|
|
case "select":
|
|
setLoadingState("selecting_global");
|
|
return;
|
|
case "create": {
|
|
const { ensureDefaultAgents } = await import("./agent/defaults");
|
|
try {
|
|
const defaultAgent = await ensureDefaultAgents(client, {
|
|
preferredModel: model,
|
|
});
|
|
if (defaultAgent) {
|
|
setSelectedGlobalAgentId(defaultAgent.id);
|
|
setLoadingState("assembling");
|
|
return;
|
|
}
|
|
// If null (createDefaultAgents disabled), fall through
|
|
} catch (err) {
|
|
console.error(
|
|
`Failed to create default agent: ${err instanceof Error ? err.message : String(err)}`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
setLoadingState("assembling");
|
|
}
|
|
checkAndStart();
|
|
}, [
|
|
forceNew,
|
|
agentIdArg,
|
|
fromAfFile,
|
|
shouldResume,
|
|
specifiedConversationId,
|
|
]);
|
|
|
|
// Main initialization effect - runs after profile selection
|
|
useEffect(() => {
|
|
if (loadingState !== "assembling") return;
|
|
|
|
async function init() {
|
|
const client = await getClient();
|
|
|
|
// Determine which agent we'll be using (before loading tools)
|
|
let resumingAgentId: string | null = null;
|
|
|
|
// Priority 1: --agent flag
|
|
if (agentIdArg) {
|
|
// Use cached agent from name resolution if available
|
|
if (validatedAgent && validatedAgent.id === agentIdArg) {
|
|
resumingAgentId = agentIdArg;
|
|
} else {
|
|
try {
|
|
const agent = await client.agents.retrieve(agentIdArg);
|
|
setValidatedAgent(agent);
|
|
resumingAgentId = agentIdArg;
|
|
} catch {
|
|
// Agent doesn't exist, will create new later
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
if (!resumingAgentId && !shouldCreateNew && selectedGlobalAgentId) {
|
|
// Use cached agent from Phase 1 validation if available
|
|
if (validatedAgent && validatedAgent.id === selectedGlobalAgentId) {
|
|
resumingAgentId = selectedGlobalAgentId;
|
|
} else {
|
|
try {
|
|
const agent = await client.agents.retrieve(selectedGlobalAgentId);
|
|
setValidatedAgent(agent);
|
|
resumingAgentId = selectedGlobalAgentId;
|
|
} catch {
|
|
// Selected agent doesn't exist - show selector again
|
|
setLoadingState("selecting_global");
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Priority 3: LRU from local settings (if not --new or user explicitly requested new from selector)
|
|
if (!resumingAgentId && !shouldCreateNew) {
|
|
const localProjectSettings =
|
|
settingsManager.getLocalProjectSettings();
|
|
if (localProjectSettings?.lastAgent) {
|
|
try {
|
|
await client.agents.retrieve(localProjectSettings.lastAgent);
|
|
resumingAgentId = localProjectSettings.lastAgent;
|
|
} catch {
|
|
// LRU agent doesn't exist (wrong org, deleted, etc.)
|
|
// Show selector instead of silently creating a new agent
|
|
setLoadingState("selecting_global");
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Set resuming state early so loading messages are accurate
|
|
setIsResumingSession(!!resumingAgentId);
|
|
|
|
// Load an initial toolset for startup (explicit --toolset or model-derived).
|
|
// App.tsx will reconcile persisted per-agent toolset preference after agent metadata loads.
|
|
const modelForTools = getModelForToolLoading(
|
|
model,
|
|
toolset as "auto" | "codex" | "default" | "gemini" | undefined,
|
|
);
|
|
await loadTools(modelForTools);
|
|
|
|
setLoadingState("initializing");
|
|
const { createAgent } = await import("./agent/create");
|
|
|
|
let agent: AgentState | null = null;
|
|
|
|
// Priority 1: Import from AgentFile template (local file or registry)
|
|
if (fromAfFile) {
|
|
setLoadingState("importing");
|
|
let result: { agent: AgentState; skills?: string[] };
|
|
|
|
if (isRegistryImport) {
|
|
// Import from letta-ai/agent-file registry
|
|
const { importAgentFromRegistry } = await import("./agent/import");
|
|
result = await importAgentFromRegistry({
|
|
handle: fromAfFile,
|
|
modelOverride: model,
|
|
stripMessages: true,
|
|
stripSkills: false,
|
|
});
|
|
} else {
|
|
// Import from local file
|
|
const { importAgentFromFile } = await import("./agent/import");
|
|
result = await importAgentFromFile({
|
|
filePath: fromAfFile,
|
|
modelOverride: model,
|
|
stripMessages: true,
|
|
stripSkills: false,
|
|
});
|
|
}
|
|
|
|
agent = result.agent;
|
|
setAgentProvenance({
|
|
isNew: true,
|
|
blocks: [],
|
|
});
|
|
|
|
// Mark imported agents as "custom" to prevent legacy auto-migration
|
|
// from overwriting their system prompt on resume.
|
|
if (settingsManager.isReady) {
|
|
settingsManager.setSystemPromptPreset(agent.id, "custom");
|
|
}
|
|
|
|
// Display extracted skills summary
|
|
if (result.skills && result.skills.length > 0) {
|
|
const { getAgentSkillsDir } = await import("./agent/skills");
|
|
const skillsDir = getAgentSkillsDir(agent.id);
|
|
console.log(
|
|
`\n📦 Extracted ${result.skills.length} skill${result.skills.length === 1 ? "" : "s"} to ${skillsDir}: ${result.skills.join(", ")}\n`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Priority 2: Try to use --agent specified ID
|
|
if (!agent && agentIdArg) {
|
|
try {
|
|
agent = await client.agents.retrieve(agentIdArg);
|
|
} catch (error) {
|
|
console.error(
|
|
`Agent ${agentIdArg} not found (error: ${JSON.stringify(error)})`,
|
|
);
|
|
console.error(
|
|
"When using --agent, the specified agent ID must exist.",
|
|
);
|
|
console.error("Run 'letta' without --agent to create a new agent.");
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
// Priority 3: Check if --new flag was passed or user requested new from selector
|
|
if (!agent && shouldCreateNew) {
|
|
// For self-hosted: if default model unavailable and no model selected yet, show picker
|
|
if (availableServerModels.length > 0 && !selectedServerModel) {
|
|
setLoadingState("selecting_global");
|
|
return;
|
|
}
|
|
|
|
// Determine effective model:
|
|
// 1. Use selectedServerModel if user picked from self-hosted picker
|
|
// 2. Use model if --model flag was passed
|
|
// 3. Otherwise, use billing-tier-aware default (free tier gets GLM-5)
|
|
let effectiveModel = selectedServerModel || model;
|
|
if (!effectiveModel && !selfHostedBaseUrl) {
|
|
// On Letta API without explicit model - check billing tier for appropriate default
|
|
const { getDefaultModelForTier } = await import("./agent/model");
|
|
let billingTier: string | null = null;
|
|
try {
|
|
const baseURL =
|
|
process.env.LETTA_BASE_URL ||
|
|
settings.env?.LETTA_BASE_URL ||
|
|
LETTA_CLOUD_API_URL;
|
|
const apiKey =
|
|
process.env.LETTA_API_KEY || settings.env?.LETTA_API_KEY;
|
|
const response = await fetch(`${baseURL}/v1/metadata/balance`, {
|
|
headers: getLettaCodeHeaders(apiKey),
|
|
});
|
|
if (response.ok) {
|
|
const data = (await response.json()) as {
|
|
billing_tier?: string;
|
|
};
|
|
billingTier = data.billing_tier ?? null;
|
|
}
|
|
} catch {
|
|
// Ignore - will use standard default
|
|
}
|
|
effectiveModel = getDefaultModelForTier(billingTier);
|
|
}
|
|
|
|
// Pre-determine memfs mode so the agent is created with the correct prompt.
|
|
const { isLettaCloud } = await import("./agent/memoryFilesystem");
|
|
const willAutoEnableMemfs =
|
|
shouldAutoEnableMemfsForNewAgent && (await isLettaCloud());
|
|
const effectiveMemoryMode =
|
|
requestedMemoryPromptMode ??
|
|
(willAutoEnableMemfs ? "memfs" : undefined);
|
|
|
|
const updateArgs = getModelUpdateArgs(effectiveModel);
|
|
const result = await createAgent({
|
|
model: effectiveModel,
|
|
updateArgs,
|
|
skillsDirectory,
|
|
parallelToolCalls: true,
|
|
systemPromptPreset,
|
|
memoryPromptMode: effectiveMemoryMode,
|
|
initBlocks,
|
|
baseTools,
|
|
});
|
|
agent = result.agent;
|
|
setAgentProvenance(result.provenance);
|
|
|
|
// Enable memfs on Letta Cloud (tags, repo clone, tool detach).
|
|
if (willAutoEnableMemfs) {
|
|
const { enableMemfsIfCloud } = await import(
|
|
"./agent/memoryFilesystem"
|
|
);
|
|
await enableMemfsIfCloud(agent.id);
|
|
}
|
|
}
|
|
|
|
// Priority 4: Try to resume from project settings LRU (.letta/settings.local.json)
|
|
// Note: If LRU retrieval failed in early validation, we already showed selector and returned
|
|
// Use cached agent from Phase 1 validation when available to avoid redundant API call
|
|
if (!agent && resumingAgentId) {
|
|
try {
|
|
agent =
|
|
validatedAgent && validatedAgent.id === resumingAgentId
|
|
? validatedAgent
|
|
: await client.agents.retrieve(resumingAgentId);
|
|
} catch (error) {
|
|
// Agent disappeared between validation and now - show selector
|
|
console.error(
|
|
`Agent ${resumingAgentId} not found (error: ${JSON.stringify(error)})`,
|
|
);
|
|
setLoadingState("selecting_global");
|
|
return;
|
|
}
|
|
}
|
|
|
|
// All paths should have resolved to an agent by now
|
|
// If not, it's an unexpected state - error out instead of auto-creating
|
|
if (!agent) {
|
|
console.error(
|
|
"No agent found. Use --new-agent to create a new agent.",
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
// Ensure local project settings are loaded before updating
|
|
// (they may not have been loaded if we didn't try to resume from project settings)
|
|
try {
|
|
settingsManager.getLocalProjectSettings();
|
|
} catch {
|
|
await settingsManager.loadLocalProjectSettings();
|
|
}
|
|
|
|
// Save agent ID to both project and global settings
|
|
settingsManager.updateLocalProjectSettings({ lastAgent: agent.id });
|
|
settingsManager.updateSettings({ lastAgent: agent.id });
|
|
|
|
// Set agent context for tools that need it (e.g., Skill tool)
|
|
setAgentContext(agent.id, skillsDirectory, resolvedSkillSources);
|
|
|
|
// Start memfs sync early — awaited in parallel with getResumeData below
|
|
const isSubagent = process.env.LETTA_CODE_AGENT_ROLE === "subagent";
|
|
const agentId = agent.id;
|
|
const agentTags = agent.tags ?? undefined;
|
|
const memfsSyncPromise = import("./agent/memoryFilesystem").then(
|
|
({ applyMemfsFlags }) =>
|
|
applyMemfsFlags(agentId, memfsFlag, noMemfsFlag, {
|
|
agentTags,
|
|
}),
|
|
);
|
|
|
|
// Check if we're resuming an existing agent
|
|
// We're resuming if:
|
|
// 1. We specified an agent ID via --agent flag (agentIdArg)
|
|
// 2. We're reusing a project agent (detected early as resumingAgentId)
|
|
// 3. We retrieved an agent from LRU (detected by checking if agent already existed)
|
|
const isResumingProject = !shouldCreateNew && !!resumingAgentId;
|
|
const isReusingExistingAgent =
|
|
!shouldCreateNew && !fromAfFile && agent && agent.id;
|
|
const resuming = !!(
|
|
agentIdArg ||
|
|
isResumingProject ||
|
|
isReusingExistingAgent
|
|
);
|
|
setIsResumingSession(resuming);
|
|
|
|
// If resuming, always refresh model settings from presets to keep
|
|
// preset-derived fields in sync, then apply optional command-line
|
|
// overrides (model/system prompt).
|
|
if (resuming) {
|
|
if (model) {
|
|
const modelHandle = resolveModel(model);
|
|
if (!modelHandle) {
|
|
console.error(`Error: Invalid model "${model}"`);
|
|
process.exit(1);
|
|
}
|
|
|
|
// Always apply model update - different model IDs can share the same
|
|
// handle but have different settings (e.g., gpt-5.2-medium vs gpt-5.2-xhigh)
|
|
const updateArgs = getModelUpdateArgs(model);
|
|
agent = await updateAgentLLMConfig(
|
|
agent.id,
|
|
modelHandle,
|
|
updateArgs,
|
|
);
|
|
} else {
|
|
const presetRefresh = getModelPresetUpdateForAgent(agent);
|
|
if (presetRefresh) {
|
|
const { updateArgs: resumeRefreshUpdateArgs, needsUpdate } =
|
|
getResumeRefreshArgs(presetRefresh.updateArgs, agent);
|
|
|
|
if (needsUpdate) {
|
|
agent = await updateAgentLLMConfig(
|
|
agent.id,
|
|
presetRefresh.modelHandle,
|
|
resumeRefreshUpdateArgs,
|
|
{ preserveContextWindow: true },
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (systemPromptPreset) {
|
|
// Await memfs sync first so isMemfsEnabled() reflects the final state
|
|
// before updateAgentSystemPrompt reads it to pick the memory addon.
|
|
try {
|
|
await memfsSyncPromise;
|
|
} catch (error) {
|
|
console.error(
|
|
error instanceof Error ? error.message : String(error),
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
const result = await updateAgentSystemPrompt(
|
|
agent.id,
|
|
systemPromptPreset,
|
|
);
|
|
if (!result.success || !result.agent) {
|
|
console.error(`Error: ${result.message}`);
|
|
process.exit(1);
|
|
}
|
|
agent = result.agent;
|
|
}
|
|
}
|
|
|
|
const startupAgentId = agent.id;
|
|
void clearPersistedClientToolRules(startupAgentId)
|
|
.then((cleanup) => {
|
|
if (cleanup) {
|
|
const count = cleanup.removedToolNames.length;
|
|
const names = cleanup.removedToolNames.join(", ");
|
|
debugLog(
|
|
"startup",
|
|
`Cleared ${count} persisted client tool rule${count === 1 ? "" : "s"} for ${startupAgentId}${count > 0 ? `: ${names}` : ""}`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
debugLog(
|
|
"startup",
|
|
`No persisted client tool rules to clear for ${startupAgentId}`,
|
|
);
|
|
})
|
|
.catch((error) => {
|
|
debugWarn(
|
|
"startup",
|
|
`Failed to clear persisted client tool rules for ${startupAgentId}: ${error instanceof Error ? error.message : String(error)}`,
|
|
);
|
|
});
|
|
|
|
// Handle conversation: either resume existing or create new
|
|
// Using definite assignment assertion - all branches below either set this or exit/throw
|
|
let conversationIdToUse!: string;
|
|
|
|
// Debug: log resume flag status
|
|
if (isDebugEnabled()) {
|
|
debugLog("startup", "shouldResume=%o", shouldResume);
|
|
debugLog(
|
|
"startup",
|
|
"specifiedConversationId=%s",
|
|
specifiedConversationId,
|
|
);
|
|
}
|
|
|
|
if (specifiedConversationId) {
|
|
// Use the explicitly specified conversation ID
|
|
// User explicitly requested this conversation, so error if it doesn't exist
|
|
conversationIdToUse = specifiedConversationId;
|
|
setResumedExistingConversation(true);
|
|
try {
|
|
// Load message history and pending approvals from the conversation
|
|
setLoadingState("checking");
|
|
const data = await getResumeData(
|
|
client,
|
|
agent,
|
|
specifiedConversationId,
|
|
);
|
|
setResumeData(data);
|
|
} catch (error) {
|
|
// Only treat 404/422 as "not found", rethrow other errors
|
|
if (
|
|
error instanceof APIError &&
|
|
(error.status === 404 || error.status === 422)
|
|
) {
|
|
console.error(
|
|
`Conversation ${specifiedConversationId} not found`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
throw error;
|
|
}
|
|
} else if (selectedConversationId) {
|
|
// Conversation selected from --resume selector or auto-restored from local project settings
|
|
try {
|
|
setLoadingState("checking");
|
|
const data = await getResumeData(
|
|
client,
|
|
agent,
|
|
selectedConversationId,
|
|
);
|
|
conversationIdToUse = selectedConversationId;
|
|
setResumedExistingConversation(true);
|
|
setResumeData(data);
|
|
} catch (error) {
|
|
if (
|
|
error instanceof APIError &&
|
|
(error.status === 404 || error.status === 422)
|
|
) {
|
|
// Conversation no longer exists — fall back to default conversation
|
|
console.warn(
|
|
`Previous conversation ${selectedConversationId} not found, falling back to default`,
|
|
);
|
|
conversationIdToUse = "default";
|
|
setLoadingState("checking");
|
|
const data = await getResumeData(client, agent, "default");
|
|
setResumeData(data);
|
|
setResumedExistingConversation(data.messageHistory.length > 0);
|
|
} else {
|
|
throw error;
|
|
}
|
|
}
|
|
} else if (forceNewConversation) {
|
|
// --new flag: create a new conversation (for concurrent sessions)
|
|
const conversation = await client.conversations.create({
|
|
agent_id: agent.id,
|
|
isolated_block_labels: [...ISOLATED_BLOCK_LABELS],
|
|
});
|
|
conversationIdToUse = conversation.id;
|
|
} else {
|
|
// Default (including --new-agent): use the agent's "default" conversation
|
|
conversationIdToUse = "default";
|
|
|
|
// Load message history and memfs sync in parallel — they're independent
|
|
setLoadingState("checking");
|
|
const [data] = await Promise.all([
|
|
getResumeData(client, agent, "default"),
|
|
memfsSyncPromise.catch((error) => {
|
|
console.error(
|
|
error instanceof Error ? error.message : String(error),
|
|
);
|
|
process.exit(1);
|
|
}),
|
|
]);
|
|
setResumeData(data);
|
|
setResumedExistingConversation(data.messageHistory.length > 0);
|
|
}
|
|
|
|
// Ensure memfs sync completed (already resolved for default path via Promise.all above)
|
|
try {
|
|
await memfsSyncPromise;
|
|
} catch (error) {
|
|
console.error(error instanceof Error ? error.message : String(error));
|
|
process.exit(1);
|
|
}
|
|
|
|
// Auto-heal system prompt drift (rebuild from stored recipe).
|
|
// Runs after memfs sync so isMemfsEnabled() reflects the final state.
|
|
if (resuming && !systemPromptPreset) {
|
|
let storedPreset = settingsManager.getSystemPromptPreset(agent.id);
|
|
|
|
// Adopt legacy agents (created before recipe tracking) as "custom"
|
|
// so their prompts are left untouched by auto-heal.
|
|
if (
|
|
!storedPreset &&
|
|
(agent.tags?.includes("origin:letta-code") || agent.tags?.includes("origin:lettabot")) &&
|
|
!agent.tags?.includes("role:subagent")
|
|
) {
|
|
storedPreset = "custom";
|
|
settingsManager.setSystemPromptPreset(agent.id, storedPreset);
|
|
}
|
|
|
|
if (storedPreset && storedPreset !== "custom") {
|
|
const { buildSystemPrompt: rebuildPrompt, isKnownPreset: isKnown } =
|
|
await import("./agent/promptAssets");
|
|
if (isKnown(storedPreset)) {
|
|
const memoryMode = settingsManager.isMemfsEnabled(agent.id)
|
|
? "memfs"
|
|
: "standard";
|
|
const expected = rebuildPrompt(storedPreset, memoryMode);
|
|
if (agent.system !== expected) {
|
|
const client = await getClient();
|
|
await client.agents.update(agent.id, { system: expected });
|
|
agent = await client.agents.retrieve(agent.id);
|
|
}
|
|
} else {
|
|
settingsManager.clearSystemPromptPreset(agent.id);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Save the session (agent + conversation) to settings
|
|
// Skip for subagents - they shouldn't pollute the LRU settings
|
|
if (!isSubagent) {
|
|
settingsManager.setLocalLastSession(
|
|
{ agentId: agent.id, conversationId: conversationIdToUse },
|
|
process.cwd(),
|
|
);
|
|
settingsManager.setGlobalLastSession({
|
|
agentId: agent.id,
|
|
conversationId: conversationIdToUse,
|
|
});
|
|
}
|
|
|
|
setAgentId(agent.id);
|
|
setAgentState(agent);
|
|
setConversationId(conversationIdToUse);
|
|
// Also set in global context for tools (e.g., Skill tool) to access
|
|
setContextConversationId(conversationIdToUse);
|
|
setLoadingState("ready");
|
|
}
|
|
|
|
init().catch((err) => {
|
|
// Handle errors gracefully without showing raw stack traces
|
|
const message = formatErrorDetails(err);
|
|
console.error(`\nError during initialization: ${message}`);
|
|
if (isDebugEnabled()) {
|
|
console.error(err);
|
|
}
|
|
process.exit(1);
|
|
});
|
|
}, [
|
|
forceNew,
|
|
userRequestedNewAgent,
|
|
agentIdArg,
|
|
model,
|
|
systemPromptPreset,
|
|
fromAfFile,
|
|
loadingState,
|
|
selectedGlobalAgentId,
|
|
validatedAgent,
|
|
resumeAgentId,
|
|
selectedConversationId,
|
|
]);
|
|
|
|
// Wait for keybinding auto-install to complete before showing UI
|
|
if (showKeybindingSetup === null) {
|
|
return null;
|
|
}
|
|
|
|
// During initial "selecting" phase, render ProfileSelectionInline with loading state
|
|
// to prevent component tree switch whitespace artifacts
|
|
if (loadingState === "selecting") {
|
|
return React.createElement(ProfileSelectionInline, {
|
|
lruAgentId: null,
|
|
loading: true, // Show loading state while checking
|
|
freshRepoMode: true,
|
|
onSelect: () => {},
|
|
onCreateNew: () => {},
|
|
onExit: () => process.exit(0),
|
|
});
|
|
}
|
|
|
|
// Show conversation selector for --resume flag
|
|
if (loadingState === "selecting_conversation" && resumeAgentId) {
|
|
return React.createElement(ConversationSelector, {
|
|
agentId: resumeAgentId,
|
|
agentName: resumeAgentName ?? undefined,
|
|
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, {
|
|
lruAgentId: null, // No LRU in fresh repo
|
|
loading: false,
|
|
freshRepoMode: true, // Hides "(global)" labels and simplifies context message
|
|
failedAgentMessage: failedAgentMessage ?? undefined,
|
|
// For self-hosted: pass available models so user can pick one when creating new agent
|
|
serverModelsForNewAgent:
|
|
availableServerModels.length > 0 ? availableServerModels : undefined,
|
|
defaultModelHandle: selfHostedDefaultModel ?? undefined,
|
|
serverBaseUrl: selfHostedBaseUrl ?? undefined,
|
|
onSelect: (agentId: string) => {
|
|
setSelectedGlobalAgentId(agentId);
|
|
setLoadingState("assembling");
|
|
},
|
|
onCreateNew: () => {
|
|
setUserRequestedNewAgent(true);
|
|
setLoadingState("assembling");
|
|
},
|
|
onCreateNewWithModel: (modelHandle: string) => {
|
|
setUserRequestedNewAgent(true);
|
|
setSelectedServerModel(modelHandle);
|
|
setLoadingState("assembling");
|
|
},
|
|
onExit: () => {
|
|
process.exit(0);
|
|
},
|
|
});
|
|
}
|
|
|
|
// 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: appLoadingState,
|
|
continueSession: isResumingSession,
|
|
startupApproval: resumeData?.pendingApproval ?? null,
|
|
startupApprovals: resumeData?.pendingApprovals ?? EMPTY_APPROVAL_ARRAY,
|
|
messageHistory: resumeData?.messageHistory ?? EMPTY_MESSAGE_ARRAY,
|
|
resumedExistingConversation,
|
|
tokenStreaming: settings.tokenStreaming,
|
|
reasoningTabCycleEnabled: settings.reasoningTabCycleEnabled === true,
|
|
showCompactions: settings.showCompactions,
|
|
agentProvenance,
|
|
releaseNotes,
|
|
sessionContextReminderEnabled: !noSystemInfoReminderFlag,
|
|
});
|
|
}
|
|
|
|
return React.createElement(App, {
|
|
agentId,
|
|
agentState,
|
|
conversationId,
|
|
loadingState: appLoadingState,
|
|
continueSession: isResumingSession,
|
|
startupApproval: resumeData?.pendingApproval ?? null,
|
|
startupApprovals: resumeData?.pendingApprovals ?? EMPTY_APPROVAL_ARRAY,
|
|
messageHistory: resumeData?.messageHistory ?? EMPTY_MESSAGE_ARRAY,
|
|
resumedExistingConversation,
|
|
tokenStreaming: settings.tokenStreaming,
|
|
reasoningTabCycleEnabled: settings.reasoningTabCycleEnabled === true,
|
|
showCompactions: settings.showCompactions,
|
|
agentProvenance,
|
|
releaseNotes,
|
|
updateNotification,
|
|
sessionContextReminderEnabled: !noSystemInfoReminderFlag,
|
|
});
|
|
}
|
|
|
|
markMilestone("REACT_RENDER_START");
|
|
render(
|
|
React.createElement(LoadingApp, {
|
|
forceNew: forceNew,
|
|
initBlocks: initBlocks,
|
|
baseTools: baseTools,
|
|
agentIdArg: specifiedAgentId,
|
|
preResolvedAgent: nameResolvedAgent,
|
|
model: specifiedModel,
|
|
systemPromptPreset: systemPromptPreset,
|
|
toolset: specifiedToolset as
|
|
| "auto"
|
|
| "codex"
|
|
| "default"
|
|
| "gemini"
|
|
| undefined,
|
|
skillsDirectory: skillsDirectory,
|
|
fromAfFile: fromAfFile,
|
|
isRegistryImport: isRegistryImport,
|
|
}),
|
|
{
|
|
exitOnCtrlC: false, // We handle CTRL-C manually with double-press guard
|
|
},
|
|
);
|
|
}
|
|
|
|
main();
|