# Built-In Tools: What Can the Agent Actually Do to a Filesystem?

> The coding agent ships six built-in tools: Read, Write, Edit, Bash, Grep/Find, and Ls. This page asks how each tool definition wraps the underlying operation (tool-definition-wrapper.ts), what file-mutation-queue.ts serializes to prevent concurrent edits, how bash.ts sandboxes commands, and what output-accumulator.ts does to keep large tool results from overflowing the context. The tools/ directory is the system's ground-level action surface.

- Repository: earendil-works/pi
- GitHub: https://github.com/earendil-works/pi
- Human wiki: https://grok-wiki.com/public/wiki/earendil-works-pi-8b87608fc234
- Complete Markdown: https://grok-wiki.com/public/wiki/earendil-works-pi-8b87608fc234/llms-full.txt

## Source Files

- `packages/coding-agent/src/core/tools/tool-definition-wrapper.ts`
- `packages/coding-agent/src/core/tools/file-mutation-queue.ts`
- `packages/coding-agent/src/core/tools/bash.ts`
- `packages/coding-agent/src/core/tools/edit.ts`
- `packages/coding-agent/src/core/tools/output-accumulator.ts`
- `packages/coding-agent/test/tools.test.ts`

---

<details>
<summary>Relevant source files</summary>
The following files were used as context for generating this wiki page:

- [packages/coding-agent/src/core/tools/tool-definition-wrapper.ts](packages/coding-agent/src/core/tools/tool-definition-wrapper.ts)
- [packages/coding-agent/src/core/tools/file-mutation-queue.ts](packages/coding-agent/src/core/tools/file-mutation-queue.ts)
- [packages/coding-agent/src/core/tools/bash.ts](packages/coding-agent/src/core/tools/bash.ts)
- [packages/coding-agent/src/core/tools/edit.ts](packages/coding-agent/src/core/tools/edit.ts)
- [packages/coding-agent/src/core/tools/output-accumulator.ts](packages/coding-agent/src/core/tools/output-accumulator.ts)
- [packages/coding-agent/src/core/tools/read.ts](packages/coding-agent/src/core/tools/read.ts)
- [packages/coding-agent/src/core/tools/write.ts](packages/coding-agent/src/core/tools/write.ts)
- [packages/coding-agent/src/core/tools/grep.ts](packages/coding-agent/src/core/tools/grep.ts)
- [packages/coding-agent/src/core/tools/find.ts](packages/coding-agent/src/core/tools/find.ts)
- [packages/coding-agent/src/core/tools/ls.ts](packages/coding-agent/src/core/tools/ls.ts)
- [packages/coding-agent/src/core/tools/truncate.ts](packages/coding-agent/src/core/tools/truncate.ts)
- [packages/coding-agent/test/tools.test.ts](packages/coding-agent/test/tools.test.ts)
</details>

# Built-In Tools: What Can the Agent Actually Do to a Filesystem?

The coding agent ships seven built-in tools that form its complete ground-level action surface on a filesystem: **Read**, **Write**, **Edit**, **Bash**, **Grep**, **Find**, and **Ls**. Each tool is defined via a `ToolDefinition` object and bridged into the core runtime through a thin wrapper. Beneath that surface lie two shared services that make concurrent use safe: a per-file serialization queue that prevents torn writes, and a streaming output accumulator that bounds memory consumption for long-running commands. Understanding these layers explains both what the agent *can* do and what it is *prevented* from doing accidentally.

This page traces each tool from its public interface down to the operating-system call it ultimately makes, examines the concurrency and truncation mechanisms that guard against data loss and context overflow, and surfaces the design assumptions encoded in each layer.

---

## The `ToolDefinition` → `AgentTool` Bridge

### What problem does a wrapper solve?

The agent runtime (`@earendil-works/pi-agent-core`) speaks `AgentTool`. The tools directory prefers a richer `ToolDefinition` that additionally carries prompt metadata, TUI render callbacks, and a `prepareArguments` hook for argument normalization. The wrapper erases those extras when handing control to the runtime.

### What the wrapper actually does

```ts
// packages/coding-agent/src/core/tools/tool-definition-wrapper.ts:5-18
export function wrapToolDefinition<TDetails = unknown>(
  definition: ToolDefinition<any, TDetails>,
  ctxFactory?: () => ExtensionContext,
): AgentTool<any, TDetails> {
  return {
    name: definition.name,
    label: definition.label,
    description: definition.description,
    parameters: definition.parameters,
    prepareArguments: definition.prepareArguments,
    executionMode: definition.executionMode,
    execute: (toolCallId, params, signal, onUpdate) =>
      definition.execute(toolCallId, params, signal, onUpdate, ctxFactory?.() as ExtensionContext),
  };
}
```

The bridge is deliberately minimal: it copies the five scalar fields that `AgentTool` needs, keeps `prepareArguments` for argument normalization, and injects an `ExtensionContext` when one is available. The render callbacks (`renderCall`, `renderResult`) stay inside `ToolDefinition` and are consumed only by the TUI layer.

An inverse path also exists: `createToolDefinitionFromAgentTool` synthesizes a minimal `ToolDefinition` from a plain `AgentTool`, keeping the registry definition-first even for caller-supplied overrides.

Sources: [packages/coding-agent/src/core/tools/tool-definition-wrapper.ts:5-45]()

---

## The `FileMutationQueue`: Serializing Concurrent Writes

### What would break without it?

Two simultaneous `edit` or `write` calls targeting the same file would race at the OS level: read-then-write sequences could interleave, leaving the file in a corrupt or partially updated state. The model can and does issue multiple tool calls in a single turn.

### How the queue works

```ts
// packages/coding-agent/src/core/tools/file-mutation-queue.ts:4-39
const fileMutationQueues = new Map<string, Promise<void>>();

export async function withFileMutationQueue<T>(filePath: string, fn: () => Promise<T>): Promise<T> {
  const key = getMutationQueueKey(filePath);
  const currentQueue = fileMutationQueues.get(key) ?? Promise.resolve();

  let releaseNext!: () => void;
  const nextQueue = new Promise<void>((resolveQueue) => { releaseNext = resolveQueue; });
  const chainedQueue = currentQueue.then(() => nextQueue);
  fileMutationQueues.set(key, chainedQueue);

  await currentQueue;
  try {
    return await fn();
  } finally {
    releaseNext();
    if (fileMutationQueues.get(key) === chainedQueue) {
      fileMutationQueues.delete(key);
    }
  }
}
```

The key insight is the promise-chaining pattern: each caller appends its own `nextQueue` promise to the tail of the existing chain and then awaits the *previous* head before executing. When it finishes it calls `releaseNext()`, unblocking the next waiter. The map entry is deleted when no further waiters exist, preventing unbounded growth.

The queue key is resolved via `realpathSync.native` to canonicalize symlinks—two paths that point to the same inode share one queue.

Operations on *different* files are fully parallel; only same-file mutations are serialized.

Sources: [packages/coding-agent/src/core/tools/file-mutation-queue.ts:1-39]()

---

## Read

### What it does

`createReadTool` reads a file, detects whether it is an image or text, and returns structured content blocks. For images (detected by magic bytes via `detectSupportedImageMimeTypeFromFile`, not by extension), it optionally resizes to 2000×2000 and returns a base64-encoded `image` block alongside a text note. For text, it applies `truncateHead` and returns a single `text` block with an actionable continuation notice.

### Key parameters

| Parameter | Default | Effect |
|-----------|---------|--------|
| `path`    | required | Resolved against `cwd` |
| `offset`  | 1 (line) | 1-indexed; error if beyond EOF |
| `limit`   | auto    | User-specified max lines; remainder noted in output |

Text output is capped at **2000 lines or 50 KB** (whichever hits first). When truncated, the model receives a message like:

```
[Showing lines 1-2000 of 2500. Use offset=2001 to continue.]
```

The `Read` tool does **not** use `withFileMutationQueue`—reads are not serialized because no state is mutated.

Sources: [packages/coding-agent/src/core/tools/read.ts:206-358](), [packages/coding-agent/src/core/tools/truncate.ts:78-160]()

---

## Write

### What it does

`createWriteTool` creates or fully overwrites a file. It first calls `ops.mkdir` with `{ recursive: true }` to create any missing parent directories, then writes the content as UTF-8.

Critically, it wraps the entire mkdir-plus-write sequence in `withFileMutationQueue`:

```ts
// packages/coding-agent/src/core/tools/write.ts:201-238
return withFileMutationQueue(absolutePath, () =>
  new Promise<...>((resolve, reject) => {
    ...
    await ops.mkdir(dir);
    await ops.writeFile(absolutePath, content);
    resolve({ content: [{ type: "text", text: `Successfully wrote ${content.length} bytes to ${path}` }], ... });
  })
);
```

There is no partial-write detection or rollback: if the process dies mid-write, the file is corrupt. The tool is intended for new files or complete rewrites—the `promptGuidelines` field in the definition explicitly states this.

Sources: [packages/coding-agent/src/core/tools/write.ts:181-280]()

---

## Edit

### What it does

`createEditTool` performs targeted in-place text replacement. Its schema accepts a `path` and an `edits` array, where each entry specifies an `oldText`/`newText` pair. All edits are matched against the *original* file in a single pass—not incrementally—so the agent cannot express overlapping edits.

The execution sequence:
1. Check access (`R_OK | W_OK`), surface `ENOENT`/`EACCES` immediately
2. Read the file into a buffer
3. Strip BOM, normalize line endings to LF
4. Call `applyEditsToNormalizedContent` (from `edit-diff.ts`) to match and replace all blocks atomically
5. Restore original line endings (CRLF preserved if the file used CRLF)
6. Re-add any BOM
7. Write the result via `withFileMutationQueue`

If any one edit fails (text not found, or found more than once), **no** edits are applied—the file is not touched. This all-or-nothing guarantee is enforced in `edit-diff.ts` before the write ever happens.

```ts
// packages/coding-agent/src/core/tools/edit.ts:314-421 (simplified)
return withFileMutationQueue(absolutePath, () => new Promise((resolve, reject) => {
  ...
  const buffer = await ops.readFile(absolutePath);
  const { bom, text: content } = stripBom(rawContent);
  const originalEnding = detectLineEnding(content);
  const normalizedContent = normalizeToLF(content);
  const { baseContent, newContent } = applyEditsToNormalizedContent(normalizedContent, edits, path);
  const finalContent = bom + restoreLineEndings(newContent, originalEnding);
  await ops.writeFile(absolutePath, finalContent);
  resolve({ ..., details: { diff, patch, firstChangedLine } });
}));
```

**Fuzzy matching**: The `prepareArguments` hook normalizes some LLM artifacts before matching—smart quotes mapped to ASCII, Unicode dashes to hyphens, fullwidth punctuation to halfwidth, non-breaking spaces to regular spaces, trailing whitespace stripped from lines, and Unicode compatibility normalization. Exact matches take priority over fuzzy matches.

Sources: [packages/coding-agent/src/core/tools/edit.ts:291-493](), [packages/coding-agent/test/tools.test.ts:227-436]()

---

## Bash

### What it does

`createBashTool` spawns a real shell subprocess and streams its combined stdout+stderr output. The shell is discovered via `getShellConfig` (respecting an optional `shellPath` override). The process is spawned `detached` on Unix so its entire process tree can be killed as a unit.

### Execution model

```ts
// packages/coding-agent/src/core/tools/bash.ts:66-128 (createLocalBashOperations)
const child = spawn(shell, [...args, command], {
  cwd,
  detached: process.platform !== "win32",
  env: env ?? getShellEnv(),
  stdio: ["ignore", "pipe", "pipe"],
  windowsHide: true,
});
```

stdin is closed (`"ignore"`), preventing interactive prompts from hanging indefinitely. stdout and stderr are merged into a single stream via the `onData` callback.

### Timeout and abort

- **Timeout**: If `timeout` is provided (seconds), a `setTimeout` kills the entire process tree via `killProcessTree(child.pid)`.
- **Abort**: An `AbortSignal` listener calls `killProcessTree` immediately on signal.
- **Exit code**: Any non-zero exit code is surfaced as a thrown `Error`; the model sees partial output plus `Command exited with code N`.

### `BashSpawnHook` and `commandPrefix`

Two extension points modify what actually runs:
- `commandPrefix` prepends shell setup lines (e.g. environment initialization) before every command.
- `spawnHook?: (context: BashSpawnContext) => BashSpawnContext` lets an extension rewrite the command, `cwd`, or environment before spawn—used for SSH delegation.

### Output streaming and throttling

Data flows into an `OutputAccumulator` on every `onData` callback. The UI is updated via a throttle: updates are batched with a minimum 100 ms gap (`BASH_UPDATE_THROTTLE_MS`), preventing chatty commands from flooding the TUI.

Sources: [packages/coding-agent/src/core/tools/bash.ts:64-447]()

---

## `OutputAccumulator`: Bounded Memory for Streaming Output

### What problem does it solve?

A bash command might emit gigabytes of output. The model context can hold far less. Without a bound, the agent would exhaust memory before it even reached the truncation decision.

### Architecture

`OutputAccumulator` maintains a *rolling tail* in memory and an optional temp file on disk:

```
Raw data (Buffer)
      │
      ├─► tailText (in-memory rolling window, 2× maxBytes)
      │         trimTail() fires when tailText > 2×maxBytes
      │
      └─► tempFileStream (if totalRawBytes > maxBytes)
                WriteStream to tmpdir/pi-bash-<hex>.log
```

- **In-memory tail**: UTF-8 decoded incrementally via a streaming `TextDecoder`. When `tailBytes` exceeds `2 × maxRollingBytes`, `trimTail()` slices from a UTF-8 character boundary, preserving multi-byte characters.
- **Temp file**: Opened lazily the first time output exceeds the limits. All raw `Buffer` chunks are written; the in-memory chunks buffered before the threshold are flushed first.
- **Snapshot**: `snapshot({ persistIfTruncated: true })` applies `truncateTail` to the in-memory tail and returns both the display-safe content and the `fullOutputPath` for the model to reference.

```ts
// packages/coding-agent/src/core/tools/output-accumulator.ts:91-119
snapshot(options: { persistIfTruncated?: boolean } = {}): OutputSnapshot {
  const tailTruncation = truncateTail(this.getSnapshotText(), { maxLines: this.maxLines, maxBytes: this.maxBytes });
  const truncated = this.totalLines > this.maxLines || this.totalDecodedBytes > this.maxBytes;
  ...
  if (options.persistIfTruncated && truncation.truncated) {
    this.ensureTempFile();
  }
  return { content: truncation.content, truncation, fullOutputPath: this.tempFilePath };
}
```

Defaults are **2000 lines or 50 KB** (from `truncate.ts`), matching the Read tool. Bash uses `truncateTail` (keep last N), while Read uses `truncateHead` (keep first N)—because for command output the recent end is most useful, while for file reads the beginning is.

Sources: [packages/coding-agent/src/core/tools/output-accumulator.ts:1-222](), [packages/coding-agent/src/core/tools/truncate.ts:163-241]()

---

## Grep

### What it does

`createGrepTool` delegates pattern matching to **ripgrep** (`rg`), spawned as a subprocess with `--json` output for structured parsing. The tool resolves `rg` via `ensureTool` (which can auto-download it if absent).

```ts
// packages/coding-agent/src/core/tools/grep.ts:214-219
const args: string[] = ["--json", "--line-number", "--color=never", "--hidden"];
if (ignoreCase) args.push("--ignore-case");
if (literal) args.push("--fixed-strings");
if (glob) args.push("--glob", glob);
args.push("--", pattern, searchPath);
```

The `--` separator before the pattern is a deliberate injection guard—flag-like patterns such as `--pre=script.sh` are treated as literal search text, not ripgrep flags. The test suite verifies this:

```ts
// packages/coding-agent/test/tools.test.ts:722-737
const result = await grepTool.execute("test-call-grep-injection", {
  pattern: `--pre=${payload}`,
  path: testDir,
});
expect(getTextOutput(result)).toContain("No matches found");
expect(existsSync(marker)).toBe(false);
```

Output is capped at **100 matches** (default) or **50 KB**, whichever arrives first. Matches beyond the limit cause the child process to be killed. Individual lines are truncated to **500 characters** to prevent single long lines from consuming the budget.

Sources: [packages/coding-agent/src/core/tools/grep.ts:122-384]()

---

## Find

### What it does

`createFindTool` delegates glob-based file discovery to **fd** (`fd-find`), also resolved via `ensureTool`. The tool handles both simple basename patterns (matched by fd's default `--glob` mode) and path-containing patterns:

```ts
// packages/coding-agent/src/core/tools/find.ts:241-249
if (pattern.includes("/")) {
  args.push("--full-path");
  if (!pattern.startsWith("/") && !pattern.startsWith("**/") && pattern !== "**") {
    effectivePattern = `**/${pattern}`;
  }
}
args.push("--", effectivePattern, searchPath);
```

The `--` separator prevents flag-injection attacks on the pattern argument. `--no-require-git` makes `.gitignore` semantics apply even outside git repositories without leaking sibling-directory rules. Hidden files are included (`--hidden`).

Results are relativized against `searchPath` and capped at **1000 results** (default) or **50 KB**.

Sources: [packages/coding-agent/src/core/tools/find.ts:112-370]()

---

## Ls

### What it does

`createLsTool` reads a directory directly via Node.js `readdirSync`, sorts entries case-insensitively, appends a `/` suffix for subdirectories, and returns the sorted list as a text block. Unlike `find`, it does not recurse and does not respect `.gitignore`. It does include dotfiles by default.

Entries are capped at **500** (default `limit`). The byte cap from `truncateHead` is applied to the assembled string as a secondary guard.

Sources: [packages/coding-agent/src/core/tools/ls.ts:99-228]()

---

## Truncation Architecture: `truncate.ts`

All seven tools share the same two-limit truncation policy defined in one file:

| Constant | Value | Used by |
|---|---|---|
| `DEFAULT_MAX_LINES` | 2000 | Read, OutputAccumulator |
| `DEFAULT_MAX_BYTES` | 50 KB | All tools |
| `GREP_MAX_LINE_LENGTH` | 500 chars | Grep (per-line) |

Two strategies exist, chosen by tool semantics:

- **`truncateHead`** (keep beginning): Read, Grep, Find, Ls. Correct for file inspection—the model wants the top of the file or the first matching results.
- **`truncateTail`** (keep end): OutputAccumulator / Bash. Correct for command output—the model wants the final state, which contains errors, prompts, and results.

Both strategies return a `TruncationResult` that includes `totalLines`, `outputLines`, `truncatedBy` (`"lines"` or `"bytes"`), and `fullOutputPath` (Bash only). These fields drive the actionable continuation notices shown in tool output.

Sources: [packages/coding-agent/src/core/tools/truncate.ts:1-276]()

---

## Pluggable Operations Pattern

Every tool except Ls exposes an `*Operations` interface that replaces its I/O backend:

```text
ReadOperations    { readFile, access, detectImageMimeType? }
WriteOperations   { writeFile, mkdir }
EditOperations    { readFile, writeFile, access }
BashOperations    { exec }
GrepOperations    { isDirectory, readFile }
FindOperations    { exists, glob }
LsOperations      { exists, stat, readdir }
```

This means the entire tool surface can be redirected to SSH, a container, a mock, or a remote filesystem by swapping a single options object—without touching the truncation, queueing, or rendering logic. The Bash tool's `BashSpawnHook` extends this further by allowing arbitrary command, `cwd`, or environment rewriting before spawn.

---

## Concurrency and Safety Summary

```text
Two concurrent edit calls targeting the same file:

 Call A                        Call B
   │                             │
   ├─ withFileMutationQueue ──┐  ├─ withFileMutationQueue ──┐
   │   key = realpath(file)   │  │   key = realpath(file)   │
   │   appends to chain       │  │   appends to chain       │
   │   awaits currentQueue ◄──┘  │   awaits A's nextQueue ◄─┘
   │                             │
   ├─ reads file                 │ (blocked)
   ├─ applies edits              │
   ├─ writes file                │
   └─ calls releaseNext() ──────►│ unblocked
                                 ├─ reads file (sees A's result)
                                 ├─ applies edits
                                 └─ writes file
```

Write and Edit both route through `withFileMutationQueue`. Read does not—reads are never serialized. Bash has no file-level lock because it runs arbitrary commands; the OS provides whatever atomicity the commands themselves implement.

Sources: [packages/coding-agent/src/core/tools/file-mutation-queue.ts:19-39](), [packages/coding-agent/src/core/tools/edit.ts:316](), [packages/coding-agent/src/core/tools/write.ts:203]()

---

## Summary

The seven built-in tools cover all of the agent's filesystem surface area: Read and Ls for observation, Write and Edit for mutation, Bash for arbitrary shell execution, and Grep and Find for search. Every mutation path flows through `withFileMutationQueue` to prevent concurrent torn writes, and every output path flows through the shared truncation constants in `truncate.ts` to prevent context overflow. The `OutputAccumulator` adds a second layer specifically for streaming bash output, maintaining an in-memory rolling tail and spilling to a temp file on disk when output exceeds limits. All seven tools expose a pluggable `*Operations` interface, keeping the scheduling, truncation, and rendering logic decoupled from the I/O backend and making them portable to remote or virtualized environments without changing any of the safety machinery.

Sources: [packages/coding-agent/src/core/tools/truncate.ts:1-13]()
