From c5b30ed8787080202e484299c644e3fcaf1f787b Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 7 Nov 2025 20:36:23 +0100 Subject: [PATCH] Add read-only file explorer --- packages/app/src/app/_layout.tsx | 1 + packages/app/src/app/agent/[id].tsx | 13 +- packages/app/src/app/file-explorer.tsx | 360 ++++++++++++++++++ packages/app/src/contexts/session-context.tsx | 155 +++++++- .../src/server/file-explorer/service.ts | 209 ++++++++++ packages/server/src/server/messages.ts | 46 +++ packages/server/src/server/session.ts | 94 +++++ 7 files changed, 876 insertions(+), 2 deletions(-) create mode 100644 packages/app/src/app/file-explorer.tsx create mode 100644 packages/server/src/server/file-explorer/service.ts diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index dd8139171..061f7ab49 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -70,6 +70,7 @@ export default function RootLayout() { + diff --git a/packages/app/src/app/agent/[id].tsx b/packages/app/src/app/agent/[id].tsx index e87b859a9..59fcdc53b 100644 --- a/packages/app/src/app/agent/[id].tsx +++ b/packages/app/src/app/agent/[id].tsx @@ -13,7 +13,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller"; import ReanimatedAnimated, { useAnimatedStyle, useSharedValue } from "react-native-reanimated"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { MoreVertical, GitBranch } from "lucide-react-native"; +import { MoreVertical, GitBranch, Folder } from "lucide-react-native"; import { BackHeader } from "@/components/headers/back-header"; import { AgentStreamView } from "@/components/agent-stream-view"; import { AgentInputArea } from "@/components/agent-input-area"; @@ -185,6 +185,13 @@ export default function AgentScreen() { } }, [id, router, handleCloseMenu]); + const handleBrowseFiles = useCallback(() => { + handleCloseMenu(); + if (id) { + router.push(`/file-explorer?agentId=${id}`); + } + }, [handleCloseMenu, id, router]); + if (!agent) { return ( @@ -257,6 +264,10 @@ export default function AgentScreen() { View Changes + + + Browse Files + diff --git a/packages/app/src/app/file-explorer.tsx b/packages/app/src/app/file-explorer.tsx new file mode 100644 index 000000000..0a79aa5d7 --- /dev/null +++ b/packages/app/src/app/file-explorer.tsx @@ -0,0 +1,360 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + ActivityIndicator, + Image, + Pressable, + ScrollView, + Text, + View, +} from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { useLocalSearchParams } from "expo-router"; +import { BackHeader } from "@/components/headers/back-header"; +import { useSession, type ExplorerEntry } from "@/contexts/session-context"; + +export default function FileExplorerScreen() { + const { agentId } = useLocalSearchParams<{ agentId: string }>(); + const { + agents, + fileExplorer, + requestDirectoryListing, + requestFilePreview, + } = useSession(); + const [currentPath, setCurrentPath] = useState("."); + const [selectedEntryPath, setSelectedEntryPath] = useState(null); + + const agent = agentId ? agents.get(agentId) : undefined; + const explorerState = agentId ? fileExplorer.get(agentId) : undefined; + const directory = explorerState?.directories.get(currentPath); + const entries = directory?.entries ?? []; + const isLoading = explorerState?.isLoading ?? false; + const error = explorerState?.lastError ?? null; + const preview = selectedEntryPath + ? explorerState?.files.get(selectedEntryPath) + : null; + + useEffect(() => { + if (!agentId) { + return; + } + setCurrentPath("."); + setSelectedEntryPath(null); + requestDirectoryListing(agentId, "."); + }, [agentId, requestDirectoryListing]); + + const parentPath = useMemo(() => { + if (currentPath === ".") { + return null; + } + const segments = currentPath.split("/"); + segments.pop(); + const nextPath = segments.join("/"); + return nextPath.length === 0 ? "." : nextPath; + }, [currentPath]); + + const handleEntryPress = useCallback( + (entry: ExplorerEntry) => { + if (!agentId) { + return; + } + + if (entry.kind === "directory") { + setCurrentPath(entry.path); + setSelectedEntryPath(null); + requestDirectoryListing(agentId, entry.path); + return; + } + + setSelectedEntryPath(entry.path); + requestFilePreview(agentId, entry.path); + }, + [agentId, requestDirectoryListing, requestFilePreview] + ); + + const handleNavigateUp = useCallback(() => { + if (!agentId || !parentPath) { + return; + } + setCurrentPath(parentPath); + setSelectedEntryPath(null); + requestDirectoryListing(agentId, parentPath); + }, [agentId, parentPath, requestDirectoryListing]); + + if (!agent) { + return ( + + + + Agent not found + + + ); + } + + return ( + + + + + Path + + {currentPath} + + + {parentPath && ( + + Up + + )} + + + + + {error ? ( + + {error} + + ) : isLoading && entries.length === 0 ? ( + + + Loading... + + ) : entries.length === 0 ? ( + + Directory is empty + + ) : ( + + {entries.map((entry) => ( + handleEntryPress(entry)} + > + + {entry.name} + + {entry.kind.toUpperCase()} ·{" "} + {formatFileSize({ size: entry.size })} ·{" "} + {formatModifiedTime({ value: entry.modifiedAt })} + + + + {entry.kind === "directory" ? "Open" : "Preview"} + + + ))} + + )} + + + + {selectedEntryPath && isLoading && !preview ? ( + + + Loading file... + + ) : !selectedEntryPath ? ( + + Select a file to preview + + ) : !preview ? ( + + No preview available yet + + ) : preview.kind === "text" ? ( + + + {preview.content} + + + ) : preview.kind === "image" && preview.content ? ( + + + + ) : ( + + Binary preview unavailable + + {formatFileSize({ size: preview.size })} + + + )} + + + + ); +} + +function formatFileSize({ size }: { size: number }): string { + if (size < 1024) { + return `${size} B`; + } + if (size < 1024 * 1024) { + return `${(size / 1024).toFixed(1)} KB`; + } + return `${(size / (1024 * 1024)).toFixed(1)} MB`; +} + +function formatModifiedTime({ value }: { value: string }): string { + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return value; + } + return date.toLocaleString(); +} + +const styles = StyleSheet.create((theme) => ({ + container: { + flex: 1, + backgroundColor: theme.colors.background, + }, + headerRow: { + flexDirection: "row", + alignItems: "center", + paddingHorizontal: theme.spacing[4], + paddingVertical: theme.spacing[2], + gap: theme.spacing[3], + }, + breadcrumbs: { + flex: 1, + }, + breadcrumbLabel: { + color: theme.colors.mutedForeground, + fontSize: theme.fontSize.xs, + marginBottom: theme.spacing[1], + }, + breadcrumbText: { + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + fontFamily: "monospace", + }, + upButton: { + paddingHorizontal: theme.spacing[4], + paddingVertical: theme.spacing[2], + borderRadius: theme.borderRadius.lg, + backgroundColor: theme.colors.muted, + }, + upButtonText: { + color: theme.colors.foreground, + fontWeight: theme.fontWeight.semibold, + }, + content: { + flex: 1, + flexDirection: "column", + paddingHorizontal: theme.spacing[4], + paddingBottom: theme.spacing[4], + gap: theme.spacing[4], + }, + listSection: { + flex: 1, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.border, + borderRadius: theme.borderRadius.lg, + padding: theme.spacing[2], + }, + previewSection: { + flex: 1, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.border, + borderRadius: theme.borderRadius.lg, + padding: theme.spacing[2], + }, + centerState: { + flex: 1, + alignItems: "center", + justifyContent: "center", + gap: theme.spacing[2], + padding: theme.spacing[4], + }, + loadingText: { + color: theme.colors.mutedForeground, + fontSize: theme.fontSize.sm, + }, + errorText: { + color: theme.colors.destructive, + fontSize: theme.fontSize.base, + textAlign: "center", + }, + emptyText: { + color: theme.colors.mutedForeground, + fontSize: theme.fontSize.base, + textAlign: "center", + }, + entryRow: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + paddingVertical: theme.spacing[3], + paddingHorizontal: theme.spacing[3], + borderRadius: theme.borderRadius.md, + marginBottom: theme.spacing[2], + }, + directoryRow: { + backgroundColor: theme.colors.muted, + }, + fileRow: { + backgroundColor: theme.colors.card, + }, + selectedRow: { + borderWidth: theme.borderWidth[2], + borderColor: theme.colors.primary, + }, + entryTextContainer: { + flex: 1, + marginRight: theme.spacing[3], + }, + entryName: { + color: theme.colors.foreground, + fontSize: theme.fontSize.base, + fontWeight: theme.fontWeight.semibold, + }, + entryMeta: { + color: theme.colors.mutedForeground, + fontSize: theme.fontSize.xs, + marginTop: theme.spacing[1], + }, + entryAction: { + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.semibold, + }, + textPreview: { + flex: 1, + }, + textPreviewContent: { + padding: theme.spacing[2], + }, + codeText: { + color: theme.colors.foreground, + fontFamily: "monospace", + fontSize: theme.fontSize.sm, + }, + imagePreviewContainer: { + flex: 1, + alignItems: "center", + justifyContent: "center", + }, + image: { + width: "100%", + height: "100%", + }, +})); diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 59aa0bd97..b842fddcc 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -81,6 +81,54 @@ export interface Command { exitCode: number | null; } +export type ExplorerEntryKind = "file" | "directory"; +export type ExplorerFileKind = "text" | "image" | "binary"; +export type ExplorerEncoding = "utf-8" | "base64" | "none"; + +export interface ExplorerEntry { + name: string; + path: string; + kind: ExplorerEntryKind; + size: number; + modifiedAt: string; +} + +export interface ExplorerFile { + path: string; + kind: ExplorerFileKind; + encoding: ExplorerEncoding; + content?: string; + mimeType?: string; + size: number; + modifiedAt: string; +} + +interface ExplorerDirectory { + path: string; + entries: ExplorerEntry[]; +} + +interface ExplorerRequestState { + path: string; + mode: "list" | "file"; +} + +export interface AgentFileExplorerState { + directories: Map; + files: Map; + isLoading: boolean; + lastError: string | null; + pendingRequest: ExplorerRequestState | null; +} + +const createExplorerState = (): AgentFileExplorerState => ({ + directories: new Map(), + files: new Map(), + isLoading: false, + lastError: null, + pendingRequest: null, +}); + interface SessionContextValue { // WebSocket @@ -119,6 +167,11 @@ interface SessionContextValue { gitDiffs: Map; requestGitDiff: (agentId: string) => void; + // File explorer + fileExplorer: Map; + requestDirectoryListing: (agentId: string, path: string) => void; + requestFilePreview: (agentId: string, path: string) => void; + // Helpers initializeAgent: (params: { agentId: string; requestId?: string }) => void; sendAgentMessage: (agentId: string, message: string, imageUris?: string[]) => Promise; @@ -172,6 +225,19 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) { const [agentUpdates, setAgentUpdates] = useState>(new Map()); const [pendingPermissions, setPendingPermissions] = useState>(new Map()); const [gitDiffs, setGitDiffs] = useState>(new Map()); + const [fileExplorer, setFileExplorer] = useState>(new Map()); + + const updateExplorerState = useCallback( + (agentId: string, updater: (state: AgentFileExplorerState) => AgentFileExplorerState) => { + setFileExplorer((prev) => { + const next = new Map(prev); + const current = next.get(agentId) ?? createExplorerState(); + next.set(agentId, updater(current)); + return next; + }); + }, + [] + ); // WebSocket message handlers useEffect(() => { @@ -613,6 +679,47 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) { } }); + const unsubFileExplorer = ws.on("file_explorer_response", (message) => { + if (message.type !== "file_explorer_response") { + return; + } + const { agentId, directory, file, mode, error } = message.payload; + + console.log( + "[Session] File explorer response for agent:", + agentId, + mode, + error ? `(error: ${error})` : "" + ); + + updateExplorerState(agentId, (state) => { + const nextState: AgentFileExplorerState = { + ...state, + isLoading: false, + lastError: error ?? null, + pendingRequest: null, + directories: state.directories, + files: state.files, + }; + + if (!error) { + if (mode === "list" && directory) { + const directories = new Map(state.directories); + directories.set(directory.path, directory); + nextState.directories = directories; + } + + if (mode === "file" && file) { + const files = new Map(state.files); + files.set(file.path, file); + nextState.files = files; + } + } + + return nextState; + }); + }); + return () => { unsubSessionState(); unsubAgentCreated(); @@ -626,8 +733,9 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) { unsubChunk(); unsubTranscription(); unsubGitDiff(); + unsubFileExplorer(); }; - }, [ws, audioPlayer, setIsPlayingAudio]); + }, [ws, audioPlayer, setIsPlayingAudio, updateExplorerState]); const initializeAgent = useCallback(({ agentId, requestId }: { agentId: string; requestId?: string }) => { setInitializingAgents((prev) => { @@ -808,6 +916,48 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) { ws.send(msg); }, [ws]); + const requestDirectoryListing = useCallback((agentId: string, path: string) => { + const normalizedPath = path && path.length > 0 ? path : "."; + updateExplorerState(agentId, (state) => ({ + ...state, + isLoading: true, + lastError: null, + pendingRequest: { path: normalizedPath, mode: "list" }, + })); + + const msg: WSInboundMessage = { + type: "session", + message: { + type: "file_explorer_request", + agentId, + path: normalizedPath, + mode: "list", + }, + }; + ws.send(msg); + }, [updateExplorerState, ws]); + + const requestFilePreview = useCallback((agentId: string, path: string) => { + const normalizedPath = path && path.length > 0 ? path : "."; + updateExplorerState(agentId, (state) => ({ + ...state, + isLoading: true, + lastError: null, + pendingRequest: { path: normalizedPath, mode: "file" }, + })); + + const msg: WSInboundMessage = { + type: "session", + message: { + type: "file_explorer_request", + agentId, + path: normalizedPath, + mode: "file", + }, + }; + ws.send(msg); + }, [updateExplorerState, ws]); + const value: SessionContextValue = { ws, audioPlayer, @@ -831,6 +981,9 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) { setPendingPermissions, gitDiffs, requestGitDiff, + fileExplorer, + requestDirectoryListing, + requestFilePreview, initializeAgent, sendAgentMessage, sendAgentAudio, diff --git a/packages/server/src/server/file-explorer/service.ts b/packages/server/src/server/file-explorer/service.ts new file mode 100644 index 000000000..2314e3ee7 --- /dev/null +++ b/packages/server/src/server/file-explorer/service.ts @@ -0,0 +1,209 @@ +import { promises as fs } from "fs"; +import path from "path"; + +export type ExplorerEntryKind = "file" | "directory"; +export type ExplorerFileKind = "text" | "image" | "binary"; +export type ExplorerEncoding = "utf-8" | "base64" | "none"; + +export interface ListDirectoryParams { + root: string; + relativePath?: string; +} + +export interface ReadFileParams { + root: string; + relativePath: string; +} + +export interface FileExplorerEntry { + name: string; + path: string; + kind: ExplorerEntryKind; + size: number; + modifiedAt: string; +} + +export interface FileExplorerDirectory { + path: string; + entries: FileExplorerEntry[]; +} + +export interface FileExplorerFile { + path: string; + kind: ExplorerFileKind; + encoding: ExplorerEncoding; + content?: string; + mimeType?: string; + size: number; + modifiedAt: string; +} + +const TEXT_EXTENSIONS = new Set([ + ".ts", + ".tsx", + ".js", + ".jsx", + ".json", + ".md", + ".txt", + ".yml", + ".yaml", + ".css", + ".scss", + ".html", + ".mjs", + ".cjs", + ".sh", +]); + +const IMAGE_MIME_TYPES: Record = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + ".svg": "image/svg+xml", +}; + +interface ScopedPathParams { + root: string; + relativePath?: string; +} + +interface EntryPayloadParams { + root: string; + targetPath: string; + name: string; + kind: ExplorerEntryKind; +} + +export async function listDirectoryEntries({ + root, + relativePath = ".", +}: ListDirectoryParams): Promise { + const directoryPath = await resolveScopedPath({ root, relativePath }); + const stats = await fs.stat(directoryPath); + + if (!stats.isDirectory()) { + throw new Error("Requested path is not a directory"); + } + + const dirents = await fs.readdir(directoryPath, { withFileTypes: true }); + + const entries = await Promise.all( + dirents.map(async (dirent) => { + const targetPath = path.join(directoryPath, dirent.name); + const kind: ExplorerEntryKind = dirent.isDirectory() + ? "directory" + : "file"; + return buildEntryPayload({ root, targetPath, name: dirent.name, kind }); + }) + ); + + entries.sort((a, b) => { + if (a.kind !== b.kind) { + return a.kind === "directory" ? -1 : 1; + } + return a.name.localeCompare(b.name); + }); + + return { + path: normalizeRelativePath({ root, targetPath: directoryPath }), + entries, + }; +} + +export async function readExplorerFile({ + root, + relativePath, +}: ReadFileParams): Promise { + const filePath = await resolveScopedPath({ root, relativePath }); + const stats = await fs.stat(filePath); + + if (!stats.isFile()) { + throw new Error("Requested path is not a file"); + } + + const ext = path.extname(filePath).toLowerCase(); + const basePayload = { + path: normalizeRelativePath({ root, targetPath: filePath }), + size: stats.size, + modifiedAt: stats.mtime.toISOString(), + }; + + if (TEXT_EXTENSIONS.has(ext)) { + const content = await fs.readFile(filePath, "utf-8"); + return { + ...basePayload, + kind: "text", + encoding: "utf-8", + content, + mimeType: "text/plain", + }; + } + + if (ext in IMAGE_MIME_TYPES) { + const buffer = await fs.readFile(filePath); + return { + ...basePayload, + kind: "image", + encoding: "base64", + content: buffer.toString("base64"), + mimeType: IMAGE_MIME_TYPES[ext], + }; + } + + return { + ...basePayload, + kind: "binary", + encoding: "none", + mimeType: "application/octet-stream", + }; +} + +async function resolveScopedPath({ + root, + relativePath = ".", +}: ScopedPathParams): Promise { + const normalizedRoot = path.resolve(root); + const requestedPath = path.resolve(normalizedRoot, relativePath); + const relative = path.relative(normalizedRoot, requestedPath); + + if ( + relative === "" || + (!relative.startsWith("..") && !path.isAbsolute(relative)) + ) { + return requestedPath; + } + + throw new Error("Access outside of agent workspace is not allowed"); +} + +async function buildEntryPayload({ + root, + targetPath, + name, + kind, +}: EntryPayloadParams): Promise { + const stats = await fs.stat(targetPath); + return { + name, + path: normalizeRelativePath({ root, targetPath }), + kind, + size: stats.size, + modifiedAt: stats.mtime.toISOString(), + }; +} + +function normalizeRelativePath({ + root, + targetPath, +}: { + root: string; + targetPath: string; +}): string { + const normalizedRoot = path.resolve(root); + const normalizedTarget = path.resolve(targetPath); + const relative = path.relative(normalizedRoot, normalizedTarget); + return relative === "" ? "." : relative.split(path.sep).join("/"); +} diff --git a/packages/server/src/server/messages.ts b/packages/server/src/server/messages.ts index 7e3f081db..c3c874a08 100644 --- a/packages/server/src/server/messages.ts +++ b/packages/server/src/server/messages.ts @@ -127,6 +127,36 @@ export const GitDiffRequestSchema = z.object({ agentId: z.string(), }); +const FileExplorerEntrySchema = z.object({ + name: z.string(), + path: z.string(), + kind: z.enum(["file", "directory"]), + size: z.number(), + modifiedAt: z.string(), +}); + +const FileExplorerFileSchema = z.object({ + path: z.string(), + kind: z.enum(["text", "image", "binary"]), + encoding: z.enum(["utf-8", "base64", "none"]), + content: z.string().optional(), + mimeType: z.string().optional(), + size: z.number(), + modifiedAt: z.string(), +}); + +const FileExplorerDirectorySchema = z.object({ + path: z.string(), + entries: z.array(FileExplorerEntrySchema), +}); + +export const FileExplorerRequestSchema = z.object({ + type: z.literal("file_explorer_request"), + agentId: z.string(), + path: z.string().optional(), + mode: z.enum(["list", "file"]), +}); + export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ UserTextMessageSchema, RealtimeAudioChunkMessageSchema, @@ -143,6 +173,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ SetAgentModeMessageSchema, AgentPermissionResponseMessageSchema, GitDiffRequestSchema, + FileExplorerRequestSchema, ]); export type SessionInboundMessage = z.infer; @@ -336,6 +367,18 @@ export const GitDiffResponseSchema = z.object({ }), }); +export const FileExplorerResponseSchema = z.object({ + type: z.literal("file_explorer_response"), + payload: z.object({ + agentId: z.string(), + path: z.string(), + mode: z.enum(["list", "file"]), + directory: FileExplorerDirectorySchema.nullable(), + file: FileExplorerFileSchema.nullable(), + error: z.string().nullable(), + }), +}); + export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ ActivityLogMessageSchema, AssistantChunkMessageSchema, @@ -354,6 +397,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ AgentPermissionRequestMessageSchema, AgentPermissionResolvedMessageSchema, GitDiffResponseSchema, + FileExplorerResponseSchema, ]); export type SessionOutboundMessage = z.infer< @@ -392,6 +436,8 @@ export type SetAgentModeMessage = z.infer; export type AgentPermissionResponseMessage = z.infer; export type GitDiffRequest = z.infer; export type GitDiffResponse = z.infer; +export type FileExplorerRequest = z.infer; +export type FileExplorerResponse = z.infer; // ============================================================================ // WebSocket Level Messages (wraps session messages) diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 758f82931..422150a54 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -13,6 +13,7 @@ import { import type { SessionInboundMessage, SessionOutboundMessage, + FileExplorerRequest, } from "./messages.js"; import { getSystemPrompt } from "./agent/system-prompt.js"; import { getAllTools } from "./agent/llm-openai.js"; @@ -35,6 +36,10 @@ import { isTitleGeneratorInitialized, } from "../services/agent-title-generator.js"; import { expandTilde } from "./terminal-mcp/tmux.js"; +import { + listDirectoryEntries, + readExplorerFile, +} from "./file-explorer/service.js"; const execAsync = promisify(exec); @@ -522,6 +527,10 @@ export class Session { case "git_diff_request": await this.handleGitDiffRequest(msg.agentId); break; + + case "file_explorer_request": + await this.handleFileExplorerRequest(msg); + break; } } catch (error: any) { console.error( @@ -1076,6 +1085,91 @@ export class Session { } } + /** + * Handle read-only file explorer requests scoped to an agent's cwd + */ + private async handleFileExplorerRequest( + request: FileExplorerRequest + ): Promise { + const { agentId, path: requestedPath = ".", mode } = request; + + console.log( + `[Session ${this.clientId}] Handling file explorer request for agent ${agentId} (${mode} ${requestedPath})` + ); + + try { + const agents = this.agentManager.listAgents(); + const agent = agents.find((a) => a.id === agentId); + + if (!agent) { + this.emit({ + type: "file_explorer_response", + payload: { + agentId, + path: requestedPath, + mode, + directory: null, + file: null, + error: `Agent not found: ${agentId}`, + }, + }); + return; + } + + if (mode === "list") { + const directory = await listDirectoryEntries({ + root: agent.cwd, + relativePath: requestedPath, + }); + + this.emit({ + type: "file_explorer_response", + payload: { + agentId, + path: directory.path, + mode, + directory, + file: null, + error: null, + }, + }); + } else { + const file = await readExplorerFile({ + root: agent.cwd, + relativePath: requestedPath, + }); + + this.emit({ + type: "file_explorer_response", + payload: { + agentId, + path: file.path, + mode, + directory: null, + file, + error: null, + }, + }); + } + } catch (error: any) { + console.error( + `[Session ${this.clientId}] Failed to fulfill file explorer request for agent ${agentId}:`, + error + ); + this.emit({ + type: "file_explorer_response", + payload: { + agentId, + path: requestedPath, + mode, + directory: null, + file: null, + error: error.message, + }, + }); + } + } + /** * Send current session state (live agents and commands) to client */