Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/handlers/filesystem-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,12 @@ export async function handleListDirectory(args: unknown): Promise<ServerResult>
try {
const startTime = Date.now();
const parsed = ListDirectoryArgsSchema.parse(args);
const entries = await listDirectory(parsed.path, parsed.depth);
const entries = await listDirectory(
parsed.path,
parsed.depth,
parsed.respectGitignore,
parsed.includeHidden
);
const duration = Date.now() - startTime;

const resultText = entries.join('\n');
Expand Down
12 changes: 11 additions & 1 deletion src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -332,14 +332,24 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
- depth=2: Contents plus one level of subdirectories
- depth=3+: Multiple levels deep

GITIGNORE AND HIDDEN FILES SUPPORT:
- respectGitignore (default: true): When enabled, automatically respects .gitignore, .git/info/exclude, and global git ignore rules
- includeHidden (default: false): When enabled, includes hidden files and directories (those starting with '.')

By default, this tool respects gitignore rules and skips:
- node_modules/, .git/, dist/, build/, and other ignored directories
- Hidden files and directories (unless includeHidden=true)
- Files listed in .gitignore, .git/info/exclude, and global git config

This behavior matches the search tools for consistency.

Results show full relative paths from the root directory being listed.
Example output with depth=2:
[DIR] src
[FILE] src/index.ts
[DIR] src/tools
[FILE] src/tools/filesystem.ts

If a directory cannot be accessed, it will show [DENIED] instead.
Only works within allowed directories.

${PATH_GUIDANCE}
Expand Down
185 changes: 151 additions & 34 deletions src/tools/filesystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import fetch from 'cross-fetch';
import { createReadStream } from 'fs';
import { createInterface } from 'readline';
import { isBinaryFile } from 'isbinaryfile';
import { spawn } from 'child_process';
import { rgPath } from '@vscode/ripgrep';
import {capture} from '../utils/capture.js';
import {withTimeout} from '../utils/withTimeout.js';
import {configManager} from '../config-manager.js';
Expand Down Expand Up @@ -890,46 +892,161 @@ export async function createDirectory(dirPath: string): Promise<void> {
await fs.mkdir(validPath, { recursive: true });
}

export async function listDirectory(dirPath: string, depth: number = 2): Promise<string[]> {
export async function listDirectory(
dirPath: string,
depth: number = 2,
respectGitignore: boolean = true,
includeHidden: boolean = false
): Promise<string[]> {
const validPath = await validatePath(dirPath);

// Strategy: Run ripgrep with depth+1 to discover immediate subdirectories
// Then filter results back to the requested depth
const scanDepth = depth + 1;

// Use ripgrep to get list of files respecting gitignore
const rgArgs: string[] = ['--files'];

// Add max-depth flag (scan one level deeper to discover directories)
rgArgs.push('--max-depth', scanDepth.toString());

// Handle gitignore respect
if (!respectGitignore) {
rgArgs.push('--no-ignore');
}

// Handle hidden files
if (includeHidden) {
rgArgs.push('--hidden');
}

// Add the path
rgArgs.push(validPath);

// Execute ripgrep
const files = await new Promise<string[]>((resolve, reject) => {
const rgProcess = spawn(rgPath, rgArgs);
const output: string[] = [];
let errorOutput = '';

rgProcess.stdout?.on('data', (data: Buffer) => {
output.push(data.toString());
});

rgProcess.stderr?.on('data', (data: Buffer) => {
errorOutput += data.toString();
});

rgProcess.on('close', (code: number) => {
// Code 0 = success, 1 = no matches, 2 = some files couldn't be searched
if (code === 0 || code === 1 || code === 2) {
const allOutput = output.join('');
const fileList = allOutput
.split('\n')
.map(line => line.trim())
.filter(Boolean);
resolve(fileList);
} else {
reject(new Error(`ripgrep failed with code ${code}: ${errorOutput}`));
}
});

rgProcess.on('error', (error: Error) => {
reject(error);
});
});
Comment on lines +927 to +957

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Child process can hang: add timeout and kill ripgrep

Spawning rg without a timeout is an external-call hazard; a slow filesystem or huge tree can block the request thread indefinitely. Add a guard that kills the process after a bounded interval and surfaces a clear error.

Apply this diff to add a timeout:

-    const files = await new Promise<string[]>((resolve, reject) => {
-        const rgProcess = spawn(rgPath, rgArgs);
+    const files = await new Promise<string[]>((resolve, reject) => {
+        const RG_TIMEOUT_MS = 30000;
+        const rgProcess = spawn(rgPath, rgArgs);
         const output: string[] = [];
         let errorOutput = '';
+        let timedOut = false;
+        const timer = setTimeout(() => {
+            timedOut = true;
+            try { rgProcess.kill('SIGKILL'); } catch {}
+        }, RG_TIMEOUT_MS);
         
         rgProcess.stdout?.on('data', (data: Buffer) => {
             output.push(data.toString());
         });
         
         rgProcess.stderr?.on('data', (data: Buffer) => {
             errorOutput += data.toString();
         });
         
         rgProcess.on('close', (code: number) => {
+            clearTimeout(timer);
+            if (timedOut) {
+                return reject(new Error(`ripgrep timed out after ${RG_TIMEOUT_MS}ms`));
+            }
             // Code 0 = success, 1 = no matches, 2 = some files couldn't be searched
             if (code === 0 || code === 1 || code === 2) {
                 const allOutput = output.join('');
                 const fileList = allOutput
                     .split('\n')
                     .map(line => line.trim())
                     .filter(Boolean);
                 resolve(fileList);
             } else {
                 reject(new Error(`ripgrep failed with code ${code}: ${errorOutput}`));
             }
         });
         
         rgProcess.on('error', (error: Error) => {
-            reject(error);
+            clearTimeout(timer);
+            reject(error);
         });
     });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const files = await new Promise<string[]>((resolve, reject) => {
const rgProcess = spawn(rgPath, rgArgs);
const output: string[] = [];
let errorOutput = '';
rgProcess.stdout?.on('data', (data: Buffer) => {
output.push(data.toString());
});
rgProcess.stderr?.on('data', (data: Buffer) => {
errorOutput += data.toString();
});
rgProcess.on('close', (code: number) => {
// Code 0 = success, 1 = no matches, 2 = some files couldn't be searched
if (code === 0 || code === 1 || code === 2) {
const allOutput = output.join('');
const fileList = allOutput
.split('\n')
.map(line => line.trim())
.filter(Boolean);
resolve(fileList);
} else {
reject(new Error(`ripgrep failed with code ${code}: ${errorOutput}`));
}
});
rgProcess.on('error', (error: Error) => {
reject(error);
});
});
const files = await new Promise<string[]>((resolve, reject) => {
const RG_TIMEOUT_MS = 30000;
const rgProcess = spawn(rgPath, rgArgs);
const output: string[] = [];
let errorOutput = '';
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
try { rgProcess.kill('SIGKILL'); } catch {}
}, RG_TIMEOUT_MS);
rgProcess.stdout?.on('data', (data: Buffer) => {
output.push(data.toString());
});
rgProcess.stderr?.on('data', (data: Buffer) => {
errorOutput += data.toString();
});
rgProcess.on('close', (code: number) => {
clearTimeout(timer);
if (timedOut) {
return reject(new Error(`ripgrep timed out after ${RG_TIMEOUT_MS}ms`));
}
// Code 0 = success, 1 = no matches, 2 = some files couldn't be searched
if (code === 0 || code === 1 || code === 2) {
const allOutput = output.join('');
const fileList = allOutput
.split('\n')
.map(line => line.trim())
.filter(Boolean);
resolve(fileList);
} else {
reject(new Error(`ripgrep failed with code ${code}: ${errorOutput}`));
}
});
rgProcess.on('error', (error: Error) => {
clearTimeout(timer);
reject(error);
});
});
🤖 Prompt for AI Agents
In src/tools/filesystem.ts around lines 927 to 957, the ripgrep child process is
spawned without a timeout which can hang on slow or huge filesystems; add a
watchdog that sets a bounded timeout (e.g., configurable constant) which, when
reached, kills the rg process (call kill on the child and remove listeners),
rejects the promise with a clear timeout error message including rg args/path,
and clears the timer on normal close or error to avoid leaks; ensure the timeout
is cleared in the 'close' and 'error' handlers and handle the case where kill
itself errors so the promise always resolves/rejects exactly once.


// Extract directories and files from the file paths
const directories = new Set<string>();
const fileSet = new Set<string>();

for (const filePath of files) {
// Get relative path from the root
const relativePath = path.relative(validPath, filePath);
if (!relativePath || relativePath.startsWith('..')) continue;

const parts = relativePath.split(path.sep);

// Add all parent directories up to the requested depth
for (let i = 1; i < parts.length && i <= depth; i++) {
const dirPath = parts.slice(0, i).join(path.sep);
directories.add(dirPath);
}

// Add the file itself only if it's within the requested depth
if (parts.length <= depth) {
fileSet.add(relativePath);
}
}
Comment on lines +963 to +980

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Empty directories are omitted (ripgrep --files lists files only)

Deriving directories solely from files misses directories that contain no files (or only subdirs with no files within the scanned depth). list_directory should show those too; current output will be blank in such cases.

Minimal approach: supplement the directories set with a shallow Node.js scan up to depth for dir entries not implied by files.

     for (const filePath of files) {
       ...
     }
 
-    // Build the result list
+    // Supplement: include directories that contain no files within the scanned depth
+    await supplementMissingDirectories(validPath, depth, directories, includeHidden, respectGitignore);
+
+    // Build the result list

Add this helper in the same module:

async function supplementMissingDirectories(
  root: string,
  maxDepth: number,
  directories: Set<string>,
  includeHidden: boolean,
  respectGitignore: boolean
) {
  const queue: Array<{abs: string; rel: string; d: number}> = [{abs: root, rel: '', d: 0}];
  const gitignoreFilters = new Set<string>(); // Optional: populate from ripgrep/globs if needed

  while (queue.length) {
    const {abs, rel, d} = queue.shift()!;
    if (d >= maxDepth) continue;

    let entries: import('fs').Dirent[];
    try {
      entries = await (await import('fs/promises')).readdir(abs, { withFileTypes: true });
    } catch {
      continue;
    }

    for (const e of entries) {
      const isHidden = e.name.startsWith('.');
      if (!includeHidden && isHidden) continue;

      const childRel = rel ? path.join(rel, e.name) : e.name;
      const childAbs = path.join(abs, e.name);

      if (e.isDirectory()) {
        // Optionally skip gitignored directories when respectGitignore is true (left as enhancement)
        directories.add(childRel);
        queue.push({abs: childAbs, rel: childRel, d: d + 1});
      }
    }
  }
}

Note: If strict gitignore parity is required for these empty dirs, wire in ignore parsing; otherwise this at least prevents vanishing empty trees.


// Build the result list
const results: string[] = [];

async function listRecursive(currentPath: string, currentDepth: number, relativePath: string = ''): Promise<void> {
if (currentDepth <= 0) return;

let entries;
try {
entries = await fs.readdir(currentPath, { withFileTypes: true });
} catch (error) {
// If we can't read this directory (permission denied), show as denied
const displayPath = relativePath || path.basename(currentPath);
results.push(`[DENIED] ${displayPath}`);
return;
const allPaths = new Map<string, 'dir' | 'file'>();

// Add all directories
for (const dir of directories) {
allPaths.set(dir, 'dir');
}

// Add all files
for (const file of fileSet) {
allPaths.set(file, 'file');
}

// Group paths by their parent directory
const MAX_ITEMS_PER_DIR = 100;
const pathsByParent = new Map<string, string[]>();

for (const [pathStr, type] of allPaths) {
const parts = pathStr.split(path.sep);
const parentPath = parts.length > 1 ? parts.slice(0, -1).join(path.sep) : '';

if (!pathsByParent.has(parentPath)) {
pathsByParent.set(parentPath, []);
}

for (const entry of entries) {
const fullPath = path.join(currentPath, entry.name);
const displayPath = relativePath ? path.join(relativePath, entry.name) : entry.name;

// Add this entry to results
results.push(`${entry.isDirectory() ? "[DIR]" : "[FILE]"} ${displayPath}`);

// If it's a directory and we have depth remaining, recurse
if (entry.isDirectory() && currentDepth > 1) {
try {
// Validate the path before recursing
await validatePath(fullPath);
await listRecursive(fullPath, currentDepth - 1, displayPath);
} catch (error) {
// If validation fails or we can't access it, it will be marked as denied
// when we try to read it in the recursive call
continue;
}
pathsByParent.get(parentPath)!.push(pathStr);
}

// Sort children within each parent directory alphabetically
for (const children of pathsByParent.values()) {
children.sort((a, b) => a.localeCompare(b));
}

// Build a tree structure and output in hierarchical order
const outputPath = (pathStr: string, depth: number = 0) => {
const type = allPaths.get(pathStr);
if (!type) return;

const prefix = type === 'dir' ? '[DIR]' : '[FILE]';
const indent = ' '.repeat(depth);
results.push(`${indent}${prefix} ${pathStr}`);

// Get children of this directory
const children = pathsByParent.get(pathStr) || [];

// Check if this directory should be trimmed
if (children.length > MAX_ITEMS_PER_DIR) {
const hiddenCount = children.length - MAX_ITEMS_PER_DIR;
results.push(`${indent} ⚠️ ${pathStr}/ contains ${children.length} items (${hiddenCount} hidden). List this directory directly to see all.`);

// Output only first MAX_ITEMS_PER_DIR children
for (let i = 0; i < MAX_ITEMS_PER_DIR; i++) {
outputPath(children[i], depth);
}
} else {
// Output all children
for (const child of children) {
outputPath(child, depth);
}
}
Comment on lines +1016 to 1041

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Hierarchy rendering bug: indentation never increases (param shadowing and wrong recursion)

outputPath uses a parameter named depth that shadows the outer depth, and recursion calls pass the same value, so all lines render at the same indent. This breaks the “hierarchical listing” claim.

Apply this diff to fix indentation and avoid shadowing:

-    const outputPath = (pathStr: string, depth: number = 0) => {
-        const type = allPaths.get(pathStr);
+    const outputPath = (pathStr: string, level: number = 0) => {
+        const type = allPaths.get(pathStr);
         if (!type) return;
-        
-        const prefix = type === 'dir' ? '[DIR]' : '[FILE]';
-        const indent = '  '.repeat(depth);
+        const prefix = type === 'dir' ? '[DIR]' : '[FILE]';
+        const indent = '  '.repeat(level);
         results.push(`${indent}${prefix} ${pathStr}`);
         
         // Get children of this directory
         const children = pathsByParent.get(pathStr) || [];
         
         // Check if this directory should be trimmed
         if (children.length > MAX_ITEMS_PER_DIR) {
             const hiddenCount = children.length - MAX_ITEMS_PER_DIR;
             results.push(`${indent}    ⚠️ ${pathStr}/ contains ${children.length} items (${hiddenCount} hidden). List this directory directly to see all.`);
             
             // Output only first MAX_ITEMS_PER_DIR children
             for (let i = 0; i < MAX_ITEMS_PER_DIR; i++) {
-                outputPath(children[i], depth);
+                outputPath(children[i], level + 1);
             }
         } else {
             // Output all children
             for (const child of children) {
-                outputPath(child, depth);
+                outputPath(child, level + 1);
             }
         }
     };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const outputPath = (pathStr: string, depth: number = 0) => {
const type = allPaths.get(pathStr);
if (!type) return;
const prefix = type === 'dir' ? '[DIR]' : '[FILE]';
const indent = ' '.repeat(depth);
results.push(`${indent}${prefix} ${pathStr}`);
// Get children of this directory
const children = pathsByParent.get(pathStr) || [];
// Check if this directory should be trimmed
if (children.length > MAX_ITEMS_PER_DIR) {
const hiddenCount = children.length - MAX_ITEMS_PER_DIR;
results.push(`${indent} ⚠️ ${pathStr}/ contains ${children.length} items (${hiddenCount} hidden). List this directory directly to see all.`);
// Output only first MAX_ITEMS_PER_DIR children
for (let i = 0; i < MAX_ITEMS_PER_DIR; i++) {
outputPath(children[i], depth);
}
} else {
// Output all children
for (const child of children) {
outputPath(child, depth);
}
}
const outputPath = (pathStr: string, level: number = 0) => {
const type = allPaths.get(pathStr);
if (!type) return;
const prefix = type === 'dir' ? '[DIR]' : '[FILE]';
const indent = ' '.repeat(level);
results.push(`${indent}${prefix} ${pathStr}`);
// Get children of this directory
const children = pathsByParent.get(pathStr) || [];
// Check if this directory should be trimmed
if (children.length > MAX_ITEMS_PER_DIR) {
const hiddenCount = children.length - MAX_ITEMS_PER_DIR;
results.push(`${indent} ⚠️ ${pathStr}/ contains ${children.length} items (${hiddenCount} hidden). List this directory directly to see all.`);
// Output only first MAX_ITEMS_PER_DIR children
for (let i = 0; i < MAX_ITEMS_PER_DIR; i++) {
outputPath(children[i], level + 1);
}
} else {
// Output all children
for (const child of children) {
outputPath(child, level + 1);
}
}
};
🤖 Prompt for AI Agents
In src/tools/filesystem.ts around lines 1016 to 1041 the depth parameter is
being shadowed/kept constant so indentation never increases; fix by renaming the
parameter (or an inner variable) to avoid shadowing and pass an incremented
depth when recursing into children (e.g., outputPath(child, depth + 1)) both in
the trimmed branch and the else branch so children render with increased
indentation; also ensure any indent computation uses the (renamed) current depth
variable.

};

// Start with root-level items (those with no parent path)
const rootItems = pathsByParent.get('') || [];
for (const item of rootItems) {
outputPath(item, 0);
}

await listRecursive(validPath, depth);

return results;
}

Expand Down
2 changes: 2 additions & 0 deletions src/tools/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ export const CreateDirectoryArgsSchema = z.object({
export const ListDirectoryArgsSchema = z.object({
path: z.string(),
depth: z.number().optional().default(2),
respectGitignore: z.boolean().optional().default(true),
includeHidden: z.boolean().optional().default(false),
});

export const MoveFileArgsSchema = z.object({
Expand Down
2 changes: 1 addition & 1 deletion src/utils/toolHistory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ class ToolHistory {
// Keep only last 1000 entries
this.history = records.slice(-this.MAX_ENTRIES);
console.error(`[ToolHistory] Loaded ${this.history.length} entries from disk`);

// If file is getting too large, trim it
if (lines.length > this.MAX_ENTRIES * 2) {
this.trimHistoryFile();
Expand Down