diff --git a/bun.lock b/bun.lock index 4271bc5..283a66d 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "@letta-ai/letta-code", diff --git a/src/cli/App.tsx b/src/cli/App.tsx index 38663dc..bbafdeb 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -6451,23 +6451,17 @@ export default function App({ ); let name: string | undefined; - let listenAgentId: string | undefined; + let _listenAgentId: string | undefined; for (let i = 1; i < parts.length; i++) { const part = parts[i]; const nextPart = parts[i + 1]; - if (part === "--name" && nextPart) { + if (part === "--env-name" && nextPart) { name = nextPart; i++; - } else if (part === "--agent" && nextPart) { - listenAgentId = nextPart; - i++; } } - // Default to current agent if not specified - const targetAgentId = listenAgentId || agentId; - const cmd = commandRunner.start(msg, "Starting listener..."); const { handleListen, setActiveCommandId: setActiveListenCommandId } = await import("./commands/listen"); @@ -6478,9 +6472,11 @@ export default function App({ buffersRef, refreshDerived, setCommandRunning, + agentId, + conversationId: conversationIdRef.current, }, msg, - { name, agentId: targetAgentId }, + { envName: name }, ); } finally { setActiveListenCommandId(null); diff --git a/src/cli/commands/listen.ts b/src/cli/commands/listen.ts index ed758be..8a4e3cd 100644 --- a/src/cli/commands/listen.ts +++ b/src/cli/commands/listen.ts @@ -28,6 +28,8 @@ export interface ListenCommandContext { buffersRef: { current: Buffers }; refreshDerived: () => void; setCommandRunning: (running: boolean) => void; + agentId: string | null; + conversationId: string | null; } // Helper to add a command result to buffers @@ -85,33 +87,64 @@ function updateCommandResult( } interface ListenOptions { - name?: string; - agentId?: string; + envName?: string; } /** * Handle /listen command - * Usage: /listen --name "george" [--agent agent-xyz] + * Usage: /listen [--env-name "work-laptop"] + * /listen off */ export async function handleListen( ctx: ListenCommandContext, msg: string, opts: ListenOptions = {}, ): Promise { + // Handle /listen off - stop the listener + if (msg.trim() === "/listen off") { + const { stopListenerClient, isListenerActive } = await import( + "../../websocket/listen-client" + ); + + if (!isListenerActive()) { + addCommandResult( + ctx.buffersRef, + ctx.refreshDerived, + msg, + "Listen mode is not active.", + false, + ); + return; + } + + stopListenerClient(); + addCommandResult( + ctx.buffersRef, + ctx.refreshDerived, + msg, + "✓ Listen mode stopped\n\nListener disconnected from Letta Cloud.", + true, + ); + return; + } + // Show usage if needed if (msg.includes("--help") || msg.includes("-h")) { addCommandResult( ctx.buffersRef, ctx.refreshDerived, msg, - "Usage: /listen --name [--agent ]\n\n" + + "Usage: /listen [--env-name ]\n" + + " /listen off\n\n" + "Register this letta-code instance to receive messages from Letta Cloud.\n\n" + "Options:\n" + - " --name Friendly name for this connection (required)\n" + - " --agent Bind connection to specific agent (defaults to current agent)\n\n" + + " --env-name Friendly name for this environment (uses hostname if not provided)\n" + + " off Stop the active listener connection\n" + + " -h, --help Show this help message\n\n" + "Examples:\n" + - ' /listen --name "george" # Uses current agent\n' + - ' /listen --name "laptop-work" --agent agent-abc123\n\n' + + " /listen # Start listener with hostname\n" + + ' /listen --env-name "work-laptop" # Start with custom name\n' + + " /listen off # Stop listening\n\n" + "Once connected, this instance will listen for incoming messages from cloud agents.\n" + "Messages will be executed locally using your letta-code environment.", true, @@ -119,37 +152,37 @@ export async function handleListen( return; } - // Validate required parameters - const connectionName = opts.name; - const agentId = opts.agentId; + // Determine connection name + let connectionName: string; - if (!connectionName) { - addCommandResult( - ctx.buffersRef, - ctx.refreshDerived, - msg, - "Error: --name is required\n\n" + - 'Usage: /listen --name "george"\n\n' + - "Provide a friendly name to identify this connection (e.g., your name, device name).", - false, - ); - return; + if (opts.envName) { + // Explicitly provided - use it and save to local project settings + connectionName = opts.envName; + settingsManager.setListenerEnvName(connectionName); + } else { + // Not provided - check saved local project settings + const savedName = settingsManager.getListenerEnvName(); + + if (savedName) { + // Reuse saved name + connectionName = savedName; + } else { + // No saved name - use hostname and save it + connectionName = hostname(); + settingsManager.setListenerEnvName(connectionName); + } } - if (!agentId) { - addCommandResult( - ctx.buffersRef, - ctx.refreshDerived, - msg, - "Error: No agent specified\n\n" + - "This connection needs a default agent to execute messages.\n" + - "If you're seeing this, it means no agent is active in this conversation.\n\n" + - "Please start a conversation with an agent first, or specify one explicitly:\n" + - ' /listen --name "george" --agent agent-abc123', - false, - ); - return; - } + // Helper to build ADE connection URL + const buildConnectionUrl = (connId: string): string => { + if (!ctx.agentId) return ""; + + let url = `https://app.letta.com/agents/${ctx.agentId}?deviceId=${connId}`; + if (ctx.conversationId) { + url += `&conversationId=${ctx.conversationId}`; + } + return url; + }; // Start listen flow ctx.setCommandRunning(true); @@ -200,7 +233,6 @@ export async function handleListen( body: JSON.stringify({ deviceId, connectionName, - agentId: opts.agentId, }), }); @@ -214,10 +246,6 @@ export async function handleListen( wsUrl: string; }; - // Build agent info message - const adeUrl = `https://app.letta.com/agents/${agentId}`; - const agentInfo = `Agent: ${agentId}\n→ ${adeUrl}\n\n`; - updateCommandResult( ctx.buffersRef, ctx.refreshDerived, @@ -225,8 +253,7 @@ export async function handleListen( msg, `✓ Registered successfully!\n\n` + `Connection ID: ${connectionId}\n` + - `Name: "${connectionName}"\n` + - agentInfo + + `Environment: "${connectionName}"\n` + `WebSocket: ${wsUrl}\n\n` + `Starting WebSocket connection...`, true, @@ -243,9 +270,7 @@ export async function handleListen( wsUrl, deviceId, connectionName, - agentId, onStatusChange: (status, connId) => { - const adeUrl = `https://app.letta.com/agents/${agentId}?deviceId=${connId}`; const statusText = status === "receiving" ? "Receiving message" @@ -253,43 +278,45 @@ export async function handleListen( ? "Processing message" : "Awaiting instructions"; - updateCommandResult( - ctx.buffersRef, - ctx.refreshDerived, - cmdId, - msg, - `Connected to Letta Cloud\n` + - `${statusText}\n\n` + - `View in ADE → ${adeUrl}`, - true, - "finished", - ); - }, - onRetrying: (attempt, _maxAttempts, nextRetryIn) => { - const adeUrl = `https://app.letta.com/agents/${agentId}?deviceId=${connectionId}`; - updateCommandResult( - ctx.buffersRef, - ctx.refreshDerived, - cmdId, - msg, - `Reconnecting to Letta Cloud...\n` + - `Attempt ${attempt}, retrying in ${Math.round(nextRetryIn / 1000)}s\n\n` + - `View in ADE → ${adeUrl}`, - true, - "running", - ); - }, - onConnected: () => { - const adeUrl = `https://app.letta.com/agents/${agentId}?deviceId=${connectionId}`; + const url = buildConnectionUrl(connId); + const urlText = url ? `\n\nConnect to this environment:\n${url}` : ""; updateCommandResult( ctx.buffersRef, ctx.refreshDerived, cmdId, msg, - `Connected to Letta Cloud\n` + - `Awaiting instructions\n\n` + - `View in ADE → ${adeUrl}`, + `Environment initialized: ${connectionName}\n${statusText}${urlText}`, + true, + "finished", + ); + }, + onRetrying: (attempt, _maxAttempts, nextRetryIn, connId) => { + const url = buildConnectionUrl(connId); + const urlText = url ? `\n\nConnect to this environment:\n${url}` : ""; + + updateCommandResult( + ctx.buffersRef, + ctx.refreshDerived, + cmdId, + msg, + `Environment initialized: ${connectionName}\n` + + `Reconnecting to Letta Cloud...\n` + + `Attempt ${attempt}, retrying in ${Math.round(nextRetryIn / 1000)}s${urlText}`, + true, + "running", + ); + }, + onConnected: (connId) => { + const url = buildConnectionUrl(connId); + const urlText = url ? `\n\nConnect to this environment:\n${url}` : ""; + + updateCommandResult( + ctx.buffersRef, + ctx.refreshDerived, + cmdId, + msg, + `Environment initialized: ${connectionName}\nAwaiting instructions${urlText}`, true, "finished", ); diff --git a/src/cli/commands/registry.ts b/src/cli/commands/registry.ts index 92633b1..9f055f5 100644 --- a/src/cli/commands/registry.ts +++ b/src/cli/commands/registry.ts @@ -118,6 +118,15 @@ export const commands: Record = { return "Opening provider connection..."; }, }, + // "/listen": { + // desc: "Connect to Letta Cloud (device connect mode)", + // args: "[--env-name ]", + // order: 17.5, + // handler: () => { + // // Handled specially in App.tsx + // return "Starting listener..."; + // }, + // }, "/clear": { desc: "Clear in-context messages", order: 18, diff --git a/src/cli/components/ListenerStatusUI.tsx b/src/cli/components/ListenerStatusUI.tsx index c97ae24..df88c87 100644 --- a/src/cli/components/ListenerStatusUI.tsx +++ b/src/cli/components/ListenerStatusUI.tsx @@ -3,9 +3,8 @@ import Spinner from "ink-spinner"; import { useEffect, useState } from "react"; interface ListenerStatusUIProps { - agentId: string; connectionId: string; - conversationId?: string; + envName: string; onReady: (callbacks: { updateStatus: (status: "idle" | "receiving" | "processing") => void; updateRetryStatus: (attempt: number, nextRetryIn: number) => void; @@ -14,7 +13,7 @@ interface ListenerStatusUIProps { } export function ListenerStatusUI(props: ListenerStatusUIProps) { - const { agentId, connectionId, conversationId, onReady } = props; + const { connectionId, envName, onReady } = props; const [status, setStatus] = useState<"idle" | "receiving" | "processing">( "idle", ); @@ -35,8 +34,6 @@ export function ListenerStatusUI(props: ListenerStatusUIProps) { }); }, [onReady]); - const adeUrl = `https://app.letta.com/agents/${agentId}?deviceId=${connectionId}${conversationId ? `&conversationId=${conversationId}` : ""}`; - const statusText = retryInfo ? `Reconnecting (attempt ${retryInfo.attempt}, retry in ${Math.round(retryInfo.nextRetryIn / 1000)}s)` : status === "receiving" @@ -51,7 +48,7 @@ export function ListenerStatusUI(props: ListenerStatusUIProps) { - Connected to Letta Cloud + The name of your environment is: {envName} @@ -68,9 +65,10 @@ export function ListenerStatusUI(props: ListenerStatusUIProps) { - View in ADE → - - {adeUrl} + + Connect to this environment by visiting any agent and clicking the + "cloud" button at the bottom left of the messenger input and swapping + your environment to {envName} diff --git a/src/cli/subcommands/listen.tsx b/src/cli/subcommands/listen.tsx index b882de4..fb10954 100644 --- a/src/cli/subcommands/listen.tsx +++ b/src/cli/subcommands/listen.tsx @@ -1,27 +1,47 @@ /** - * CLI subcommand: letta listen --name "george" + * CLI subcommand: letta listen --name \"george\" * Register letta-code as a listener to receive messages from Letta Cloud */ +import { hostname } from "node:os"; import { parseArgs } from "node:util"; -import { render } from "ink"; +import { Box, render, Text } from "ink"; +import TextInput from "ink-text-input"; +import type React from "react"; +import { useState } from "react"; import { getServerUrl } from "../../agent/client"; import { settingsManager } from "../../settings-manager"; import { ListenerStatusUI } from "../components/ListenerStatusUI"; -export async function runListenSubcommand(argv: string[]): Promise { - // Preprocess args to support --conv as alias for --conversation - const processedArgv = argv.map((arg) => - arg === "--conv" ? "--conversation" : arg, - ); +/** + * Interactive prompt for environment name + */ +function PromptEnvName(props: { + onSubmit: (envName: string) => void; +}): React.ReactElement { + const [value, setValue] = useState(""); + return ( + + Enter environment name (or press Enter for hostname): + { + const finalName = input.trim() || hostname(); + props.onSubmit(finalName); + }} + /> + + ); +} + +export async function runListenSubcommand(argv: string[]): Promise { // Parse arguments const { values } = parseArgs({ - args: processedArgv, + args: argv, options: { - name: { type: "string" }, - agent: { type: "string" }, - conversation: { type: "string", short: "C" }, + envName: { type: "string" }, help: { type: "boolean", short: "h" }, }, allowPositionals: false, @@ -29,30 +49,20 @@ export async function runListenSubcommand(argv: string[]): Promise { // Show help if (values.help) { - console.log( - "Usage: letta listen --name [--agent ] [--conversation ]\n", - ); + console.log("Usage: letta listen [--env-name ]\n"); console.log( "Register this letta-code instance to receive messages from Letta Cloud.\n", ); console.log("Options:"); console.log( - " --name Friendly name for this connection (required)", - ); - console.log( - " --agent Bind connection to specific agent (required for CLI usage)", - ); - console.log(" --conversation , --conv , -C "); - console.log( - " Route messages to a specific conversation", + " --env-name Friendly name for this environment (uses hostname if not provided)", ); console.log(" -h, --help Show this help message\n"); console.log("Examples:"); - console.log(' letta listen --name "george" --agent agent-abc123'); - console.log(' letta listen --name "laptop-work" --agent agent-xyz789'); console.log( - ' letta listen --name "daily-cron" --agent agent-abc123 --conv conv-xyz789\n', + " letta listen # Uses hostname as default", ); + console.log(' letta listen --env-name "work-laptop"\n'); console.log( "Once connected, this instance will listen for incoming messages from cloud agents.", ); @@ -62,29 +72,39 @@ export async function runListenSubcommand(argv: string[]): Promise { return 0; } - const connectionName = values.name; - const agentId = values.agent; - const conversationId = values.conversation as string | undefined; + // Load local project settings to access saved environment name + await settingsManager.loadLocalProjectSettings(); - if (!connectionName) { - console.error("Error: --name is required\n"); - console.error('Usage: letta listen --name "george" --agent agent-abc123\n'); - console.error( - "Provide a friendly name to identify this connection (e.g., your name, device name).", - ); - return 1; - } + // Determine connection name + let connectionName: string; - if (!agentId) { - console.error("Error: --agent is required\n"); - console.error('Usage: letta listen --name "george" --agent agent-abc123\n'); - console.error( - "A listener connection needs a default agent to execute messages.", - ); - console.error( - "Specify which agent should receive messages from this connection.", - ); - return 1; + if (values.envName) { + // Explicitly provided - use it and save to local project settings + connectionName = values.envName; + settingsManager.setListenerEnvName(connectionName); + } else { + // Not provided - check saved local project settings + const savedName = settingsManager.getListenerEnvName(); + + if (savedName) { + // Reuse saved name + connectionName = savedName; + } else { + // No saved name - prompt user + connectionName = await new Promise((resolve) => { + const { unmount } = render( + { + unmount(); + resolve(name); + }} + />, + ); + }); + + // Save to local project settings for future runs + settingsManager.setListenerEnvName(connectionName); + } } try { @@ -115,8 +135,6 @@ export async function runListenSubcommand(argv: string[]): Promise { body: JSON.stringify({ deviceId, connectionName, - agentId, - ...(conversationId && { conversationId }), }), }); @@ -144,9 +162,8 @@ export async function runListenSubcommand(argv: string[]): Promise { const { unmount } = render( { updateStatusCallback = callbacks.updateStatus; updateRetryStatusCallback = callbacks.updateRetryStatus; @@ -165,8 +182,6 @@ export async function runListenSubcommand(argv: string[]): Promise { wsUrl, deviceId, connectionName, - agentId, - defaultConversationId: conversationId, onStatusChange: (status) => { clearRetryStatusCallback?.(); updateStatusCallback?.(status); diff --git a/src/settings-manager.ts b/src/settings-manager.ts index e02c5c6..d0e946f 100644 --- a/src/settings-manager.ts +++ b/src/settings-manager.ts @@ -120,6 +120,7 @@ export interface LocalProjectSettings { // Server-indexed settings (agent IDs are server-specific) sessionsByServer?: Record; // key = normalized base URL pinnedAgentsByServer?: Record; // key = normalized base URL + listenerEnvName?: string; // Saved environment name for listener connections (project-specific) } const DEFAULT_SETTINGS: Settings = { @@ -1349,6 +1350,54 @@ class SettingsManager { console.warn("unpinProfile is deprecated, use unpinLocal(agentId) instead"); } + // ===================================================================== + // Listener Environment Name Helpers + // ===================================================================== + + /** + * Get saved listener environment name from local project settings (if any). + * Returns undefined if not set or settings not loaded. + */ + getListenerEnvName( + workingDirectory: string = process.cwd(), + ): string | undefined { + try { + const localSettings = this.getLocalProjectSettings(workingDirectory); + return localSettings.listenerEnvName; + } catch { + // Settings not loaded yet + return undefined; + } + } + + /** + * Save listener environment name to local project settings. + * Loads settings if not already loaded. + */ + setListenerEnvName( + envName: string, + workingDirectory: string = process.cwd(), + ): void { + try { + this.updateLocalProjectSettings( + { listenerEnvName: envName }, + workingDirectory, + ); + } catch { + // Settings not loaded yet - load and retry + this.loadLocalProjectSettings(workingDirectory) + .then(() => { + this.updateLocalProjectSettings( + { listenerEnvName: envName }, + workingDirectory, + ); + }) + .catch((error) => { + console.error("Failed to save listener environment name:", error); + }); + } + } + // ===================================================================== // Agent Settings (unified agents array) Helpers // ===================================================================== diff --git a/src/websocket/listen-client.ts b/src/websocket/listen-client.ts index a7ca785..c5f7326 100644 --- a/src/websocket/listen-client.ts +++ b/src/websocket/listen-client.ts @@ -33,9 +33,7 @@ interface StartListenerOptions { wsUrl: string; deviceId: string; connectionName: string; - agentId?: string; - defaultConversationId?: string; - onConnected: () => void; + onConnected: (connectionId: string) => void; onDisconnected: () => void; onError: (error: Error) => void; onStatusChange?: ( @@ -46,6 +44,7 @@ interface StartListenerOptions { attempt: number, maxAttempts: number, nextRetryIn: number, + connectionId: string, ) => void; } @@ -353,7 +352,7 @@ async function connectWithRetry( Math.log2(MAX_RETRY_DURATION_MS / INITIAL_RETRY_DELAY_MS), ); - opts.onRetrying?.(attempt, maxAttempts, delay); + opts.onRetrying?.(attempt, maxAttempts, delay, opts.connectionId); await new Promise((resolve) => { runtime.reconnectTimeout = setTimeout(resolve, delay); @@ -381,9 +380,6 @@ async function connectWithRetry( const url = new URL(opts.wsUrl); url.searchParams.set("deviceId", opts.deviceId); url.searchParams.set("connectionName", opts.connectionName); - if (opts.agentId) { - url.searchParams.set("agentId", opts.agentId); - } const socket = new WebSocket(url.toString(), { headers: { @@ -399,7 +395,7 @@ async function connectWithRetry( } runtime.hasSuccessfulConnection = true; - opts.onConnected(); + opts.onConnected(opts.connectionId); // Send current mode state to cloud for UI sync sendClientMessage(socket, { @@ -439,7 +435,6 @@ async function connectWithRetry( socket, opts.onStatusChange, opts.connectionId, - opts.defaultConversationId, ); opts.onStatusChange?.("idle", opts.connectionId); }) @@ -504,7 +499,6 @@ async function handleIncomingMessage( connectionId: string, ) => void, connectionId?: string, - defaultConversationId?: string, ): Promise { try { const agentId = msg.agentId; @@ -515,8 +509,7 @@ async function handleIncomingMessage( const requestedConversationId = msg.conversationId || undefined; // For sendMessageStream: "default" means use agent endpoint, else use conversations endpoint - const conversationId = - requestedConversationId ?? defaultConversationId ?? "default"; + const conversationId = requestedConversationId ?? "default"; if (!agentId) { return; @@ -746,6 +739,13 @@ async function handleIncomingMessage( } } +/** + * Check if listener is currently active. + */ +export function isListenerActive(): boolean { + return activeRuntime !== null && activeRuntime.socket !== null; +} + /** * Stop the active listener connection. */