Merge upstream/dev

This commit is contained in:
CREDO23 2026-04-27 22:44:40 +02:00
commit 2d962f6dd2
107 changed files with 15033 additions and 2277 deletions

View file

@ -57,6 +57,10 @@ export const IPC_CHANNELS = {
// Agent filesystem mode
AGENT_FILESYSTEM_GET_SETTINGS: 'agent-filesystem:get-settings',
AGENT_FILESYSTEM_GET_MOUNTS: 'agent-filesystem:get-mounts',
AGENT_FILESYSTEM_LIST_FILES: 'agent-filesystem:list-files',
AGENT_FILESYSTEM_TREE_WATCH_START: 'agent-filesystem:tree-watch-start',
AGENT_FILESYSTEM_TREE_WATCH_STOP: 'agent-filesystem:tree-watch-stop',
AGENT_FILESYSTEM_TREE_DIRTY: 'agent-filesystem:tree-dirty',
AGENT_FILESYSTEM_SET_SETTINGS: 'agent-filesystem:set-settings',
AGENT_FILESYSTEM_PICK_ROOT: 'agent-filesystem:pick-root',
} as const;

View file

@ -38,6 +38,7 @@ import {
trackEvent,
} from '../modules/analytics';
import {
listAgentFilesystemFiles,
readAgentLocalFileText,
writeAgentLocalFileText,
getAgentFilesystemMounts,
@ -45,6 +46,11 @@ import {
pickAgentFilesystemRoot,
setAgentFilesystemSettings,
} from '../modules/agent-filesystem';
import {
startAgentFilesystemTreeWatch,
stopAgentFilesystemTreeWatch,
type AgentFilesystemTreeWatchOptions,
} from '../modules/agent-filesystem-tree-watcher';
let authTokens: { bearer: string; refresh: string } | null = null;
@ -136,21 +142,24 @@ export function registerIpcHandlers(): void {
readLocalFiles(paths)
);
ipcMain.handle(IPC_CHANNELS.READ_AGENT_LOCAL_FILE_TEXT, async (_event, virtualPath: string) => {
ipcMain.handle(
IPC_CHANNELS.READ_AGENT_LOCAL_FILE_TEXT,
async (_event, virtualPath: string, searchSpaceId?: number | null) => {
try {
const result = await readAgentLocalFileText(virtualPath);
const result = await readAgentLocalFileText(virtualPath, searchSpaceId);
return { ok: true, path: result.path, content: result.content };
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to read local file';
return { ok: false, path: virtualPath, error: message };
}
});
}
);
ipcMain.handle(
IPC_CHANNELS.WRITE_AGENT_LOCAL_FILE_TEXT,
async (_event, virtualPath: string, content: string) => {
async (_event, virtualPath: string, content: string, searchSpaceId?: number | null) => {
try {
const result = await writeAgentLocalFileText(virtualPath, content);
const result = await writeAgentLocalFileText(virtualPath, content, searchSpaceId);
return { ok: true, path: result.path };
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to write local file';
@ -233,21 +242,52 @@ export function registerIpcHandlers(): void {
};
});
ipcMain.handle(IPC_CHANNELS.AGENT_FILESYSTEM_GET_SETTINGS, () =>
getAgentFilesystemSettings()
ipcMain.handle(IPC_CHANNELS.AGENT_FILESYSTEM_GET_SETTINGS, (_event, searchSpaceId?: number | null) =>
getAgentFilesystemSettings(searchSpaceId)
);
ipcMain.handle(IPC_CHANNELS.AGENT_FILESYSTEM_GET_MOUNTS, () =>
getAgentFilesystemMounts()
ipcMain.handle(IPC_CHANNELS.AGENT_FILESYSTEM_GET_MOUNTS, (_event, searchSpaceId?: number | null) =>
getAgentFilesystemMounts(searchSpaceId)
);
ipcMain.handle(
IPC_CHANNELS.AGENT_FILESYSTEM_LIST_FILES,
(
_event,
options: {
rootPath: string;
searchSpaceId?: number | null;
excludePatterns?: string[] | null;
fileExtensions?: string[] | null;
}
) =>
listAgentFilesystemFiles(options)
);
ipcMain.handle(
IPC_CHANNELS.AGENT_FILESYSTEM_SET_SETTINGS,
(_event, settings: { mode?: 'cloud' | 'desktop_local_folder'; localRootPaths?: string[] | null }) =>
setAgentFilesystemSettings(settings)
(
_event,
payload: {
searchSpaceId?: number | null;
settings: { mode?: 'cloud' | 'desktop_local_folder'; localRootPaths?: string[] | null };
}
) => setAgentFilesystemSettings(payload?.searchSpaceId, payload?.settings ?? {})
);
ipcMain.handle(IPC_CHANNELS.AGENT_FILESYSTEM_PICK_ROOT, () =>
pickAgentFilesystemRoot()
);
ipcMain.handle(
IPC_CHANNELS.AGENT_FILESYSTEM_TREE_WATCH_START,
(_event, options: AgentFilesystemTreeWatchOptions) =>
startAgentFilesystemTreeWatch(options)
);
ipcMain.handle(
IPC_CHANNELS.AGENT_FILESYSTEM_TREE_WATCH_STOP,
(_event, searchSpaceId?: number | null) =>
stopAgentFilesystemTreeWatch(searchSpaceId)
);
}

View file

@ -0,0 +1,302 @@
import { BrowserWindow } from 'electron';
import chokidar, { type FSWatcher } from 'chokidar';
import { resolve } from 'node:path';
import { IPC_CHANNELS } from '../ipc/channels';
import { listAgentFilesystemFiles } from './agent-filesystem';
const SAFETY_POLL_MS = 60_000;
const EVENT_DEBOUNCE_MS = 700;
export type AgentFilesystemTreeWatchOptions = {
searchSpaceId?: number | null;
rootPaths: string[];
excludePatterns?: string[] | null;
fileExtensions?: string[] | null;
};
type TreeDirtyReason = 'watcher_event' | 'safety_poll';
type TreeDirtyEvent = {
searchSpaceId: number | null;
reason: TreeDirtyReason;
rootPath: string;
changedPath: string | null;
timestamp: number;
};
type WatchSession = {
searchSpaceId: number | null;
optionsSignature: string;
rootPaths: string[];
excludePatterns: string[];
fileExtensions: string[] | null;
watchers: FSWatcher[];
pollTimer: NodeJS.Timeout | null;
emitTimer: NodeJS.Timeout | null;
rootSnapshotByPath: Map<string, string>;
pendingDirtyByRoot: Map<string, { reason: TreeDirtyReason; changedPath: string | null }>;
disposed: boolean;
};
const sessions = new Map<string, WatchSession>();
function normalizeSearchSpaceId(searchSpaceId?: number | null): number | null {
if (typeof searchSpaceId === 'number' && Number.isFinite(searchSpaceId) && searchSpaceId > 0) {
return searchSpaceId;
}
return null;
}
function getSessionKey(searchSpaceId?: number | null): string {
const normalized = normalizeSearchSpaceId(searchSpaceId);
return normalized === null ? 'default' : String(normalized);
}
function normalizeRootPath(pathValue: string): string {
const normalized = resolve(pathValue.trim());
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
}
function normalizeList(value: string[] | null | undefined): string[] {
if (!value || value.length === 0) return [];
return value
.filter((entry): entry is string => typeof entry === 'string')
.map((entry) => entry.trim())
.filter(Boolean);
}
function normalizeExtensions(value: string[] | null | undefined): string[] | null {
const normalized = normalizeList(value).map((entry) => entry.toLowerCase());
return normalized.length > 0 ? normalized : null;
}
function buildOptionsSignature(
searchSpaceId: number | null,
rootPaths: string[],
excludePatterns: string[],
fileExtensions: string[] | null
): string {
return JSON.stringify({
searchSpaceId,
rootPaths: [...rootPaths].sort(),
excludePatterns: [...excludePatterns].sort(),
fileExtensions: fileExtensions ? [...fileExtensions].sort() : null,
});
}
function hashText(input: string, seed: number): number {
let hash = seed >>> 0;
for (let i = 0; i < input.length; i += 1) {
hash ^= input.charCodeAt(i);
hash = Math.imul(hash, 16777619);
hash >>>= 0;
}
return hash;
}
async function buildRootSnapshotSignature(
session: WatchSession,
rootPath: string
): Promise<string> {
let hash = 2166136261;
hash = hashText(`space:${session.searchSpaceId ?? 'default'}|root:${rootPath}`, hash);
const files = await listAgentFilesystemFiles({
rootPath,
searchSpaceId: session.searchSpaceId,
excludePatterns: session.excludePatterns,
fileExtensions: session.fileExtensions,
});
const sortedFiles = [...files].sort((a, b) => a.relativePath.localeCompare(b.relativePath));
hash = hashText(`count:${sortedFiles.length}`, hash);
for (const file of sortedFiles) {
hash = hashText(
`${file.relativePath}|${Math.round(file.mtimeMs)}|${file.size}`,
hash
);
}
return hash.toString(16);
}
function sendTreeDirtyEvent(
searchSpaceId: number | null,
reason: TreeDirtyReason,
rootPath: string,
changedPath: string | null
): void {
const payload: TreeDirtyEvent = {
searchSpaceId,
reason,
rootPath,
changedPath,
timestamp: Date.now(),
};
for (const win of BrowserWindow.getAllWindows()) {
if (!win.isDestroyed()) {
win.webContents.send(IPC_CHANNELS.AGENT_FILESYSTEM_TREE_DIRTY, payload);
}
}
}
function scheduleDirtyEmit(
session: WatchSession,
reason: TreeDirtyReason,
rootPath: string,
changedPath: string | null = null
): void {
if (session.disposed) return;
const existing = session.pendingDirtyByRoot.get(rootPath);
if (!existing || existing.reason === 'safety_poll') {
session.pendingDirtyByRoot.set(rootPath, { reason, changedPath });
}
if (session.emitTimer) {
clearTimeout(session.emitTimer);
}
session.emitTimer = setTimeout(() => {
session.emitTimer = null;
if (session.disposed) return;
const pending = Array.from(session.pendingDirtyByRoot.entries());
session.pendingDirtyByRoot.clear();
for (const [pendingRootPath, payload] of pending) {
sendTreeDirtyEvent(
session.searchSpaceId,
payload.reason,
pendingRootPath,
payload.changedPath
);
}
}, EVENT_DEBOUNCE_MS);
}
async function closeSession(session: WatchSession): Promise<void> {
session.disposed = true;
if (session.emitTimer) {
clearTimeout(session.emitTimer);
session.emitTimer = null;
}
if (session.pollTimer) {
clearInterval(session.pollTimer);
session.pollTimer = null;
}
await Promise.allSettled(session.watchers.map((watcher) => watcher.close()));
}
export async function startAgentFilesystemTreeWatch(
options: AgentFilesystemTreeWatchOptions
): Promise<{ ok: true }> {
const searchSpaceId = normalizeSearchSpaceId(options.searchSpaceId);
const rootPaths = Array.from(
new Set(normalizeList(options.rootPaths).map((rootPath) => normalizeRootPath(rootPath)))
);
const excludePatterns = Array.from(new Set(normalizeList(options.excludePatterns)));
const fileExtensions = normalizeExtensions(options.fileExtensions);
const sessionKey = getSessionKey(searchSpaceId);
if (rootPaths.length === 0) {
await stopAgentFilesystemTreeWatch(searchSpaceId);
return { ok: true };
}
const optionsSignature = buildOptionsSignature(
searchSpaceId,
rootPaths,
excludePatterns,
fileExtensions
);
const existing = sessions.get(sessionKey);
if (existing && existing.optionsSignature === optionsSignature) {
return { ok: true };
}
if (existing) {
await closeSession(existing);
sessions.delete(sessionKey);
}
const ignored = [
/(^|[/\\])\../,
...excludePatterns.map((pattern) => `**/${pattern}/**`),
];
const watchers = rootPaths.map((rootPath) =>
chokidar.watch(rootPath, {
persistent: true,
ignoreInitial: true,
awaitWriteFinish: {
stabilityThreshold: 500,
pollInterval: 100,
},
ignored,
})
);
const session: WatchSession = {
searchSpaceId,
optionsSignature,
rootPaths,
excludePatterns,
fileExtensions,
watchers,
pollTimer: null,
emitTimer: null,
rootSnapshotByPath: new Map(),
pendingDirtyByRoot: new Map(),
disposed: false,
};
for (let index = 0; index < watchers.length; index += 1) {
const watcher = watchers[index];
const rootPath = rootPaths[index];
watcher.on('add', (filePath) => scheduleDirtyEmit(session, 'watcher_event', rootPath, filePath));
watcher.on('change', (filePath) =>
scheduleDirtyEmit(session, 'watcher_event', rootPath, filePath)
);
watcher.on('unlink', (filePath) =>
scheduleDirtyEmit(session, 'watcher_event', rootPath, filePath)
);
watcher.on('addDir', (filePath) =>
scheduleDirtyEmit(session, 'watcher_event', rootPath, filePath)
);
watcher.on('unlinkDir', (filePath) =>
scheduleDirtyEmit(session, 'watcher_event', rootPath, filePath)
);
}
for (const rootPath of rootPaths) {
try {
const signature = await buildRootSnapshotSignature(session, rootPath);
session.rootSnapshotByPath.set(rootPath, signature);
} catch {
session.rootSnapshotByPath.set(rootPath, '');
}
}
session.pollTimer = setInterval(() => {
void (async () => {
if (session.disposed) return;
for (const rootPath of session.rootPaths) {
try {
const nextSignature = await buildRootSnapshotSignature(session, rootPath);
const previousSignature = session.rootSnapshotByPath.get(rootPath) ?? '';
if (nextSignature !== previousSignature) {
session.rootSnapshotByPath.set(rootPath, nextSignature);
scheduleDirtyEmit(session, 'safety_poll', rootPath, null);
}
} catch {
// Keep watcher resilient on transient IO errors.
}
}
})();
}, SAFETY_POLL_MS);
sessions.set(sessionKey, session);
return { ok: true };
}
export async function stopAgentFilesystemTreeWatch(
searchSpaceId?: number | null
): Promise<{ ok: true }> {
const sessionKey = getSessionKey(searchSpaceId);
const session = sessions.get(sessionKey);
if (!session) return { ok: true };
sessions.delete(sessionKey);
await closeSession(session);
return { ok: true };
}

View file

@ -1,6 +1,7 @@
import { app, dialog } from "electron";
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
import type { Dirent } from "node:fs";
import { access, mkdir, readdir, readFile, realpath, stat, writeFile } from "node:fs/promises";
import { dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
export type AgentFilesystemMode = "cloud" | "desktop_local_folder";
@ -10,8 +11,60 @@ export interface AgentFilesystemSettings {
updatedAt: string;
}
type AgentFilesystemSettingsStore = {
version: 2;
spaces: Record<string, AgentFilesystemSettings>;
};
const SETTINGS_FILENAME = "agent-filesystem-settings.json";
const MAX_LOCAL_ROOTS = 5;
const MAX_LOCAL_ROOTS = 10;
const DEFAULT_SPACE_KEY = "default";
let cachedSettingsStore: AgentFilesystemSettingsStore | null = null;
const LOCAL_OPENABLE_TEXT_EXTENSIONS = new Set<string>([
".md",
".markdown",
".txt",
".json",
".yaml",
".yml",
".csv",
".tsv",
".xml",
".html",
".htm",
".css",
".scss",
".sass",
".sql",
".toml",
".ini",
".conf",
".log",
".py",
".js",
".jsx",
".mjs",
".cjs",
".ts",
".tsx",
".java",
".kt",
".kts",
".go",
".rs",
".rb",
".php",
".swift",
".r",
".lua",
".sh",
".bash",
".zsh",
".fish",
".env",
".mk",
]);
function getSettingsPath(): string {
return join(app.getPath("userData"), SETTINGS_FILENAME);
@ -25,14 +78,23 @@ function getDefaultSettings(): AgentFilesystemSettings {
};
}
async function canonicalizeRootPath(pathValue: string): Promise<string> {
const resolvedPath = resolve(pathValue);
try {
return await realpath(resolvedPath);
} catch {
return resolvedPath;
}
}
function normalizeLocalRootPaths(paths: unknown): string[] {
if (!Array.isArray(paths)) {
return [];
}
const uniquePaths = new Set<string>();
for (const path of paths) {
if (typeof path !== "string") continue;
const trimmed = path.trim();
for (const rawPath of paths) {
if (typeof rawPath !== "string") continue;
const trimmed = rawPath.trim();
if (!trimmed) continue;
uniquePaths.add(trimmed);
if (uniquePaths.size >= MAX_LOCAL_ROOTS) {
@ -42,30 +104,112 @@ function normalizeLocalRootPaths(paths: unknown): string[] {
return [...uniquePaths];
}
export async function getAgentFilesystemSettings(): Promise<AgentFilesystemSettings> {
try {
const raw = await readFile(getSettingsPath(), "utf8");
const parsed = JSON.parse(raw) as Partial<AgentFilesystemSettings>;
if (parsed.mode !== "cloud" && parsed.mode !== "desktop_local_folder") {
return getDefaultSettings();
async function normalizeLocalRootPathsCanonical(paths: unknown): Promise<string[]> {
const normalizedPaths = normalizeLocalRootPaths(paths);
const canonicalizedPaths = await Promise.all(
normalizedPaths.map((pathValue) => canonicalizeRootPath(pathValue))
);
const uniquePaths = new Set<string>();
for (const canonicalPath of canonicalizedPaths) {
uniquePaths.add(canonicalPath);
if (uniquePaths.size >= MAX_LOCAL_ROOTS) {
break;
}
return {
mode: parsed.mode,
localRootPaths: normalizeLocalRootPaths(parsed.localRootPaths),
updatedAt: parsed.updatedAt ?? new Date().toISOString(),
};
}
return [...uniquePaths];
}
function normalizeSearchSpaceKey(searchSpaceId?: number | null): string {
if (typeof searchSpaceId === "number" && Number.isFinite(searchSpaceId) && searchSpaceId > 0) {
return String(searchSpaceId);
}
return DEFAULT_SPACE_KEY;
}
function toSettingsFromUnknown(value: unknown): AgentFilesystemSettings | null {
if (!value || typeof value !== "object") {
return null;
}
const parsed = value as Partial<AgentFilesystemSettings>;
if (parsed.mode !== "cloud" && parsed.mode !== "desktop_local_folder") {
return null;
}
return {
mode: parsed.mode,
localRootPaths: normalizeLocalRootPaths(parsed.localRootPaths),
updatedAt: parsed.updatedAt ?? new Date().toISOString(),
};
}
function getDefaultStore(): AgentFilesystemSettingsStore {
return { version: 2, spaces: {} };
}
function getSettingsFromStore(
store: AgentFilesystemSettingsStore,
searchSpaceId?: number | null
): AgentFilesystemSettings {
const key = normalizeSearchSpaceKey(searchSpaceId);
return store.spaces[key] ?? getDefaultSettings();
}
async function loadAgentFilesystemSettingsStore(): Promise<AgentFilesystemSettingsStore> {
if (cachedSettingsStore) {
return cachedSettingsStore;
}
const settingsPath = getSettingsPath();
try {
const raw = await readFile(settingsPath, "utf8");
const parsed = JSON.parse(raw) as unknown;
const nextStore = getDefaultStore();
if (
parsed &&
typeof parsed === "object" &&
"version" in parsed &&
"spaces" in parsed &&
(parsed as { version?: unknown }).version === 2
) {
const parsedStore = parsed as { spaces?: Record<string, unknown>; version: 2 };
if (parsedStore.spaces && typeof parsedStore.spaces === "object") {
for (const [spaceKey, rawSettings] of Object.entries(parsedStore.spaces)) {
const normalizedSettings = toSettingsFromUnknown(rawSettings);
if (normalizedSettings) {
nextStore.spaces[String(spaceKey)] = normalizedSettings;
}
}
}
} else {
// Strict migration: reject legacy/non-scoped settings and reset.
await mkdir(dirname(settingsPath), { recursive: true });
await writeFile(settingsPath, JSON.stringify(nextStore, null, 2), "utf8");
}
cachedSettingsStore = nextStore;
return nextStore;
} catch {
return getDefaultSettings();
cachedSettingsStore = getDefaultStore();
await mkdir(dirname(settingsPath), { recursive: true });
await writeFile(settingsPath, JSON.stringify(cachedSettingsStore, null, 2), "utf8");
return cachedSettingsStore;
}
}
export async function getAgentFilesystemSettings(
searchSpaceId?: number | null
): Promise<AgentFilesystemSettings> {
const store = await loadAgentFilesystemSettingsStore();
return getSettingsFromStore(store, searchSpaceId);
}
export async function setAgentFilesystemSettings(
searchSpaceId: number | null | undefined,
settings: {
mode?: AgentFilesystemMode;
localRootPaths?: string[] | null;
}
): Promise<AgentFilesystemSettings> {
const current = await getAgentFilesystemSettings();
const store = await loadAgentFilesystemSettingsStore();
const key = normalizeSearchSpaceKey(searchSpaceId);
const current = getSettingsFromStore(store, searchSpaceId);
const nextMode =
settings.mode === "cloud" || settings.mode === "desktop_local_folder"
? settings.mode
@ -75,13 +219,21 @@ export async function setAgentFilesystemSettings(
localRootPaths:
settings.localRootPaths === undefined
? current.localRootPaths
: normalizeLocalRootPaths(settings.localRootPaths ?? []),
: await normalizeLocalRootPathsCanonical(settings.localRootPaths ?? []),
updatedAt: new Date().toISOString(),
};
const settingsPath = getSettingsPath();
await mkdir(dirname(settingsPath), { recursive: true });
await writeFile(settingsPath, JSON.stringify(next, null, 2), "utf8");
const nextStore: AgentFilesystemSettingsStore = {
version: 2,
spaces: {
...store.spaces,
[key]: next,
},
};
await writeFile(settingsPath, JSON.stringify(nextStore, null, 2), "utf8");
cachedSettingsStore = nextStore;
return next;
}
@ -122,11 +274,35 @@ function toVirtualPath(rootPath: string, absolutePath: string): string {
return `/${rel.replace(/\\/g, "/")}`;
}
function assertLocalOpenableTextFile(absolutePath: string): void {
const extension = extname(absolutePath).toLowerCase();
if (!LOCAL_OPENABLE_TEXT_EXTENSIONS.has(extension)) {
throw new Error(
`Unsupported local file type '${extension || "(no extension)"}'. ` +
"Only text/code files can be opened in local mode."
);
}
}
export type LocalRootMount = {
mount: string;
rootPath: string;
};
export type AgentFilesystemListOptions = {
rootPath: string;
searchSpaceId?: number | null;
excludePatterns?: string[] | null;
fileExtensions?: string[] | null;
};
export type AgentFilesystemFileEntry = {
relativePath: string;
fullPath: string;
size: number;
mtimeMs: number;
};
function sanitizeMountName(rawMount: string): string {
const normalized = rawMount
.trim()
@ -155,11 +331,111 @@ function buildRootMounts(rootPaths: string[]): LocalRootMount[] {
return mounts;
}
export async function getAgentFilesystemMounts(): Promise<LocalRootMount[]> {
const rootPaths = await resolveCurrentRootPaths();
export async function getAgentFilesystemMounts(
searchSpaceId?: number | null
): Promise<LocalRootMount[]> {
const rootPaths = await resolveCurrentRootPaths(searchSpaceId);
return buildRootMounts(rootPaths);
}
function normalizeComparablePath(pathValue: string): string {
const normalized = resolve(pathValue);
return process.platform === "win32" ? normalized.toLowerCase() : normalized;
}
function normalizeExtensionSet(fileExtensions: string[] | null | undefined): Set<string> | null {
if (!fileExtensions || fileExtensions.length === 0) {
return null;
}
const set = new Set<string>();
for (const extension of fileExtensions) {
if (typeof extension !== "string") continue;
const trimmed = extension.trim().toLowerCase();
if (!trimmed) continue;
set.add(trimmed.startsWith(".") ? trimmed : `.${trimmed}`);
}
return set.size > 0 ? set : null;
}
function normalizeExcludeSet(excludePatterns: string[] | null | undefined): Set<string> {
const set = new Set<string>();
for (const pattern of excludePatterns ?? []) {
if (typeof pattern !== "string") continue;
const trimmed = pattern.trim();
if (!trimmed) continue;
set.add(trimmed);
}
return set;
}
export async function listAgentFilesystemFiles(
options: AgentFilesystemListOptions
): Promise<AgentFilesystemFileEntry[]> {
const allowedRootPaths = await resolveCurrentRootPaths(options.searchSpaceId);
const requestedRootPath = await canonicalizeRootPath(options.rootPath);
const normalizedRequestedRoot = normalizeComparablePath(requestedRootPath);
const allowedRoots = new Set(
(
await Promise.all(allowedRootPaths.map((rootPath) => canonicalizeRootPath(rootPath)))
).map((rootPath) => normalizeComparablePath(rootPath))
);
if (!allowedRoots.has(normalizedRequestedRoot)) {
throw new Error("Selected path is not an allowed local root");
}
const excludePatterns = normalizeExcludeSet(options.excludePatterns);
const extensionSet = normalizeExtensionSet(options.fileExtensions);
const files: AgentFilesystemFileEntry[] = [];
const stack: string[] = [requestedRootPath];
while (stack.length > 0) {
const currentDir = stack.pop();
if (!currentDir) continue;
let entries: Dirent[];
try {
entries = await readdir(currentDir, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
if (entry.name.startsWith(".") || excludePatterns.has(entry.name)) {
continue;
}
const absolutePath = join(currentDir, entry.name);
if (entry.isDirectory()) {
stack.push(absolutePath);
continue;
}
if (!entry.isFile()) {
continue;
}
if (extensionSet) {
const extension = extname(entry.name).toLowerCase();
if (!extensionSet.has(extension)) {
continue;
}
}
try {
const fileStat = await stat(absolutePath);
if (!fileStat.isFile()) {
continue;
}
files.push({
relativePath: relative(requestedRootPath, absolutePath).replace(/\\/g, "/"),
fullPath: absolutePath,
size: fileStat.size,
mtimeMs: fileStat.mtimeMs,
});
} catch {
// Files can disappear while scanning.
}
}
}
return files;
}
function parseMountedVirtualPath(
virtualPath: string,
mounts: LocalRootMount[]
@ -198,8 +474,8 @@ function toMountedVirtualPath(mount: string, rootPath: string, absolutePath: str
return `/${mount}${relativePath}`;
}
async function resolveCurrentRootPaths(): Promise<string[]> {
const settings = await getAgentFilesystemSettings();
async function resolveCurrentRootPaths(searchSpaceId?: number | null): Promise<string[]> {
const settings = await getAgentFilesystemSettings(searchSpaceId);
if (settings.localRootPaths.length === 0) {
throw new Error("No local filesystem roots selected");
}
@ -207,9 +483,10 @@ async function resolveCurrentRootPaths(): Promise<string[]> {
}
export async function readAgentLocalFileText(
virtualPath: string
virtualPath: string,
searchSpaceId?: number | null
): Promise<{ path: string; content: string }> {
const rootPaths = await resolveCurrentRootPaths();
const rootPaths = await resolveCurrentRootPaths(searchSpaceId);
const mounts = buildRootMounts(rootPaths);
const { mount, subPath } = parseMountedVirtualPath(virtualPath, mounts);
const rootMount = findMountByName(mounts, mount);
@ -219,6 +496,7 @@ export async function readAgentLocalFileText(
);
}
const absolutePath = resolveVirtualPath(rootMount.rootPath, subPath);
assertLocalOpenableTextFile(absolutePath);
const content = await readFile(absolutePath, "utf8");
return {
path: toMountedVirtualPath(rootMount.mount, rootMount.rootPath, absolutePath),
@ -228,9 +506,10 @@ export async function readAgentLocalFileText(
export async function writeAgentLocalFileText(
virtualPath: string,
content: string
content: string,
searchSpaceId?: number | null
): Promise<{ path: string }> {
const rootPaths = await resolveCurrentRootPaths();
const rootPaths = await resolveCurrentRootPaths(searchSpaceId);
const mounts = buildRootMounts(rootPaths);
const { mount, subPath } = parseMountedVirtualPath(virtualPath, mounts);
const rootMount = findMountByName(mounts, mount);

View file

@ -66,10 +66,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
// Browse files via native dialog
browseFiles: () => ipcRenderer.invoke(IPC_CHANNELS.BROWSE_FILES),
readLocalFiles: (paths: string[]) => ipcRenderer.invoke(IPC_CHANNELS.READ_LOCAL_FILES, paths),
readAgentLocalFileText: (virtualPath: string) =>
ipcRenderer.invoke(IPC_CHANNELS.READ_AGENT_LOCAL_FILE_TEXT, virtualPath),
writeAgentLocalFileText: (virtualPath: string, content: string) =>
ipcRenderer.invoke(IPC_CHANNELS.WRITE_AGENT_LOCAL_FILE_TEXT, virtualPath, content),
readAgentLocalFileText: (virtualPath: string, searchSpaceId?: number | null) =>
ipcRenderer.invoke(IPC_CHANNELS.READ_AGENT_LOCAL_FILE_TEXT, virtualPath, searchSpaceId),
writeAgentLocalFileText: (virtualPath: string, content: string, searchSpaceId?: number | null) =>
ipcRenderer.invoke(IPC_CHANNELS.WRITE_AGENT_LOCAL_FILE_TEXT, virtualPath, content, searchSpaceId),
// Auth token sync across windows
getAuthTokens: () => ipcRenderer.invoke(IPC_CHANNELS.GET_AUTH_TOKENS),
@ -101,13 +101,52 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.invoke(IPC_CHANNELS.ANALYTICS_CAPTURE, { event, properties }),
getAnalyticsContext: () => ipcRenderer.invoke(IPC_CHANNELS.ANALYTICS_GET_CONTEXT),
// Agent filesystem mode
getAgentFilesystemSettings: () =>
ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_GET_SETTINGS),
getAgentFilesystemMounts: () =>
ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_GET_MOUNTS),
getAgentFilesystemSettings: (searchSpaceId?: number | null) =>
ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_GET_SETTINGS, searchSpaceId),
getAgentFilesystemMounts: (searchSpaceId?: number | null) =>
ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_GET_MOUNTS, searchSpaceId),
listAgentFilesystemFiles: (options: {
rootPath: string;
searchSpaceId?: number | null;
excludePatterns?: string[] | null;
fileExtensions?: string[] | null;
}) => ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_LIST_FILES, options),
startAgentFilesystemTreeWatch: (options: {
searchSpaceId?: number | null;
rootPaths: string[];
excludePatterns?: string[] | null;
fileExtensions?: string[] | null;
}) => ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_TREE_WATCH_START, options),
stopAgentFilesystemTreeWatch: (searchSpaceId?: number | null) =>
ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_TREE_WATCH_STOP, searchSpaceId),
onAgentFilesystemTreeDirty: (
callback: (data: {
searchSpaceId: number | null;
reason: 'watcher_event' | 'safety_poll';
rootPath: string;
changedPath: string | null;
timestamp: number;
}) => void
) => {
const listener = (
_event: unknown,
data: {
searchSpaceId: number | null;
reason: 'watcher_event' | 'safety_poll';
rootPath: string;
changedPath: string | null;
timestamp: number;
}
) => callback(data);
ipcRenderer.on(IPC_CHANNELS.AGENT_FILESYSTEM_TREE_DIRTY, listener);
return () => {
ipcRenderer.removeListener(IPC_CHANNELS.AGENT_FILESYSTEM_TREE_DIRTY, listener);
};
},
setAgentFilesystemSettings: (settings: {
mode?: "cloud" | "desktop_local_folder";
localRootPaths?: string[] | null;
}) => ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_SET_SETTINGS, settings),
}, searchSpaceId?: number | null) =>
ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_SET_SETTINGS, { searchSpaceId, settings }),
pickAgentFilesystemRoot: () => ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_PICK_ROOT),
});