Pi's default model-facing tools are four — read, bash, edit, write — with grep, find and ls available as options and powershell on Windows. Each is a small module in packages/coding-agent/src/core/tools/. Small, but full of systems programming: bounded output for a bounded context window, exact-then-fuzzy string matching, per-file mutual exclusion, process groups and signals, UTF-8 boundaries, and Unicode quirks of real file systems.
Learning goals. Describe each tool's contract; explain why tool output must be truncated and how Pi chooses what to keep; explain the edit tool's matching algorithm and why edits are applied in reverse order; explain how the bash tool kills a whole process tree and why waiting for a child to "finish" is subtle; evaluate the tools' security posture.
8.1 Anatomy of a tool definition
Every tool is created by a factory such as createReadToolDefinition(cwd, options) that returns a ToolDefinition: the AgentTool fields from Chapter 6 (name, description, parameters schema, execute) plus things the coding agent adds — a one-line promptSnippet and promptGuidelines that buildSystemPromptSections() assembles into the system prompt (Chapter 1), renderers for the terminal UI, an optional outputSchema for structured results that programmatic callers such as codemode scripts receive, and constrainedSampling: { type: "json_schema", strict: "prefer" }.
Each tool also takes pluggable operations:
/**
* Pluggable operations for the read tool.
* Override these to delegate file reading to remote systems (for example SSH).
*/
export interface ReadOperations {
readFile: (absolutePath: string) => Promise<Buffer>;
access: (absolutePath: string) => Promise<void>;
detectImageMimeType?: (absolutePath: string) => Promise<string | null | undefined>;
}The tool's logic (paging, truncation, messages to the model) is separated from the I/O primitives it uses. Swap the operations and the same tool reads files on a remote machine or inside a container. This is the ports and adapters (hexagonal) architecture at the smallest scale, and it is how the containerization recipes in docs/containerization.md route built-in tools into a micro-VM.
8.2 Why outputs are truncated
A tool result becomes part of the next prompt. One cat of a minified bundle or one npm test with verbose logging could consume the whole context window, and every later request would pay for it again. truncate.ts defines the budget:
/**
* Shared truncation utilities for tool outputs.
*
* Truncation is based on two independent limits - whichever is hit first wins:
* - Line limit (default: 2000 lines)
* - Byte limit (default: 50KB)
*
* Never returns partial lines (except bash tail truncation edge case).
*/
export const DEFAULT_MAX_LINES = 2000;
export const DEFAULT_MAX_BYTES = 50 * 1024; // 50KBThe two functions encode a judgement about which end matters: truncateHead "keep[s] first N lines/bytes. Suitable for file reads where you want to see the beginning"; truncateTail "keep[s] last N lines/bytes. Suitable for bash output where you want to see the end (errors, final results)." Byte limits are applied on UTF-8 boundaries so a multi-byte character is never split.
The crucial design choice is that truncation is never silent. Each tool appends an actionable notice telling the model how to get the rest: [Showing lines 1-2000 of 8450. Use offset=2001 to continue.] for reads, or [Showing lines 6451-8450 of 8450. Full output: /tmp/…] for bash. The model is treated as a program that can follow a pagination protocol.
8.3 read
Parameters: path, optional 1-indexed offset, optional limit. The algorithm:
- Resolve the path against the working directory (
~expansion, a leading@stripped — models copy@filementions from user prompts). - Check readability, then sniff the file type. Images (jpg, png, gif, webp, bmp) are resized to the model's input limits and returned as an image block with a text note; for a model without vision input a note says the image will be omitted.
- For text, split into lines, apply
offsetandlimit, thentruncateHead. - If the first line alone exceeds 50 KB (a minified file), return a hint to use
sed -n '<line>p' <path> | head -c 51200through bash instead.
Path resolution reveals how messy real file systems are. If the literal path does not exist, resolveReadPath() tries variants: the macOS screenshot naming that puts a narrow no-break space (U+202F) before "AM"/"PM"; the NFD form, because "macOS stores filenames in NFD (decomposed) form"; and curly apostrophes, because "macOS uses U+2019 (right single quotation mark) in screenshot names like "Capture d'écran"". Unicode lets one visible string have several byte sequences (é as one code point U+00E9, or as e + combining U+0301); normalization forms (NFC, NFD, NFKC, NFKD) choose a canonical one. A path the user can see but the program cannot open is usually a normalization mismatch.
Cancellation follows a careful pattern: the tool rejects immediately on abort but sets an aborted flag so the background work, which cannot be interrupted mid-syscall, discards its result instead of resolving a promise that already rejected.
8.4 edit: exact replacement, made robust
The edit tool does not accept diffs or line numbers. It accepts path and edits: [{ oldText, newText }, …]. Each oldText must occur exactly once in the original file, and edits must not overlap. Why this design? Line numbers go stale the moment the file changes and models count lines poorly; unified diffs are easy to get subtly wrong. Quoting the exact text to replace is something models do reliably, and uniqueness makes the target unambiguous.
The procedure in edit.ts and edit-diff.ts:
- Repair the arguments.
prepareEditArgumentsaccepts what models actually send: "Some models (Opus 4.6, GLM-5.1) send edits as a JSON string instead of an array. Others send a single edit object", plus the legacy top-leveloldText/newTextform. Be liberal in what you accept, strict in what you execute. - Serialize per file. Everything runs inside
withFileMutationQueue(absolutePath, …)(§8.5). - Normalize. Strip a byte-order mark ("The model will not include an invisible BOM in oldText"), detect the line ending, convert CRLF to LF.
- Match. For each edit,
fuzzyFindText()tries an exactindexOffirst. If that fails, it retries in a normalized space wherenormalizeForFuzzyMatch()applies NFKC, strips trailing whitespace on each line, maps smart quotes to ASCII quotes, maps the various Unicode dashes to-, and maps special spaces to a normal space. - Check. Not found → error "The old text must match exactly including all whitespace and newlines". More than one occurrence → error asking for more context. Sort matches by position and reject overlaps. Empty
oldText→ error. - Apply in reverse order. "Replacements are then applied in reverse order so offsets remain stable." Replacing text near the end of a string does not move the positions of earlier matches; replacing from the front would invalidate every later offset.
- Preserve untouched bytes. If fuzzy matching was needed, the edit was computed on normalized text, which differs from the file (trailing spaces gone, quotes changed).
applyReplacementsPreservingUnchangedLinesrewrites only the lines the replacements touch and copies all other lines byte-for-byte from the original. Fuzzy normalization never adds or removes newline characters, so line i of the normalized text corresponds to line i of the original — that invariant is what makes the overlay possible. - No-op check. If the result equals the input, fail: "This might indicate an issue with special characters".
- Write and report. Restore the original line endings and BOM, write the file, and return a display diff with line numbers plus a standard unified patch (generated by the
diffpackage, an implementation of Myers' O(ND) difference algorithm), and the first changed line for editor navigation.
The tool result tells the model only "Successfully replaced N block(s)"; the diff goes into details for the UI. The model already knows what it changed; sending the diff back would waste tokens.
8.5 Serializing writes to the same file
In parallel tool mode (Chapter 6), a model can issue two edit calls to the same file in one response. Each reads, modifies and writes the file — a classic lost update: both read version 1, the second write overwrites the first. file-mutation-queue.ts gives each file its own FIFO lock built from promise chaining:
export async function withFileMutationQueue<T>(filePath: string, fn: () => Promise<T>): Promise<T> {
const registration = registrationQueue.then(async () => {
const key = await 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);
return { key, currentQueue, chainedQueue, releaseNext };
});
…
await currentQueue;
try {
return await fn();
} finally {
releaseNext();
if (fileMutationQueues.get(key) === chainedQueue) {
fileMutationQueues.delete(key);
}
}
}Each caller waits for the promise of the previous holder and installs its own unresolved promise as the new tail; releaseNext() in finally hands the lock on even if fn throws. Two refinements: the key is the file's realpath, so ./a.ts and a symlink to it share a queue; and registration itself is serialized through registrationQueue, because computing the key is asynchronous and two callers could otherwise both read the old tail before either stored theirs. Files with different keys proceed in parallel. The map entry is deleted when the last holder releases, so the map does not grow without bound.
The edit tool also explains why it does not reject from the abort listener: "that would release the mutation queue while an in-flight filesystem operation may still finish. Checking signal.aborted after each await observes the same aborts while keeping the queue locked until the current operation has settled." Releasing a lock before the protected operation ends is the same bug as not taking it.
8.6 write
write takes path and content, creates parent directories, and overwrites the file. It is deliberately simple; it shares the mutation queue with edit. Because it replaces the whole file, the agent loop's refusal to execute tool calls from a length-truncated message (Chapter 6) matters most here.
8.7 bash: processes, groups, signals and pipes
Parameters: command and an optional timeout in seconds ("no default timeout"). The model-facing output is the combined stdout and stderr, tail-truncated, with the full output saved to a temporary file when truncated. The exit code is reported; a non-zero code makes the result an error.
Choosing a shell. getShellConfig() prefers a user-configured path; on Windows it searches Git Bash in the usual install locations, then bash.exe on PATH, and fails with installation advice; on Unix it tries /bin/bash, then bash on PATH, then falls back to sh -c.
Spawning. The essential lines:
const child = spawn(shellConfig.shell, commandFromStdin ? shellConfig.args : [...shellConfig.args, command], {
cwd,
detached: process.platform !== "win32",
env: env ?? getShellEnv(),
stdio: [commandFromStdin ? "pipe" : "ignore", "pipe", "pipe"],
windowsHide: true,
});spawn creates a child process (on Unix, conceptually fork + exec). stdio connects the child's file descriptors 0, 1 and 2: stdin is ignored (a command that waits for input would otherwise hang forever), stdout and stderr are pipes the parent reads. detached: true on Unix puts the child in a new process group whose id equals the child's pid. That is what makes cleanup possible.
Killing a process tree. A shell command often starts grandchildren (npm test runs node, which runs workers). Killing only the shell leaves them running as orphans. killProcessTree() sends SIGKILL to the negative pid:
// Use SIGKILL on Unix/Linux/Mac
try {
process.kill(-pid, "SIGKILL");
} catch {
// Fallback to killing just the child if process group kill fails
try {
process.kill(pid, "SIGKILL");
} catch {
// Process already dead
}
}In POSIX kill(2), a negative pid means "every process in that process group". On Windows, which has no process groups in this sense, Pi runs taskkill /F /T /PID — from System32 explicitly, "so cleanup does not depend on PATH". The same function runs on abort and on timeout. Pi also records the pids of detached children so that if Pi itself is killed (SIGTERM, SIGHUP) it can kill them first (trackDetachedChildPid, killTrackedDetachedChildren).
Exit status. A process killed by a signal has no exit code. Pi reports 128 + signal number ("the standard shell convention so callers do not mistake the termination for a successful command"), so a SIGKILLed command reports 137.
Knowing when a child is done. This is harder than it sounds. Node emits exit when the process ends and close when its stdio streams have closed. If the command starts a background daemon that inherits the pipe, close may never fire and the tool would hang. If you stop reading at exit, output still in flight is lost. waitForChildProcess() in utils/child-process.ts handles both: after exit, it waits for both streams to end, and arms a 100 ms idle timer that is re-armed on every data chunk, so "an actively writing descendant keeps us reading, while a quiet inherited handle … still releases us after the grace elapses." The comment cites the issue that motivated it (#5303) — most robustness code in Pi is a scar from a real bug.
Bounded memory for unbounded output. OutputAccumulator streams chunks through a UTF-8 decoder, keeps only a rolling tail in memory for live display, and spills the full output to a temporary file when it exceeds the limits. A command that prints gigabytes cannot exhaust Pi's memory.
Environment. The child receives the user's environment with Pi's bin directory on PATH, and, unless disabled, PI_SESSION_ID, PI_SESSION_FILE, PI_PROVIDER, PI_MODEL and PI_REASONING_LEVEL, so scripts can know which session invoked them. A spawnHook lets extensions rewrite the command, working directory or environment before execution — the hook point for sandboxing wrappers.
8.8 grep, find and ls
grep delegates to ripgrep and find to fd, both fast, .gitignore-aware search tools written in Rust. grep runs rg --json --line-number --color=never --hidden and parses ripgrep's JSON output, which is far more robust than parsing human-oriented text. If the binaries are missing, utils/tools-manager.ts downloads the latest release from GitHub; it resolves "latest" through the release page's HTTP redirect rather than the GitHub API, because the API's anonymous quota (60 requests per hour per IP) "is permanently exhausted behind shared egress IPs such as corporate proxies and CI runners". Downloads extract into unique temporary directories because "fd and rg downloads can run concurrently during startup, so sharing a fixed directory causes races". Note what this code does not do: it does not pin a version or verify a checksum of the downloaded binary — a trade-off worth weighing against the supply-chain discipline of Chapter 2.
When only bash is enabled, the system prompt's rules tell the model to "Use bash for file operations like ls, rg, find" (Chapter 1).
8.9 Security posture
None of these tools restricts paths to the working directory, and none asks for confirmation. docs/security.md is explicit: "Treat model-generated commands and code as untrusted. Pi can read, change, and execute files with the permissions of the account that started it, and it does not ask for approval before every tool call." And: "Safety comes from limiting the files, credentials, processes, and network services Pi can access … Watching the transcript, using project trust, and reviewing changes do not create a security boundary."
The reasoning is sound security engineering. A path allowlist inside the tool is trivially bypassed by bash (cat ../../.ssh/id_rsa). A real boundary must be enforced below the agent, by the operating system: a container, a micro-VM, a separate user account. The tool layer instead offers hooks (beforeToolCall, spawnHook, pluggable operations) so that such a boundary can be integrated, and extensions can add approval prompts for those who want them.
8.10 Concepts in this chapter
- Ports and adapters. Business logic depends on interfaces (
ReadOperations,BashOperations), not on concrete I/O. - Bounded resources. Every unbounded input (file size, command output) is capped, with an explicit continuation path.
- String search and fuzzy matching by normalization. Exact search first; on failure, search in a canonicalized space and map results back.
- Unicode normalization forms. NFC/NFD/NFKC/NFKD; equivalence of visually identical strings.
- Offset stability. Apply positional edits from the end backwards.
- Diff algorithms. Myers' algorithm computes a shortest edit script between two sequences in O(ND) time; unified diff is its textual format.
- Lost updates and mutual exclusion. Read-modify-write on shared state needs a lock; promise chains make a FIFO mutex in single-threaded JavaScript.
- Processes, file descriptors and pipes. stdin/stdout/stderr are fds 0/1/2; pipes connect a parent to a child's fds.
- Process groups and signals. Signal a whole group with a negative pid; SIGKILL cannot be caught; 128 + N for signal deaths.
- Orphans and inherited handles. Children outlive parents; descendants holding pipes keep streams open.
- Defence in depth and enforcement layers. Put security boundaries where they cannot be bypassed (the OS), not in advisory code above it.
Exercises
- A file contains
const msg = “hello”;with curly quotes and trailing spaces. The model sendsoldText: 'const msg = "hello";'. TracefuzzyFindTextandapplyReplacementsPreservingUnchangedLines. Which bytes of the file change? - Why must two
editcalls to the same file be serialized even though JavaScript is single-threaded? Draw the interleaving of awaits that loses an update. - Write a command that would hang a naive "wait for close" implementation, and one that would lose output in a naive "stop at exit" implementation.
- Run
bash -c 'sleep 100 & sleep 100'under a process that kills only the shell's pid. What remains running? Repeat withkill -9 -<pgid>. - Propose and implement a
ReadOperationsthat reads files from a Docker container withdocker exec cat. What breaks for images, and why? - Add checksum verification to the ripgrep download. Where would trusted checksums come from?
- Give two reasons why
readuses head truncation andbashuses tail truncation, and a case where each choice is wrong.