Better FS Tools
Integrations

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/pi

Pi 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.

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 Tools edit and Pi's own edit or write on the same file then wait for each other. See Locks.
  • digest: nodeDigest(): the same values as sha256Digest(), faster on Node.
  • bash: { runner: piCwdCommandRunner(), env }: commands run in ctx.cwd. Leave bash out to keep Pi's own bash. 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:

ToolRegisteredNotes
readYesReturns images, as Pi's own read does
editYesThe shared edit signature. Pi's edit renderer draws the diff
writeYesThe shared write signature
apply_patchNoRegister it from your own extension
bashNoPi'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:

OptionDefaultMeaning
fsrequiredA writable filesystem, or a function of the call. piCwdFileSystem() is the recommended value
statenullThe read store. null turns read before write off. memoryStore() turns it on
digestsha256Digest()The digest for read records. nodeDigest() is faster on Node
locksmemoryLocks()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
applyPatchofftrue, or an options object, adds apply_patch
bashoffAn 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, or clock.
  • true as the value of bash. Pass { runner, env }.
TypeError: createPiFsTools read options cannot set state: set it once at the top level

The 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:

OptionDefault
promptSnippetThe 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:

ToolDefault promptSnippet
readRead a UTF-8 text file
editEdit a text file by replacing text
writeCreate a text file, or replace all of its content
apply_patchApply a patch that adds, updates, moves, or deletes text files
bashRun 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:

FactoryOptions typeRequired
createPiReadTool()PiReadToolOptionsfs
createPiEditTool()PiEditToolOptionsfs
createPiWriteTool()PiWriteToolOptionsfs
createPiApplyPatchTool()PiApplyPatchToolOptionsfs
createPiBashTool()PiBashToolOptionsrunner 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 thisUse
Refuse a change to a file that the model has not readstate: memoryStore(). See read before write
Use locks of this process onlylocks: memoryLocks(), when no other extension writes files
Narrow the root of each callpiCwdFileSystem({ denyRoots, symlinks })
Give the model Pi's own edit inputedit: { signature: multiEditSignature() }
Send the Codex patch grammarapplyPatch: { signature: freeformPatchSignature() }
Use a filesystem that is not NodeAny 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.

HelperGives
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 call

A 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:

OptionDefaultMeaning
denyRootsnonePaths 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
newFileMode0o666 less the process umaskThe exact mode of a new file
newDirectoryMode0o777 less the process umaskThe 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:

ToolDefault signatureModel input
readdefaultReadSignature(){ path, offset?, limit? }, with integer offset and limit
editdefaultEditSignature({ matchers }){ path, old_string, new_string, replace_all? }
writedefaultWriteSignature(){ path, content }
apply_patchdefaultPatchSignature(){ patch } as JSON
bashdefaultBashSignature({ 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() gives edit with { path, edits: [{ oldText, newText }] }, the input of Pi's own edit. Each oldText is matched against the original file.
  • freeformPatchSignature() adds the Codex patch grammar. A signature with a grammar sets Pi's constrainedSampling, 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.

Tooldetails
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
writeundefined, 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:

ToolisErrorannotations
readtrue for status errorreadOnlyHint: true
edit, apply_patchtrue for status errordestructiveHint: true, idempotentHint: false, openWorldHint: false
writetrue for status errorAs edit, with idempotentHint: true
bashtrue for a nonzero exit, a timeout, an errorNone

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:

OptionEffect
toolsAn allowlist of tool names. Only these tools are active. A custom tool must be in the list too
excludeToolsA 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.

OptionDefaultMeaning
timeoutMs30_000How 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

On this page