Pi
Replace the read, edit, and write tools of the Pi coding agent, and add apply_patch and bash, with @better-fs-tools/pi.
@better-fs-tools/pi gives the Pi coding agent the Better FS Tools read, edit, and write. They replace Pi's built-in tools of the same names. The package can also add apply_patch and bash. The tools take the options of every Better FS Tools integration, with the same defaults, plus Pi's prompt options.
Install
To use the tools in Pi as they are, install the package with Pi's own command:
pi install npm:@better-fs-tools/piPi writes the package to your user settings. Add -l to write it to the project settings in .pi/settings.json. To try the tools for one run only, start Pi with pi -e npm:@better-fs-tools/pi.
To write your own extension with createPiFsTools(), install the package and its peers in your extension's project:
npm install @better-fs-tools/pi @better-fs-tools/node @better-fs-tools/read @better-fs-tools/shell @better-fs-tools/write @earendil-works/pi-coding-agent typebox@earendil-works/pi-coding-agent (^1.0.0) and typebox (^1.3.16) are required peers. Pi versions before 1.0 are not supported. The package needs Node 24 or later.
Recommended configuration
These options suit Pi. They are options, not defaults, so each one is visible in your code:
import { createPiFsTools, piMutationQueueLocks } from "@better-fs-tools/pi";
import { piCwdCommandRunner, piCwdFileSystem } from "@better-fs-tools/pi/node";
import { nodeDigest } from "@better-fs-tools/node";
import { shellEnv } from "@better-fs-tools/shell";
createPiFsTools({
fs: piCwdFileSystem(), // a Node filesystem rooted at ctx.cwd on each call
locks: piMutationQueueLocks(), // the per-file queue of Pi's own edit and write
digest: nodeDigest(), // faster on Node than sha256Digest()
// To replace Pi's own bash too:
bash: { runner: piCwdCommandRunner(), env: shellEnv(() => process.env) },
});fs: piCwdFileSystem(): each call can only touch files under the working directory of that call. See The Node helpers.locks: piMutationQueueLocks(): Pi's own per-file queue. A Better FS Toolseditand Pi's owneditorwriteon the same file then wait for each other. See Locks.digest: nodeDigest(): the same values assha256Digest(), faster on Node.bash: { runner: piCwdCommandRunner(), env }: commands run inctx.cwd. Leavebashout to keep Pi's ownbash. See Bash tool.
Every other option has the default of every integration: no read store, memoryLocks() when you leave out locks, and read returns images. Choose an integration has the full table.
What the package registers
The package has a pi.extensions entry. When Pi loads the package, the entry calls createPiFsTools() with the first three options of the recommended configuration, and registers three tools:
| Tool | Registered | Notes |
|---|---|---|
read | Yes | Returns images, as Pi's own read does |
edit | Yes | The shared edit signature. Pi's edit renderer draws the diff |
write | Yes | The shared write signature |
apply_patch | No | Register it from your own extension |
bash | No | Pi's own bash stays. Register createPiBashTool() to replace it |
The entry has no read store, so edit and write do not check for a read first. Writes stay inside ctx.cwd. The entry also registers piToolRenderers(), so Pi's built-in renderers draw the tools. It writes no settings file and no session file.
The same entry is also available as the default export of @better-fs-tools/pi/extension.
Write your own extension
To set options, add apply_patch, or turn on read before write, register the tools from your own extension with createPiFsTools():
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { createPiFsTools, piMutationQueueLocks, piToolRenderers } from "@better-fs-tools/pi";
import { piCwdFileSystem } from "@better-fs-tools/pi/node";
import { nodeDigest } from "@better-fs-tools/node";
import { denyPaths, memoryStore, unicodeRepair } from "@better-fs-tools/read";
import { hashlineFormat } from "@better-fs-tools/read/formats";
import { protectPaths } from "@better-fs-tools/write";
export default function fsToolsExtension(pi: ExtensionAPI): void {
const tools = createPiFsTools({
// The recommended Pi configuration, as in the package's extension entry.
fs: piCwdFileSystem(),
locks: piMutationQueueLocks(),
digest: nodeDigest(),
// Opt-ins: one read store, so edit and write need a read first, and apply_patch.
state: memoryStore(),
read: {
resolve: unicodeRepair({ note: false }),
authorize: denyPaths(["**/.env", "**/.env.*"]),
formatter: hashlineFormat(),
},
edit: { authorize: protectPaths() },
write: { authorize: protectPaths() },
applyPatch: { authorize: protectPaths() },
});
pi.registerTool(tools.read);
pi.registerTool(tools.edit);
pi.registerTool(tools.write);
pi.registerTool(tools.applyPatch);
// Pi's built-in read, edit, and write renderers draw the shared signatures.
pi.registerToolRenderer(piToolRenderers([tools.read, tools.edit, tools.write]));
}Options
createPiFsTools() takes the options of createFsTools(), with Pi's ExtensionContext as the host. Each tool's options also take a signature and Pi's prompt options. Set the shared options once, at the top level:
| Option | Default | Meaning |
|---|---|---|
fs | required | A writable filesystem, or a function of the call. piCwdFileSystem() is the recommended value |
state | null | The read store. null turns read before write off. memoryStore() turns it on |
digest | sha256Digest() | The digest for read records. nodeDigest() is faster on Node |
locks | memoryLocks() | The lock manager of the write tools. See Locks |
clock | () => new Date() | The clock of the tools and the store |
read, edit, write | {} | The options of one tool. See the read and write guides |
applyPatch | off | true, or an options object, adds apply_patch |
bash | off | An options object with runner and env adds bash. See Bash tool |
state can also be a function of the call. It gets the call, so it can use ctx.call.host.sessionManager.getSessionId() to give each session its own store. See Hooks and state.
These options throw TypeError:
- An unknown key at the top level.
- A shared option inside one tool's options:
fs,state,digest,locks, orclock. trueas the value ofbash. Pass{ runner, env }.
TypeError: createPiFsTools read options cannot set state: set it once at the top levelThe result has read, edit, write, applyPatch, and bash. applyPatch and bash are null when they are off. It also has the shared state, digest, locks, and clock, and invalidate(path, call?). See Forget a read after a bash command.
Prompt snippets and guidelines
Pi puts a short snippet and a list of guidelines for each tool in its system prompt. Every Pi tool takes two prompt options:
| Option | Default |
|---|---|
promptSnippet | The first sentence of the signature description, with no stop |
promptGuidelines | [] |
Pi lists a tool under "Available tools" only when it has a snippet, so every tool gets one. The text comes from the shared signature, so each integration describes the tool in the same way. With the default signatures, the snippets are:
| Tool | Default promptSnippet |
|---|---|
read | Read a UTF-8 text file |
edit | Edit a text file by replacing text |
write | Create a text file, or replace all of its content |
apply_patch | Apply a patch that adds, updates, moves, or deletes text files |
bash | Run a shell command with bash -c and return its exit code and output |
Set promptSnippet or promptGuidelines on a tool to replace the default:
import { createPiFsTools } from "@better-fs-tools/pi";
import { piCwdFileSystem } from "@better-fs-tools/pi/node";
const tools = createPiFsTools({
fs: piCwdFileSystem(),
edit: {
promptGuidelines: ["Use edit for precise changes. Copy each old_string from the file."],
},
});Register one tool
Each tool also has its own factory:
| Factory | Options type | Required |
|---|---|---|
createPiReadTool() | PiReadToolOptions | fs |
createPiEditTool() | PiEditToolOptions | fs |
createPiWriteTool() | PiWriteToolOptions | fs |
createPiApplyPatchTool() | PiApplyPatchToolOptions | fs |
createPiBashTool() | PiBashToolOptions | runner and env |
Each factory takes every option of its core factory, with the same defaults, plus signature, promptSnippet, and promptGuidelines. This extension registers only the read tool, with the path parameter renamed to file_path:
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { createPiReadTool } from "@better-fs-tools/pi";
import { piCwdFileSystem } from "@better-fs-tools/pi/node";
import { denyPaths, unicodeRepair } from "@better-fs-tools/read";
import { hashlineFormat } from "@better-fs-tools/read/formats";
import { defaultReadSignature } from "@better-fs-tools/read/signature";
export default function readExtension(pi: ExtensionAPI): void {
pi.registerTool(
createPiReadTool({
fs: piCwdFileSystem(),
signature: defaultReadSignature({ name: "read", names: { path: "file_path" } }),
resolve: unicodeRepair({ note: false }),
authorize: denyPaths(["**/.env", "**/.env.*"]),
formatter: hashlineFormat(),
}),
);
}A single factory shares nothing with another one. No tool has a read store unless you pass one, and each write tool makes its own lock manager. To use single factories with read before write, pass one store to all of them. piMutationQueueLocks() uses Pi's own queue, so write tools with it wait for each other:
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import {
createPiEditTool,
createPiReadTool,
createPiWriteTool,
piMutationQueueLocks,
} from "@better-fs-tools/pi";
import { piCwdFileSystem } from "@better-fs-tools/pi/node";
import { memoryStore } from "@better-fs-tools/read";
export default function singleToolsExtension(pi: ExtensionAPI): void {
const fs = piCwdFileSystem();
const state = memoryStore();
const locks = piMutationQueueLocks();
pi.registerTool(createPiReadTool({ fs, state }));
pi.registerTool(createPiEditTool({ fs, state, locks }));
pi.registerTool(createPiWriteTool({ fs, state, locks }));
}createPiFsTools() does the same with less code. Use a single factory when you register one tool only.
Alternatives
The recommended configuration fits most extensions. These choices fit other cases:
| To do this | Use |
|---|---|
| Refuse a change to a file that the model has not read | state: memoryStore(). See read before write |
| Use locks of this process only | locks: memoryLocks(), when no other extension writes files |
| Narrow the root of each call | piCwdFileSystem({ denyRoots, symlinks }) |
| Give the model Pi's own edit input | edit: { signature: multiEditSignature() } |
| Send the Codex patch grammar | applyPatch: { signature: freeformPatchSignature() } |
| Use a filesystem that is not Node | Any writable filesystem as fs. See Filesystems |
multiEditSignature() and freeformPatchSignature() come from @better-fs-tools/write/signature. See Tool signatures.
The Node helpers
@better-fs-tools/pi/node has the Node backends of a Pi extension. The main entry imports no Node module.
| Helper | Gives |
|---|---|
piCwdFileSystem(options?) | A per-call fs: nodeFileSystem() with cwd and allowedRoots set to ctx.cwd. It keeps the filesystems of the last 8 roots |
piCwdCommandRunner(options?) | A per-call bash runner: nodeCommandRunner() at ctx.cwd, which must be absolute. It keeps the runners of the last 8 directories |
Pi gives each tool call an ExtensionContext. With piCwdFileSystem(), the tools use its cwd as the only allowed root of that call. When the working directory changes between calls, the root changes too.
You cannot widen the root. cwd and allowedRoots in the options throw TypeError:
TypeError: piCwdFileSystem options cannot set cwd: the root is ctx.cwd on every callA call without a usable ctx.cwd gives an error result.
The ExtensionContext is the host of the call. Your host functions get it as ctx.call.host. For example, an askUser() function can call ctx.call.host.ui.confirm(...). See Hooks and state.
Root options
piCwdFileSystem() takes every option of nodeFileSystem() except cwd and allowedRoots:
| Option | Default | Meaning |
|---|---|---|
denyRoots | none | Paths that are refused as DANGEROUS_PATH, in addition to /dev, /proc, and /sys |
symlinks | "follow-within-roots" | "reject" refuses every symlink. "follow-within-roots" follows a link when its real path stays inside ctx.cwd |
hardLinks | "in-place" | How to write a file with more than one hard link. "in-place" writes through the link, which is not atomic. "refuse" fails the write with DENIED |
newFileMode | 0o666 less the process umask | The exact mode of a new file |
newDirectoryMode | 0o777 less the process umask | The exact mode of a new directory |
import { createPiFsTools } from "@better-fs-tools/pi";
import { piCwdFileSystem } from "@better-fs-tools/pi/node";
const tools = createPiFsTools({
fs: piCwdFileSystem({
symlinks: "reject",
hardLinks: "refuse",
newFileMode: 0o644,
newDirectoryMode: 0o755,
}),
});A file with one hard link is always replaced with a temporary file and a rename.
Tool signatures
The Pi tools use the default signatures of every integration:
| Tool | Default signature | Model input |
|---|---|---|
read | defaultReadSignature() | { path, offset?, limit? }, with integer offset and limit |
edit | defaultEditSignature({ matchers }) | { path, old_string, new_string, replace_all? } |
write | defaultWriteSignature() | { path, content } |
apply_patch | defaultPatchSignature() | { patch } as JSON |
bash | defaultBashSignature({ runner, limits }) | { command, timeout?, cwd? }, with timeout in milliseconds |
Set signature on a tool to change its name, its parameter names, or its descriptions. See Tool definitions. Two presets give a shape that Pi's own tools use:
multiEditSignature()giveseditwith{ path, edits: [{ oldText, newText }] }, the input of Pi's ownedit. EacholdTextis matched against the original file.freeformPatchSignature()adds the Codex patch grammar. A signature with a grammar sets Pi'sconstrainedSampling, so a model that supports grammar tools gets the grammar. Other models get the JSON schema.
import { createPiFsTools } from "@better-fs-tools/pi";
import { piCwdFileSystem } from "@better-fs-tools/pi/node";
import { freeformPatchSignature, multiEditSignature } from "@better-fs-tools/write/signature";
const tools = createPiFsTools({
fs: piCwdFileSystem(),
edit: { signature: multiEditSignature() },
applyPatch: { signature: freeformPatchSignature() },
});Renderers
Pi draws a tool named read, edit, write, or bash with its built-in renderer of that name, unless the tool has its own. Those renderers read Pi's own argument names: edits[].oldText for the edit preview, and timeout in seconds for bash.
piToolRenderers(tools) returns a ToolRendererResolver that gives each renderer the arguments in Pi's names, for any signature. Register it with pi.registerToolRenderer(). The extension entry does this for its own tools.
Without it, the tools still work, but Pi draws no edit preview before the change, and it shows a bash timeout in milliseconds as seconds. A replace-all edit gets no preview, and Pi shows the diff of the result.
Results in Pi
Each tool returns Pi content parts and Pi details. A tool error becomes Pi content text with isError: true. The tool does not throw for a tool error. Pi shows the text as an error, and a tool_result handler sees isError: true:
[edit:not-read] Read greet.ts with the read tool before changing it.Bad model input rejects with TypeError. An image part becomes a Pi image part. Other media becomes a text part that says what was left out, because Pi's tool content takes images only.
| Tool | details |
|---|---|
read | { truncation } when the read stopped exactly at a line or byte limit, else {} |
edit, apply_patch | { diff, patch, firstChangedLine }, the shape of Pi's EditToolDetails. undefined for an error or no change |
write | undefined, as for Pi's own write |
bash | { fullOutputPath } when the output spilled to a file, else undefined |
Errors and hints
Each tool sets these Pi 1.x tool fields:
| Tool | isError | annotations |
|---|---|---|
read | true for status error | readOnlyHint: true |
edit, apply_patch | true for status error | destructiveHint: true, idempotentHint: false, openWorldHint: false |
write | true for status error | As edit, with idempotentHint: true |
bash | true for a nonzero exit, a timeout, an error | None |
No tool declares an outputSchema or returns structuredContent, as in every integration. A codemode script that calls a tool gets its text. A result with isError: true makes the call reject with that text.
A read with status unsupported, for example of a binary file, is not an error. Its text tells the model why the tool did not show the file.
Permission extensions read annotations from pi.getAllTools(). Pi does not check the hints.
Read truncation
details.truncation has the shape of Pi's TruncationResult. Pi uses it to print how many lines it shows. Its content is the output of the formatter in "view" mode, without the notes. The details are {} for any other result: a read that did not stop at a line or byte limit, a total that is not exact, or a formatter that returns parts. An approximate count is worse than none. See the read formatting guide for the "view" mode.
Edit diff
details.diff is the display diff that Pi's edit renderer draws. Context and removed lines carry the old line number. Added lines carry the new one. details.patch is the unified diff of every change. With more than one file, as from apply_patch, each file's lines follow a line with its path.
1 export function greet(name: string): string {
-2 return `Hello, ${name}!`;
+2 return `Hi, ${name}!`;
3 }Details for your own tool
toPiReadDetails() and toPiMutationDetails() build the same details for a tool that you build yourself on the core:
import { toPiMutationDetails, toPiReadDetails } from "@better-fs-tools/pi";
import { nodeFileSystem } from "@better-fs-tools/node";
import { createReadTool, lineNumberFormatter, resolveReadLimits } from "@better-fs-tools/read";
import { createEditTool } from "@better-fs-tools/write";
const cwd = process.cwd();
const fs = nodeFileSystem({ cwd, allowedRoots: [cwd] });
const edit = createEditTool({ fs });
const changed = await edit({
path: "src/greet.ts",
edits: [{ oldText: "Hello", newText: "Hi" }],
});
const editDetails = toPiMutationDetails(changed);
const limits = resolveReadLimits({ maxLines: 2 });
const formatter = lineNumberFormatter();
const read = createReadTool({ fs, limits, formatter });
const call = { callId: "call-1", host: undefined };
const result = await read({ path: "src/greet.ts" }, call);
let view: string | null = null;
if (result.status === "ok") {
const { content: _content, ...outcome } = result;
const text = formatter.format(outcome, { digest: null, limits, mode: "view", call });
view = typeof text === "string" ? text : null;
}
const readDetails = toPiReadDetails(result, view, limits.maxViewBytes);toPiReadDetails(result, view, maxViewBytes) takes the read result, the formatted text in "view" mode or null, and the view byte limit. toPiMutationDetails(result) takes the result of edit or apply_patch.
Bash tool
createPiBashTool() is a bash tool from @better-fs-tools/shell with the shared default signature: { command, timeout?, cwd? }, with the timeout in milliseconds. runner and env are required. With piCwdCommandRunner(), commands run in ctx.cwd, and a cwd from the model is relative to it. The extension entry does not register this tool, so Pi's own bash stays unless you register this one:
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { createPiBashTool, piToolRenderers } from "@better-fs-tools/pi";
import { piCwdCommandRunner } from "@better-fs-tools/pi/node";
import { shellEnv } from "@better-fs-tools/shell";
// Replaces Pi's own bash, and runs each command in ctx.cwd.
// Pi's bash renderer still draws it.
export default function bashExtension(pi: ExtensionAPI): void {
const bash = createPiBashTool({
runner: piCwdCommandRunner(), // a Node runner at ctx.cwd on each call
env: shellEnv(() => process.env),
});
pi.registerTool(bash);
pi.registerToolRenderer(piToolRenderers([bash]));
}A runner object runs in its own cwd. To run it in another directory, set the cwd option, for example cwd: (call) => call.host.cwd.
For Pi's own bash input, { command, timeout? } with the timeout in seconds and no cwd, pass signature: defaultBashSignature({ timeoutUnit: "s", cwd: false }) from @better-fs-tools/shell/signature.
The default description names the timeouts and output limits from limits, and the id of a runner object. A runner factory, such as piCwdCommandRunner(), has no id to name. See the bash guide for limits, policy, and runners.
Pi's own bash also sets PI_* session variables. This tool does not. Add them with the env option when you need them.
When the output is too long, the tool can save it to a file. details.fullOutputPath gives that file to Pi's bash renderer. See Limits.
Forget a read after a bash command
A bash command can change a file after the model read it. With a read store, call invalidate() from a bash afterRun hook. The next edit of that file then needs a read:
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { createPiFsTools, piToolRenderers } from "@better-fs-tools/pi";
import { piCwdCommandRunner, piCwdFileSystem } from "@better-fs-tools/pi/node";
import { memoryStore } from "@better-fs-tools/read";
import { shellEnv } from "@better-fs-tools/shell";
// The file tools with a read store, and bash in the same bundle. After each
// command, the next edit of package.json needs a read, since the command may
// have changed it.
export default function fsAndBashExtension(pi: ExtensionAPI): void {
const tools = createPiFsTools({
fs: piCwdFileSystem(),
state: memoryStore(),
bash: {
runner: piCwdCommandRunner(),
env: shellEnv(() => process.env),
afterRun: [
{
id: "invalidate-package-json",
afterRun: async (_outcome, ctx) => {
// ctx.call carries Pi's ctx, so the path resolves under its cwd.
await tools.invalidate("package.json", ctx.call);
return {};
},
},
],
},
});
pi.registerTool(tools.read);
pi.registerTool(tools.edit);
pi.registerTool(tools.write);
pi.registerTool(tools.bash);
pi.registerToolRenderer(piToolRenderers([tools.read, tools.edit, tools.write, tools.bash]));
}Pass the call to invalidate(path, call). In a bash afterRun hook, the call is ctx.call. With piCwdFileSystem(), fs is a function of the call, so the bundle has no root until a call gives one. Without the call, invalidate() does not throw. It returns { ok: false } with reason unsupported:
{
"ok": false,
"phase": "stat",
"error": {
"reason": "unsupported",
"detail": "fs is a factory: pass the call context to invalidate(path, call)"
}
}With the call, the outcome is { ok: true, resolvedPath, recorded }. recorded is true when the store had a record for the path. With state: null, recorded is false.
Use Pi from code (SDK)
@earendil-works/pi-coding-agent is also an SDK. createAgentSession() runs the same session as the pi command, in your own Node or Bun process. The tools from createPiFsTools() work there with no change.
Replace the built-in tools
Give the tools to customTools. A custom tool with the name of a built-in tool replaces that built-in tool, so read, edit, and write replace Pi's own. Pi's own bash stays:
import { createAgentSession, SessionManager } from "@earendil-works/pi-coding-agent";
import { createPiFsTools } from "@better-fs-tools/pi";
import { piCwdFileSystem } from "@better-fs-tools/pi/node";
import { memoryStore } from "@better-fs-tools/read";
const cwd = "/work/repo";
const tools = createPiFsTools({ fs: piCwdFileSystem(), state: memoryStore(), applyPatch: true });
// read, edit, and write have the names of Pi's built-in tools, so they replace
// them. Pi's own bash stays.
const { session } = await createAgentSession({
cwd,
sessionManager: SessionManager.inMemory(cwd),
customTools: [tools.read, tools.edit, tools.write, tools.applyPatch],
});
try {
await session.prompt("Rename greet() to welcome() in src/greet.ts.");
} finally {
session.dispose();
}The active tools of this session are read, bash, edit, write, and apply_patch.
Choose the active tools
Three options of createAgentSession() choose which tools the model gets:
| Option | Effect |
|---|---|
tools | An allowlist of tool names. Only these tools are active. A custom tool must be in the list too |
excludeTools | A list of tool names to turn off. It applies after tools |
noTools | "builtin" turns off Pi's built-in tools and keeps custom and extension tools. "all" starts with no tools |
This session can read and search, but it cannot change files. read is the Better FS Tools read:
import { createAgentSession, SessionManager } from "@earendil-works/pi-coding-agent";
import { createPiFsTools } from "@better-fs-tools/pi";
import { piCwdFileSystem } from "@better-fs-tools/pi/node";
const cwd = "/work/repo";
const tools = createPiFsTools({ fs: piCwdFileSystem() });
// A read-only session: the Better FS Tools read, and Pi's grep, find, and ls.
const { session } = await createAgentSession({
cwd,
sessionManager: SessionManager.inMemory(cwd),
customTools: [tools.read],
tools: ["read", "grep", "find", "ls"],
});
session.dispose();Add hooks and guards with an inline extension
customTools only adds tools. To also use Pi events, for example a tool_call guard, or a bash afterRun hook that calls invalidate(), write an extension function. Give it to DefaultResourceLoader in extensionFactories. Pi loads it as it loads an extension file:
import {
createAgentSession,
DefaultResourceLoader,
getAgentDir,
isToolCallEventType,
SessionManager,
} from "@earendil-works/pi-coding-agent";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { createPiFsTools } from "@better-fs-tools/pi";
import { piCwdCommandRunner, piCwdFileSystem } from "@better-fs-tools/pi/node";
import { memoryStore } from "@better-fs-tools/read";
import { shellEnv } from "@better-fs-tools/shell";
const cwd = "/work/repo";
function fsToolsExtension(pi: ExtensionAPI): void {
const tools = createPiFsTools({
fs: piCwdFileSystem(),
state: memoryStore(),
bash: {
runner: piCwdCommandRunner(),
env: shellEnv(() => process.env),
afterRun: [
{
id: "invalidate-package-json",
afterRun: async (_outcome, ctx) => {
await tools.invalidate("package.json", ctx.call);
return {};
},
},
],
},
});
pi.registerTool(tools.read);
pi.registerTool(tools.edit);
pi.registerTool(tools.write);
pi.registerTool(tools.bash);
// A guard: Pi calls tool_call handlers before each tool runs.
pi.on("tool_call", async (event) => {
if (isToolCallEventType("bash", event) && /\bgit\s+push\b/u.test(event.input.command)) {
return { block: true, reason: "git push is not allowed in this host" };
}
return undefined;
});
}
const resourceLoader = new DefaultResourceLoader({
cwd,
agentDir: getAgentDir(),
extensionFactories: [{ name: "better-fs-tools", factory: fsToolsExtension }],
});
await resourceLoader.reload();
const { session } = await createAgentSession({
cwd,
resourceLoader,
sessionManager: SessionManager.inMemory(cwd),
// Turn off Pi's built-in tools. The extension's tools stay active.
noTools: "builtin",
});
try {
await session.prompt("Update the version in package.json to 2.0.0.");
} finally {
session.dispose();
}With noTools: "builtin", the active tools are the four tools of the extension. DefaultResourceLoader also finds the extension files in your user and project folders, as the pi command does. Set noExtensions: true to skip the extension files that Pi finds. The inline extensions still load.
The working directory
The cwd of createAgentSession() is the ctx.cwd of each tool call. With piCwdFileSystem(), the tools use it as their only root. Give the same directory to createAgentSession(), to DefaultResourceLoader, and to SessionManager.inMemory(). One session works in one directory. For a second directory, make a second session.
SessionManager.inMemory() keeps the conversation in memory and writes no session file. A memoryStore() also keeps its read records in memory only, so each new process starts with no reads.
One copy of Pi
piMutationQueueLocks() uses the file queue of the @earendil-works/pi-coding-agent module that the package imports. In an SDK host, install one copy of Pi, so that the package and your session use the same module. With two copies, a Better FS Tools edit and Pi's own write on the same file do not wait for each other. npm ls @earendil-works/pi-coding-agent shows the copies.
For a host on pi-agent-core without the coding agent, see Pi agent core. For the durable harness, see Pi durable.
Locks
piMutationQueueLocks() is a lock manager over withFileMutationQueue(), the per-file queue of Pi's own edit and write. A Better FS Tools edit and Pi's own edit or write that change the same file then wait for each other. It is part of the recommended configuration, and the extension entry uses it. The default of locks is memoryLocks(), as in every integration.
| Option | Default | Meaning |
|---|---|---|
timeoutMs | 30_000 | How long a change waits for its files. After that, the change fails with LOCK_TIMEOUT |
In a Pi extension, Pi's loader gives the package Pi's own module, so the queue is Pi's. In an SDK host, see One copy of Pi.
import { createPiFsTools, piMutationQueueLocks } from "@better-fs-tools/pi";
import { piCwdFileSystem } from "@better-fs-tools/pi/node";
import { memoryLocks } from "@better-fs-tools/write";
// Wait 5 seconds for Pi's queue, then fail the change with LOCK_TIMEOUT.
export const tools = createPiFsTools({
fs: piCwdFileSystem(),
locks: piMutationQueueLocks({ timeoutMs: 5_000 }),
});
// Or do not wait for Pi's own tools.
export const separate = createPiFsTools({ fs: piCwdFileSystem(), locks: memoryLocks() });See also
- Read guide and write guide: the options under
read,edit,write, andapplyPatch - Bash guide: the
createPiBashTool()options - Choose an integration: the defaults of every integration
- Node filesystem: the filesystem that
piCwdFileSystem()makes for each call - Pi agent core and Pi durable:
createAgentCoreFsTools()and@better-fs-tools/pi-durable, the tools for the other Pi libraries