Skip to content

List

A list tool lets the model inspect filesystem structure without opening file contents.

It is the lowest-cost discovery primitive in the tool stack:

  • identify candidate files before read
  • inspect directory shape before glob
  • confirm rename/move results after edits
  • build an internal map of a repo without paying content-token costs

In real agent workflows, list is often the first tool called after a user drops into a new directory. Everything after it (grep, mapping, edit planning, “what files exist?”) depends on the list tool being correct and predictable.

A production list tool has to balance competing constraints:

  • completeness vs token budget (huge trees can flood context)
  • speed vs correctness (deep recursive scans can be slow)
  • safety vs utility (paths must stay inside allowed roots)
  • determinism vs platform differences (sorting, separators, hidden files)
  • semantics (VCS-tracked listing vs filesystem listing are different products)

If listing is noisy or incomplete, the agent will “plan on ghosts”: it will believe files exist or do not exist based on truncated or filtered output.

A list tool is “good enough” when it:

  • returns deterministic ordering for stable reasoning
  • indicates truncation explicitly
  • exposes a way to page/cursor
  • has clear ignore rules
  • has a clear safety boundary for what roots can be listed
  • is cheap enough to call frequently

  • Glob and Grep for narrowing candidate paths after listing.
  • File Read for content retrieval after discovery.
  • Repo Mapping for structural summaries when a raw listing is not enough.

Aider at commit b9050e1d5faf8096eae7a46a9ecc05a86231384b does not expose a model function-call list tool.

Listing exists as a slash command (/ls) executed by the host program, not as a tool schema the model calls.

Aider implements listing in references/aider/aider/commands.py:

  • Commands.cmd_ls(self, args)
  • invoked from Commands.run() after command parsing

Core behavior (abridged) from cmd_ls:

def cmd_ls(self, args):
files = self.coder.get_all_relative_files()
other_files = []
chat_files = []
read_only_files = []
for file in files:
abs_file_path = self.coder.abs_root_path(file)
if abs_file_path in self.coder.abs_fnames:
chat_files.append(file)
else:
other_files.append(file)
for abs_file_path in self.coder.abs_read_only_fnames:
rel_file_path = self.coder.get_rel_fname(abs_file_path)
read_only_files.append(rel_file_path)
...

The key insight: Aider’s list is primarily a view of “files Aider knows about” rather than an arbitrary filesystem directory walk.

The list of “known files” comes from Coder.get_all_relative_files() in references/aider/aider/coders/base_coder.py:

def get_all_relative_files(self):
if self.repo:
files = self.repo.get_tracked_files()
else:
files = self.get_inchat_relative_files()
return sorted(set(files))

If Aider is in a git repo, listing is derived from repo.get_tracked_files().

This is not a directory scan. It is a git-tree traversal plus staged-file augmentation.

GitRepo.get_tracked_files() in references/aider/aider/repo.py:

  • tries self.repo.head.commit
  • traverses commit.tree.traverse() to collect blobs (files)
  • caches results per commit in self.tree_files[commit]
  • adds staged files from self.repo.index.entries
  • normalizes paths via normalize_path()
  • applies Aider-level ignore rules via ignored_file()

Important behaviors that impact correctness:

  • if HEAD cannot be resolved (empty repo), traversal path is skipped and listing relies on staged files and other sources
  • caching is commit-based: changing branches changes the key; changing working-tree files does not
  • path normalization uses PurePosixPath logic so output paths are forward-slash normalized

Aider applies its own ignore layer in GitRepo.ignored_file() and ignored_file_raw().

Mechanics (from references/aider/aider/repo.py):

  • refresh_aider_ignore() checks the .aiderignore file at most once per second
  • on mtime change, it rebuilds a pathspec.PathSpec using GitWildMatchPattern
  • ignored_file() caches per-filename results in ignore_file_cache

Subtree mode:

  • when subtree_only is enabled, Aider rejects files outside the current working subtree
  • this check happens before .aiderignore matching and can drop paths even if tracked

Aider’s /ls is not just a raw list; it is a session-centric view split into:

  • “Repo files not in the chat” (tracked but not in the current chat context)
  • “Read-only files” (abs_read_only_fnames)
  • “Files in chat” (present in abs_fnames)

This is a useful UX for interactive editing, because it answers “what does the model currently see?”

Because listing is not tool-callable:

  • there is no structured, model-visible list schema
  • there is no stable pagination contract
  • the model cannot ask for “depth=2” or “offset=51”
  • it is not safe to treat /ls output as a complete directory enumeration

Aider’s list is a VCS-centric file inventory, designed for chat session management.


Codex at commit 4ab44e2c5cc54ed47e47a6729dfd8aa5a3dc2476 exposes a dedicated tool named list_dir.

Codex’s list tool is a real model-callable function tool with a typed argument schema and deterministic formatting.

Tool spec is defined in references/codex/codex-rs/core/src/tools/spec.rs as create_list_dir_tool().

Key properties:

  • tool name: list_dir
  • required parameter: dir_path
  • optional parameters: offset, limit, depth
  • semantics: 1-indexed entry numbering

list_dir is not always enabled. It is registered when experimental_supported_tools contains "list_dir".

In the tool builder section (same file), Codex registers the spec and handler:

  • builder.push_spec_with_parallel_support(create_list_dir_tool(), true);
  • builder.register_handler("list_dir", list_dir_handler);

This also explicitly marks list_dir as safe for parallel tool calls.

The handler is ListDirHandler in references/codex/codex-rs/core/src/tools/handlers/list_dir.rs.

Arguments:

  • dir_path: String
  • offset: usize default 1
  • limit: usize default 25
  • depth: usize default 2

Validation rules enforced in code:

  • offset must be >= 1 (rejects 0)
  • limit must be > 0
  • depth must be > 0
  • dir_path must be an absolute path

Errors are returned as FunctionCallError::RespondToModel(...) with user-visible text.

Codex’s directory walk is implemented in list_dir_slice() and collect_entries().

Core steps:

  1. collect_entries(path, "", depth, entries) builds a flat Vec<DirEntry>
  2. entries.sort_unstable_by(|a, b| a.name.cmp(&b.name)) sorts by normalized path string
  3. slice selection uses 1-indexed offset and bounded limit
  4. format selected entries with indentation derived from prefix depth
  5. append a truncation line if there are more entries

There are two separate sorts:

  • within each directory level, the handler sorts entries before pushing them into the global list
  • it also sorts the global list by DirEntry.name (a normalized relative path string)

This yields stable tree-like ordering such as:

  • nested/
  • child.txt
  • deeper/
  • grandchild.txt

The tool output begins with a header:

  • Absolute path: <dir_path>

Then each entry is printed as a single line.

Formatting details:

  • indentation: 2 spaces per directory depth
  • directory entries have / suffix
  • symlinks have @ suffix
  • other/non-file/non-dir entries have ? suffix
  • filenames and paths are truncated to 500 characters (MAX_ENTRY_LENGTH) at character boundaries
  • Windows backslashes are normalized to / in the internal sort/print name

Truncation line:

  • if selection ends before the full list ends, it appends More than {capped_limit} entries found

Important nuance:

  • capped_limit is the number returned in the page, not the total entry count

depth controls traversal depth:

  • depth=1 lists only immediate children
  • depth=2 includes children of subdirectories
  • traversal uses a queue and only traverses entries where kind == Directory && remaining_depth > 1

Symlink behavior:

  • symlink entries are printed with @
  • symlinks are not traversed because only DirEntryKind::Directory is recursed

This avoids the worst class of symlink loops by default.

references/codex/codex-rs/core/src/tools/handlers/list_dir.rs includes unit tests that verify:

  • directories, files, and (on Unix) symlinks are rendered correctly
  • offset out of bounds returns a model-visible error
  • depth boundaries produce expected entry sets
  • pagination preserves sorted order
  • large limits and truncation behavior

The tests are valuable documentation: they encode the formatting and ordering contract.

Practical characteristics and tradeoffs:

  • the handler collects all entries up to depth before slicing; large trees can still be expensive even with small limit
  • output is plain text; there is no structured return type with per-entry metadata (size, mtime)
  • absolute paths are required; this pushes path resolution responsibility to the model/host

OpenCode at commit 7ed449974864361bad2c1f1405769fd2c2fcdf42 implements a tool named list in references/opencode/packages/opencode/src/tool/ls.ts.

The implementation is intentionally shaped around ripgrep’s fast file enumeration.

list is built using Tool.define("list", ...) and discovered like other built-in tools.

The registry pipeline in references/opencode/packages/opencode/src/tool/registry.ts:

  • ToolRegistry.all() builds a list of built-in tools plus custom/plugin tools
  • ToolRegistry.tools(model, agent?) filters tool exposure per provider and model
  • tool definitions are exposed to the model via { description, parameters }
  • plugins can mutate tool definitions via Plugin.trigger("tool.definition", ...)

The list tool is always present in all() (not feature-gated), unlike OpenCode’s commented-out TodoReadTool example.

OpenCode uses two relevant gates:

  1. assertExternalDirectory(ctx, searchPath, { kind: "directory" })
  • if searchPath is outside the active instance directory, it asks permission external_directory for parentDir/*
  • this is an “escape hatch” guardrail so tools don’t silently traverse arbitrary filesystem roots
  1. ctx.ask({ permission: "list", patterns: [searchPath], ... })
  • explicit permission for listing the target directory

So listing a directory outside the project can require two separate approvals.

The list tool does not call Node’s fs.readdir recursively.

Instead it uses Ripgrep.files:

  • spawns rg --files --glob=!.git/*
  • defaults to --hidden unless hidden: false
  • supports additional --glob=... filters
  • yields each file path as a streaming async iterator

ListTool constructs ignore globs by turning patterns into --glob=!<pattern> forms.

Defaults:

  • IGNORE_PATTERNS includes node_modules/, .git/, dist/, target/, and many more
  • LIMIT is 100 paths

The loop is explicitly capped:

  • it breaks once it has collected 100 files

Then it builds a directory structure from returned file paths:

  • collects dirs by adding all parents of each file
  • groups file basenames by directory path (filesByDir)
  • renders a tree with renderDir(dirPath, depth)

Output:

  • begins with <searchPath>/
  • then renders subdirectories (alphabetical) then files (alphabetical)

Metadata:

  • count: number of files collected (<= 100)
  • truncated: true if files.length >= LIMIT

OpenCode renders tool execution uniformly:

  • CLI run mode prints tool title/output
  • TUI renders tool blocks in the session timeline

list returns both output and metadata, enabling UI to show “truncated” hints and counts.


Aider’s /ls is not a filesystem directory list; it is a git-tracked inventory plus staged files.

Codex/OpenCode list actual directory contents.

If OpenOxide wants compatibility with all three mental models, it must name the semantics explicitly.

OpenCode caps at 100 files. Codex paginates but still has traversal cost. Aider lists tracked files and can omit untracked paths entirely.

All three can produce incomplete views.

A correct list tool must:

  • indicate truncation in the output and in structured metadata
  • expose a paging/cursor mechanism

Ignoring node_modules/ and .git/ is usually correct, but:

  • monorepos sometimes keep source-of-truth artifacts under vendor/ or third_party/
  • “dist/” and “build/” sometimes contain generated sources worth inspecting
  • ignoring hidden files by default can hide important config (.env.example, .tool-versions, .cargo/config.toml)

OpenOxide should treat ignore policy as configurable, not hard-coded.

Codex avoids traversing symlinks by only recursing FileType::is_dir() entries.

If OpenOxide traverses symlinks, it must:

  • track visited inode/device pairs
  • enforce maximum traversal depth
  • enforce timeouts and cancellation

Non-deterministic order makes the model hallucinate changes.

Sorting by normalized forward-slash paths is a simple and robust choice.

Pagination Without Stable Cursor Is Painful

Section titled “Pagination Without Stable Cursor Is Painful”

Codex uses numeric 1-indexed offset. That works but can be brittle if directory contents change between calls.

A cursor-based design with stable ordering plus a “snapshot id” avoids this class of drift.


Expose list_dir as a first-class tool with both human-readable output and structured payload.

Inputs:

  • dir_path: AbsolutePath
  • depth: u32 (default small; hard max)
  • offset: u32 (1-indexed) or cursor: String
  • limit: u32 (bounded max)
  • include_hidden: bool (default false)
  • follow_symlinks: bool (default false)
  • ignore: IgnorePolicy (built-in defaults + user config)

Outputs:

  • entries[]: { relative_path, kind, depth }
  • truncated: bool
  • next_offset or next_cursor
  • snapshot_id (optional)

Keep a compact, stable text rendering for humans, but do not force the model to parse prose.

Implementation pipeline:

  • validate that dir_path is inside the sandbox root
  • walk directory with bounded recursion
  • do not traverse symlinks by default
  • normalize paths to / for output and sorting
  • sort globally by normalized path string
  • slice by offset/limit

Ensure the walker is cancellation-aware (token from turn cancellation).

  • separate permission policy for metadata-only listing from file-content reads
  • support allowlist/denylist patterns for list roots
  • avoid listing protected internal directories by default (.git/, tool state dirs)
  • short-circuit traversal when possible (if only shallow depth is requested)
  • consider caching “directory snapshots” per turn to support repeated pagination
  • keep per-entry metadata minimal (size/mtime optional behind flags)

Unit tests:

  • formatting: indentation, suffix markers
  • sorting determinism across platforms
  • depth semantics
  • truncation metadata
  • symlink loop safety

Integration tests:

  • list under sandbox constraints
  • permission-deny behavior and error surfaces
  • large tree performance with enforced limit
  • walkdir or ignore for traversal
  • tokio for async + cancellation
  • serde/schemars for schema
  • OpenOxide permission/session crates
  • make list semantics explicit (filesystem vs VCS inventory)
  • make truncation and paging explicit and machine-readable
  • keep default ignore policy configurable