Add read-only file explorer
This commit is contained in:
@@ -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<string, string> = {
|
||||
".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<FileExplorerDirectory> {
|
||||
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<FileExplorerFile> {
|
||||
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<string> {
|
||||
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<FileExplorerEntry> {
|
||||
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("/");
|
||||
}
|
||||
@@ -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<typeof SessionInboundMessageSchema>;
|
||||
@@ -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<typeof SetAgentModeMessageSchema>;
|
||||
export type AgentPermissionResponseMessage = z.infer<typeof AgentPermissionResponseMessageSchema>;
|
||||
export type GitDiffRequest = z.infer<typeof GitDiffRequestSchema>;
|
||||
export type GitDiffResponse = z.infer<typeof GitDiffResponseSchema>;
|
||||
export type FileExplorerRequest = z.infer<typeof FileExplorerRequestSchema>;
|
||||
export type FileExplorerResponse = z.infer<typeof FileExplorerResponseSchema>;
|
||||
|
||||
// ============================================================================
|
||||
// WebSocket Level Messages (wraps session messages)
|
||||
|
||||
@@ -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<void> {
|
||||
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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user