diff --git a/bun.lock b/bun.lock index cfc9374..b512245 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/websocket/listen-client.ts b/src/websocket/listen-client.ts index 2ac276a..e1c7c42 100644 --- a/src/websocket/listen-client.ts +++ b/src/websocket/listen-client.ts @@ -3,7 +3,7 @@ * Connects to Letta Cloud and receives messages to execute locally */ -import { realpath, stat } from "node:fs/promises"; +import { readdir, realpath, stat } from "node:fs/promises"; import path from "node:path"; import { APIError } from "@letta-ai/letta-client/core/error"; import type { Stream } from "@letta-ai/letta-client/core/streaming"; @@ -190,6 +190,24 @@ interface ChangeCwdMessage { cwd: string; } +interface ListFoldersInDirectoryMessage { + type: "list_folders_in_directory"; + path: string; + agentId?: string | null; + conversationId?: string | null; +} + +interface ListFoldersInDirectoryResponseMessage { + type: "list_folders_in_directory_response"; + path: string; + folders: string[]; + hasMore: boolean; + success: boolean; + error?: string; + event_seq?: number; + session_id?: string; +} + interface CancelRunMessage { type: "cancel_run"; request_id?: string; @@ -277,6 +295,7 @@ type ServerMessage = | GetStatusMessage | GetStateMessage | ChangeCwdMessage + | ListFoldersInDirectoryMessage | CancelRunMessage | RecoverPendingApprovalsMessage | WsControlResponse; @@ -286,6 +305,7 @@ type ClientMessage = | RunRequestErrorMessage | ModeChangedMessage | CwdChangedMessage + | ListFoldersInDirectoryResponseMessage | StatusResponseMessage | StateResponseMessage; @@ -488,6 +508,65 @@ async function handleCwdChange( } } +const MAX_LIST_FOLDERS = 100; + +async function handleListFoldersInDirectory( + msg: ListFoldersInDirectoryMessage, + socket: WebSocket, + runtime: ListenerRuntime, +): Promise { + try { + const requestedPath = msg.path?.trim(); + if (!requestedPath) { + throw new Error("Path cannot be empty"); + } + + const resolvedPath = path.isAbsolute(requestedPath) + ? requestedPath + : path.resolve(process.cwd(), requestedPath); + const normalizedPath = await realpath(resolvedPath); + const stats = await stat(normalizedPath); + if (!stats.isDirectory()) { + throw new Error(`Not a directory: ${normalizedPath}`); + } + + const entries = await readdir(normalizedPath, { withFileTypes: true }); + const allFolders = entries + .filter((e) => e.isDirectory() && !e.name.startsWith(".")) + .map((e) => e.name) + .sort(); + + const folders = allFolders.slice(0, MAX_LIST_FOLDERS); + const hasMore = allFolders.length > MAX_LIST_FOLDERS; + + sendClientMessage( + socket, + { + type: "list_folders_in_directory_response", + path: normalizedPath, + folders, + hasMore, + success: true, + }, + runtime, + ); + } catch (error) { + sendClientMessage( + socket, + { + type: "list_folders_in_directory_response", + path: msg.path, + folders: [], + hasMore: false, + success: false, + error: + error instanceof Error ? error.message : "Failed to list folders", + }, + runtime, + ); + } +} + const MAX_RETRY_DURATION_MS = 5 * 60 * 1000; // 5 minutes const INITIAL_RETRY_DELAY_MS = 1000; // 1 second const MAX_RETRY_DELAY_MS = 30000; // 30 seconds @@ -830,6 +909,7 @@ export function parseServerMessage( parsed.type === "get_status" || parsed.type === "get_state" || parsed.type === "change_cwd" || + parsed.type === "list_folders_in_directory" || parsed.type === "cancel_run" || parsed.type === "recover_pending_approvals" ) { @@ -2562,6 +2642,15 @@ async function connectWithRetry( return; } + if (parsed.type === "list_folders_in_directory") { + if (runtime !== activeRuntime || runtime.intentionallyClosed) { + return; + } + + void handleListFoldersInDirectory(parsed, socket, runtime); + return; + } + // Handle status request from cloud (immediate response) if (parsed.type === "get_status") { if (runtime !== activeRuntime || runtime.intentionallyClosed) {