From 16ea0c8ff7862927df3e4f572fc12e1ef8be80fa Mon Sep 17 00:00:00 2001 From: betegon Date: Thu, 20 Aug 2026 20:52:14 +0200 Subject: [PATCH 1/3] feat(init): list_dir returns file size and a binary hint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pairs with the server bash-like file-tool redesign (cli-init-api#243). list_dir now attaches size (statSync) and an advisory isBinary (NUL sniff of the first bytes) to each file entry, so the agent can skip huge/binary files without reading them — the ls -l data it was missing. Additive + backward-safe: DirEntry gains two optional fields the server's dirEntrySchema already accepts. Co-Authored-By: Claude Opus 4.8 --- packages/cli/src/lib/init/tools/list-dir.ts | 40 ++++++++++++++++++++- packages/cli/src/lib/init/types.ts | 5 +++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/lib/init/tools/list-dir.ts b/packages/cli/src/lib/init/tools/list-dir.ts index 85f00f5d59..4321ab90e2 100644 --- a/packages/cli/src/lib/init/tools/list-dir.ts +++ b/packages/cli/src/lib/init/tools/list-dir.ts @@ -93,13 +93,51 @@ function toDirEntry( } } + if (entry.isDirectory()) { + return { + name: entry.name, + path: normalizePath(relNative), + type: "directory", + }; + } + + // File: attach size + a best-effort binary hint so the agent can decide what + // to read (like `ls -l`) without reading it first. Both are advisory. return { name: entry.name, path: normalizePath(relNative), - type: entry.isDirectory() ? "directory" : "file", + type: "file", + ...fileHints(abs), }; } +/** Cheap, best-effort size + binary hint for a file. Never throws. */ +function fileHints(abs: string): { size?: number; isBinary?: boolean } { + try { + const size = fs.statSync(abs).size; + return { isBinary: looksBinary(abs), size }; + } catch { + return {}; + } +} + +/** True if the first bytes contain a NUL — a good-enough binary sniff. */ +function looksBinary(abs: string): boolean { + let fd: number | undefined; + try { + fd = fs.openSync(abs, "r"); + const buf = Buffer.alloc(512); + const read = fs.readSync(fd, buf, 0, buf.length, 0); + return buf.subarray(0, read).includes(0); + } catch { + return false; + } finally { + if (fd !== undefined) { + fs.closeSync(fd); + } + } +} + async function walkDirectory( dir: string, depth: number, diff --git a/packages/cli/src/lib/init/types.ts b/packages/cli/src/lib/init/types.ts index e84e539290..7b3f6c62d4 100644 --- a/packages/cli/src/lib/init/types.ts +++ b/packages/cli/src/lib/init/types.ts @@ -2,6 +2,11 @@ export type DirEntry = { name: string; path: string; type: "file" | "directory"; + /** File size in bytes; omitted for directories. Lets the agent skip huge + * files without reading them, like `ls -l`. */ + size?: number; + /** Best-effort binary hint (NUL byte in the first bytes); advisory only. */ + isBinary?: boolean; }; export type ExistingProjectData = { From a8fad919c8579ba8c1dc31e4107f88ea90825b1e Mon Sep 17 00:00:00 2001 From: betegon Date: Thu, 20 Aug 2026 21:22:57 +0200 Subject: [PATCH 2/3] fix(init): list_dir hints only for regular files, opened non-blocking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Cursor Bugbot on #1446: looksBinary opened every non-directory entry with a blocking openSync and no regular-file check, so a FIFO in the tree could hang list_dir (and sentry init); it also attached size/isBinary:false to symlinks and other special files. Now only entry.isFile() regular files get hints, and the read opens with O_RDONLY|O_NONBLOCK + re-checks fstat().isFile() before reading — the same guard read-files uses. Verified: a named pipe no longer blocks the walk and carries no hints; regular files still get size. Co-Authored-By: Claude Opus 4.8 --- packages/cli/src/lib/init/tools/list-dir.ts | 35 +++++++++++---------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/packages/cli/src/lib/init/tools/list-dir.ts b/packages/cli/src/lib/init/tools/list-dir.ts index 4321ab90e2..0a9ac7ea10 100644 --- a/packages/cli/src/lib/init/tools/list-dir.ts +++ b/packages/cli/src/lib/init/tools/list-dir.ts @@ -101,36 +101,37 @@ function toDirEntry( }; } - // File: attach size + a best-effort binary hint so the agent can decide what - // to read (like `ls -l`) without reading it first. Both are advisory. + // Only regular files carry size + a binary hint (like `ls -l`). Symlinks, + // FIFOs, sockets, and devices are listed as files but get no hints and are + // never opened — a named pipe must not be able to block the walk. return { name: entry.name, path: normalizePath(relNative), type: "file", - ...fileHints(abs), + ...(entry.isFile() ? fileHints(abs) : {}), }; } -/** Cheap, best-effort size + binary hint for a file. Never throws. */ +/** + * Best-effort size + binary hint for a regular file. Opens non-blocking (so a + * special file that slips past the Dirent check can't hang) and re-confirms it + * is a regular file before reading — the same guard read-files uses. Advisory; + * never blocks, never throws. + */ function fileHints(abs: string): { size?: number; isBinary?: boolean } { - try { - const size = fs.statSync(abs).size; - return { isBinary: looksBinary(abs), size }; - } catch { - return {}; - } -} - -/** True if the first bytes contain a NUL — a good-enough binary sniff. */ -function looksBinary(abs: string): boolean { let fd: number | undefined; try { - fd = fs.openSync(abs, "r"); + // biome-ignore lint/suspicious/noBitwiseOperators: fs open flags are a bitmask. + fd = fs.openSync(abs, fs.constants.O_RDONLY | fs.constants.O_NONBLOCK); + const stat = fs.fstatSync(fd); + if (!stat.isFile()) { + return {}; + } const buf = Buffer.alloc(512); const read = fs.readSync(fd, buf, 0, buf.length, 0); - return buf.subarray(0, read).includes(0); + return { isBinary: buf.subarray(0, read).includes(0), size: stat.size }; } catch { - return false; + return {}; } finally { if (fd !== undefined) { fs.closeSync(fd); From 819a2e0c868960d5be213b45aa6b9cde88e637eb Mon Sep 17 00:00:00 2001 From: betegon Date: Thu, 20 Aug 2026 21:51:59 +0200 Subject: [PATCH 3/3] refactor(init): simplify directory listing metadata --- packages/cli/src/lib/init/tools/list-dir.ts | 62 +++++------ packages/cli/src/lib/init/types.ts | 2 - .../lib/init/tools/filesystem-tools.test.ts | 100 ++++++++++++++++++ 3 files changed, 132 insertions(+), 32 deletions(-) diff --git a/packages/cli/src/lib/init/tools/list-dir.ts b/packages/cli/src/lib/init/tools/list-dir.ts index 0a9ac7ea10..dbdcf77ff5 100644 --- a/packages/cli/src/lib/init/tools/list-dir.ts +++ b/packages/cli/src/lib/init/tools/list-dir.ts @@ -36,10 +36,17 @@ export async function listDir(payload: ListDirPayload): Promise { maxDepth, maxEntries, recursive, + truncated: false, }; await walkDirectory(targetPath, 0, state); - return { ok: true, data: { entries: state.entries } }; + return { + ok: true, + data: { + entries: state.entries, + ...(state.truncated ? { truncated: true } : {}), + }, + }; } type WalkState = { @@ -49,13 +56,14 @@ type WalkState = { maxDepth: number; maxEntries: number; recursive: boolean; + truncated: boolean; }; -async function readDirEntries(dir: string): Promise { +async function readDirEntries(dir: string): Promise { try { return await fs.promises.readdir(dir, { withFileTypes: true }); } catch { - return []; + return; } } @@ -101,41 +109,23 @@ function toDirEntry( }; } - // Only regular files carry size + a binary hint (like `ls -l`). Symlinks, - // FIFOs, sockets, and devices are listed as files but get no hints and are - // never opened — a named pipe must not be able to block the walk. + // Only regular files carry size. lstat avoids following a path that changed + // into a symlink after readdir; special files are never opened. return { name: entry.name, path: normalizePath(relNative), type: "file", - ...(entry.isFile() ? fileHints(abs) : {}), + ...(entry.isFile() ? fileSize(abs) : {}), }; } -/** - * Best-effort size + binary hint for a regular file. Opens non-blocking (so a - * special file that slips past the Dirent check can't hang) and re-confirms it - * is a regular file before reading — the same guard read-files uses. Advisory; - * never blocks, never throws. - */ -function fileHints(abs: string): { size?: number; isBinary?: boolean } { - let fd: number | undefined; +/** Return a regular file's byte size without opening or reading its contents. */ +function fileSize(abs: string): { size?: number } { try { - // biome-ignore lint/suspicious/noBitwiseOperators: fs open flags are a bitmask. - fd = fs.openSync(abs, fs.constants.O_RDONLY | fs.constants.O_NONBLOCK); - const stat = fs.fstatSync(fd); - if (!stat.isFile()) { - return {}; - } - const buf = Buffer.alloc(512); - const read = fs.readSync(fd, buf, 0, buf.length, 0); - return { isBinary: buf.subarray(0, read).includes(0), size: stat.size }; + const stat = fs.lstatSync(abs); + return stat.isFile() ? { size: stat.size } : {}; } catch { return {}; - } finally { - if (fd !== undefined) { - fs.closeSync(fd); - } } } @@ -145,11 +135,19 @@ async function walkDirectory( state: WalkState ): Promise { if (depth > state.maxDepth || state.entries.length >= state.maxEntries) { + state.truncated = true; + return; + } + + const entries = await readDirEntries(dir); + if (!entries) { + state.truncated = true; return; } - for (const entry of await readDirEntries(dir)) { + for (const entry of entries) { if (state.entries.length >= state.maxEntries) { + state.truncated = true; return; } const nextEntry = toDirEntry(state, dir, entry); @@ -158,7 +156,11 @@ async function walkDirectory( } state.entries.push(nextEntry); if (shouldRecurseInto(entry, state)) { - await walkDirectory(dir + NATIVE_SEP + entry.name, depth + 1, state); + if (depth >= state.maxDepth) { + state.truncated = true; + } else { + await walkDirectory(dir + NATIVE_SEP + entry.name, depth + 1, state); + } } } } diff --git a/packages/cli/src/lib/init/types.ts b/packages/cli/src/lib/init/types.ts index 7b3f6c62d4..f3c46734ff 100644 --- a/packages/cli/src/lib/init/types.ts +++ b/packages/cli/src/lib/init/types.ts @@ -5,8 +5,6 @@ export type DirEntry = { /** File size in bytes; omitted for directories. Lets the agent skip huge * files without reading them, like `ls -l`. */ size?: number; - /** Best-effort binary hint (NUL byte in the first bytes); advisory only. */ - isBinary?: boolean; }; export type ExistingProjectData = { diff --git a/packages/cli/test/lib/init/tools/filesystem-tools.test.ts b/packages/cli/test/lib/init/tools/filesystem-tools.test.ts index 4142b2daf1..ec122acd70 100644 --- a/packages/cli/test/lib/init/tools/filesystem-tools.test.ts +++ b/packages/cli/test/lib/init/tools/filesystem-tools.test.ts @@ -1,3 +1,4 @@ +import { execFileSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; @@ -72,9 +73,108 @@ describe("filesystem tools", () => { expect(result.ok).toBe(true); expect(entries.map((entry) => entry.path)).toContain("src/app.ts"); + expect(entries.find((entry) => entry.path === "src/app.ts")).toMatchObject({ + size: 18, + type: "file", + }); expect(precomputed.map((entry) => entry.path)).toContain("src/app.ts"); }); + test("reports when a bounded recursive listing is incomplete", async () => { + fs.mkdirSync(path.join(testDir, "nested", "deeper"), { recursive: true }); + fs.writeFileSync(path.join(testDir, "nested", "deeper", "app.ts"), "x"); + fs.writeFileSync(path.join(testDir, "root.ts"), "x"); + + const byDepth = await executeTool( + { + type: "tool", + operation: "list-dir", + cwd: testDir, + params: { path: ".", recursive: true, maxDepth: 0, maxEntries: 100 }, + }, + makeContext(testDir) + ); + const byEntries = await executeTool( + { + type: "tool", + operation: "list-dir", + cwd: testDir, + params: { path: ".", recursive: true, maxDepth: 10, maxEntries: 1 }, + }, + makeContext(testDir) + ); + + expect(byDepth.data).toMatchObject({ truncated: true }); + expect(byEntries.data).toMatchObject({ truncated: true }); + expect((byEntries.data as { entries: unknown[] }).entries).toHaveLength(1); + }); + + test("reports an unreadable directory as an incomplete listing", async () => { + const readdirSpy = vi + .spyOn(fs.promises, "readdir") + .mockRejectedValueOnce(new Error("permission denied")); + + try { + const result = await executeTool( + { + type: "tool", + operation: "list-dir", + cwd: testDir, + params: { path: ".", recursive: true }, + }, + makeContext(testDir) + ); + + expect(result).toMatchObject({ + data: { entries: [], truncated: true }, + ok: true, + }); + } finally { + readdirSpy.mockRestore(); + } + }); + + test.runIf(process.platform !== "win32")( + "lists special files without opening them or attaching a size", + async () => { + const fifoPath = path.join(testDir, "stream.pipe"); + execFileSync("mkfifo", [fifoPath]); + fs.writeFileSync(path.join(testDir, "regular.ts"), "export {};\n"); + const openSpy = vi.spyOn(fs, "openSync"); + const readSpy = vi.spyOn(fs, "readSync"); + + try { + const result = await executeTool( + { + type: "tool", + operation: "list-dir", + cwd: testDir, + params: { path: "." }, + }, + makeContext(testDir) + ); + const entry = ( + result.data as { entries: Record[] } + ).entries.find(({ path: entryPath }) => entryPath === "stream.pipe"); + const regular = ( + result.data as { entries: Record[] } + ).entries.find(({ path: entryPath }) => entryPath === "regular.ts"); + + expect(entry).toEqual({ + name: "stream.pipe", + path: "stream.pipe", + type: "file", + }); + expect(regular).toMatchObject({ size: 11, type: "file" }); + expect(openSpy).not.toHaveBeenCalled(); + expect(readSpy).not.toHaveBeenCalled(); + } finally { + openSpy.mockRestore(); + readSpy.mockRestore(); + } + } + ); + test("reads files and checks existence in batches", async () => { fs.writeFileSync(path.join(testDir, "exists.txt"), "hello");