Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,22 @@

Versioning rules are in [CONTRIBUTING.md](CONTRIBUTING.md#versioning).

## Unreleased

### Added

- Defer Tools: a new extension, off by default. In each request to Meridian
it marks the tools named in `deferTools.names` (`pi-extras.json`)
`defer_loading: true`, so their declarations leave the prompt and the model
loads them through tool search. The README gives a recommended list of 13
tools. Turn it on only with Meridian 1.89.0 or newer: older builds send
deferred tools in full and add a hidden model turn to each turn with a
tool call. Each session keeps the list of its start in a session entry,
also after a resume, fork or `/reload`, so a config edit never rewrites
the prompt cache of a session. A session that had turns before it had a list never
starts to defer. `PI_EXTRAS_DEFER_TOOLS=0` turns it off.
Requests to other providers do not change.

## 0.44.2 - 2026-11-13

### Changed
Expand Down
68 changes: 67 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ pi install git:github.com/jaedync/pi-extras
```

Restart Pi after installation. Use `pi config` to select extensions. Installing
adds all twenty-two extensions (quota hibernation, computer use and auto-update stay off until you opt in); it makes `quiet` available but does not select it. It also adds the skills `be-ambitious`, `code-slop-rules`, `formal-verification`, `implicit-state-machines`, `ui-redline`, `untangle-codebase` and `walkthrough-video` and the prompt templates `/higher` and `/wider`.
adds all twenty-three extensions (quota hibernation, computer use and auto-update stay off until you opt in); it makes `quiet` available but does not select it. It also adds the skills `be-ambitious`, `code-slop-rules`, `formal-verification`, `implicit-state-machines`, `ui-redline`, `untangle-codebase` and `walkthrough-video` and the prompt templates `/higher` and `/wider`.
Choose the theme using `/settings`. Use only one custom footer at a time.
Phase Spinner wraps an existing editor where possible; other editor extensions
can still conflict.
Expand All @@ -37,6 +37,7 @@ can still conflict.
| Bash Default Timeout | Adds a 120-second timeout only when a bash or powershell call omitted one |
| Idle GC | Once the agent has been idle for 30 seconds, one full garbage collection gives the memory of the last run back to the system |
| Loop Stall | Logs each time Pi stops answering for 500 ms or more, with what held it: main-thread CPU, GC, slow file writes and disk pressure |
| Defer Tools | Opt-in: requests to a local [Meridian](https://github.com/rynfar/meridian) mark the tools you name `defer_loading`, so their declarations leave the prompt until the model searches for them |
| Herdr Tab Name | In a [Herdr](https://herdr.dev) tab, keeps the session name and the tab label the same: `/name` renames the tab, and a session with no name takes the tab's label |
| Kagi Search | Adds `kagi_search` without replacing existing search/fetch tools |
| Gov Search | `gov_search` tool: free search of the Firecrawl Government & Legal Index (US statutes, regulations, city codes, court opinions, agency guidance), with filters for jurisdiction, court and citation; no key needed |
Expand Down Expand Up @@ -324,6 +325,8 @@ child's bash without the sandbox that hides them). The model guide is
I/O pressure (Linux). CPU time close to the stall means JavaScript or GC
work; CPU time close to zero means a blocking system call, such as an fsync
on a busy disk. At 1 MB the log moves to `loop-stall.log.1`.
- `PI_EXTRAS_DEFER_TOOLS=0`: disable Defer Tools for the run (see
[Defer Tools](#defer-tools)).
- `PI_HERDR_TAB_NAME=off`, or `herdrTabName.enabled: false` in
`pi-extras.json`: disable Herdr Tab Name. In a Herdr tab with one pane, it
keeps Pi's session name and the tab's label the same. A session that has a
Expand Down Expand Up @@ -1835,6 +1838,69 @@ a `codemode` script. The line goes into that user message only, so the system
prompt and the tool list stay the same for the whole session, and the prompt
cache stays valid. What you type yourself is never changed.

## Defer Tools

Every request declares every active tool, and a declaration costs input tokens
on every turn. Anthropic's tool search lets a request mark a tool
`defer_loading: true`. The prompt then does not hold its declaration, and the
model loads the tool through a search when it needs it. Meridian gives the
flag to Claude Code's tool search.

Defer Tools adds `defer_loading: true` to the tools that you name, in each
request to Meridian. A request goes to Meridian when its model uses the
`anthropic-messages` API and has a Meridian base URL or sends the
`x-meridian-agent` header. Requests to all other providers do not change.

It is off by default. Older Meridian builds send deferred tools in full and
add a hidden model turn to each turn with a tool call, so deferral costs more
than it saves there. Turn it on only with Meridian 1.89.0 or newer, which
keeps tool search and has no such extra turn. This is the recommended list
(tools used in under 5% of sessions):

```json
{
"deferTools": {
"names": [
"agent_send", "list_peers", "agent_request", "intercom",
"artifact_feedback", "artifact_check", "gov_search", "stop_subagent",
"get_goal", "create_goal", "propose_goal_draft", "complete_goal",
"update_goal_progress"
]
}
}
```

- `names`: the tools to defer. With no `names`, or `"names": []`, it
defers nothing.
- `meridianBaseUrls`: the base URLs that are Meridian. The default is
`["http://127.0.0.1:3456", "http://localhost:3456"]`.
- `PI_EXTRAS_DEFER_TOOLS=0` turns it off for the run.

A change to the tool list rewrites the whole prompt cache. So each session
keeps the list that it had at its start, for its whole life:

- A new session reads `pi-extras.json` and writes its list into the session
as a `defer-tools/frozen` entry.
- A resumed, forked or reloaded session uses the list in that entry and does
not read `pi-extras.json`.
- A session that had model turns before it had an entry defers nothing, so
deferral never starts in the middle of a session.

A config edit thus applies only to new sessions. The same request always
gives the same bytes, so a cache warm repeats the real request.

Some tools are never deferred: `read`, `bash`, `edit`, `write`, `codemode` and
`powershell`. A tool that already has a `defer_loading` value keeps it. A
listed tool that has `cache_control` is deferred too, and its
`cache_control` is removed, because Anthropic does not accept both on one
tool. Meridian does not send a tool's `cache_control` upstream, so this
changes nothing there, and the deferred set does not depend on Pi's cache
retention setting. When every tool of a request would be deferred, the
request does not change, because one tool must stay loaded.

A tool that Tool Loadout hides (`toolLoadout.codemodeOnly`) is not in the
request, so Defer Tools does nothing for it. Subagents do not load Defer Tools.

## Copy Blocks

Code blocks and quotes in the agent's replies are drawn as cards on a
Expand Down
10 changes: 10 additions & 0 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -1028,6 +1028,16 @@ the time, numbers and the labels of slow synchronous operations, such as
`subagents index save`; never a path, a task or any content.
`PI_LOOP_STALL=off` turns it off.

## Defer Tools

Defer Tools is off until `deferTools.names` lists tools. It sends nothing by
itself and writes no files of its own. It reads the `deferTools` section of
`pi-extras.json` and `PI_EXTRAS_DEFER_TOOLS` once per new session. It writes
the list (tool names and base URLs only) into the session as a
`defer-tools/frozen` entry. In a request to Meridian it adds
`defer_loading: true` to the listed tool declarations. It changes nothing else in the request, and it leaves
requests to other providers as they are.

## Reporting

Do not open a public issue containing tokens, session links, auth files, raw
Expand Down
225 changes: 225 additions & 0 deletions extensions/defer-tools.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
/**
* Defer Tools. Requests to Meridian mark the tools named in `deferTools.names` `defer_loading: true`,
* so their declarations leave the prompt and the model loads one through tool search when it needs
* it (Meridian hands the flag to Claude Code's ToolSearch). Other providers get Pi's payload as is.
* Off until names are given: see RECOMMENDED_DEFER_TOOLS for why.
*
* A changed tool list rewrites the whole prompt cache, so each session freezes the list at its
* start and keeps it in a session entry (FROZEN_ENTRY). A resume, fork or `/reload` reads that
* entry, not pi-extras.json; a session that had turns before it had an entry never defers. The output
* depends only on the payload and that list, so a warm request repeats the real one byte for byte.
* Load it before Cache Compaction: that extension keeps the payload it sees to replay as a warm.
*
* Children do not load it: a child loads only the extensions that own its tools, and most of the
* recommended list is excluded from children anyway (lib/subagents/child.ts CHILD_TOOL_EXCLUDE).
*/
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { readSection } from "../lib/extras-config.ts";

/**
* Tools used in under 5% of sessions on two machines over 14 days: a list to paste into
* `deferTools.names`. Not the default: Meridian builds older than 1.89.0 send deferred tools in
* full and add a hidden digest turn to each tool-call turn, so turn it on only with Meridian
* 1.89.0 or newer (upstream #1368 and #1370).
*/
export const RECOMMENDED_DEFER_TOOLS: readonly string[] = Object.freeze([
"agent_send", "list_peers", "agent_request", "intercom", "artifact_feedback", "artifact_check", "gov_search",
"stop_subagent", "get_goal", "create_goal", "propose_goal_draft", "complete_goal", "update_goal_progress",
]);
export const DEFAULT_MERIDIAN_BASE_URLS: readonly string[] = Object.freeze(["http://127.0.0.1:3456", "http://localhost:3456"]);
/** The model's own ways to work; a deferred one would cost a search before every use. */
const NEVER_DEFERRED: ReadonlySet<string> = new Set(["read", "bash", "edit", "write", "codemode", "powershell"]);
const TOOL_NAME = /^[A-Za-z0-9_-]{1,64}$/;
const MERIDIAN_HEADER = "x-meridian-agent";
const VIRTUAL_API = "pi-virtual";
const KILL_SWITCH = "PI_EXTRAS_DEFER_TOOLS";
const STORE_SLOT = Symbol.for("pi-extras.defer-tools.sessions");
/** The session entry that holds a session's frozen config. */
export const FROZEN_ENTRY = "defer-tools/frozen";

export interface DeferConfig {
readonly names: readonly string[];
readonly meridianBaseUrls: readonly string[];
}

const neverDeferred = (name: string) => NEVER_DEFERRED.has(name.toLowerCase());
const normalizeUrl = (url: string) => url.trim().replace(/\/+$/, "").toLowerCase();

export function parseDeferConfig(section: Record<string, unknown>): DeferConfig {
// Off unless the owner names the tools (see RECOMMENDED_DEFER_TOOLS).
const raw: readonly unknown[] = Array.isArray(section.names) ? section.names : [];
const names = raw.filter((name): name is string => typeof name === "string").map((name) => name.trim())
.filter((name) => TOOL_NAME.test(name) && !neverDeferred(name));
const urls = Array.isArray(section.meridianBaseUrls)
? section.meridianBaseUrls.filter((url): url is string => typeof url === "string" && /^https?:\/\/\S+$/i.test(url.trim())).map(normalizeUrl)
: DEFAULT_MERIDIAN_BASE_URLS;
return { names: [...new Set(names)], meridianBaseUrls: [...new Set(urls)] };
}

/** The config as the session will keep it: a copy that nothing can change, off with the kill switch. */
export function freezeDeferConfig(section: Record<string, unknown>, env: Record<string, string | undefined>): DeferConfig {
const parsed = parseDeferConfig(section);
const names = env[KILL_SWITCH]?.trim() === "0" ? [] : parsed.names;
return Object.freeze({ names: Object.freeze([...names]), meridianBaseUrls: Object.freeze([...parsed.meridianBaseUrls]) });
}

interface ModelLike {
readonly provider?: string;
readonly id?: string;
readonly api?: string;
readonly baseUrl?: string;
readonly headers?: Record<string, string> | undefined;
}

function hasMeridianHeader(headers: unknown): boolean {
if (!headers || typeof headers !== "object") return false;
// A null value is a header that a hook deleted.
return Object.entries(headers).some(([key, value]) => key.toLowerCase() === MERIDIAN_HEADER && value !== null && value !== undefined);
}

/** Meridian speaks the Anthropic Messages API, at a known base URL or with its agent header. */
export function isMeridianModel(model: ModelLike | undefined, meridianBaseUrls: readonly string[]): boolean {
if (!model || model.api !== "anthropic-messages") return false;
if (hasMeridianHeader(model.headers)) return true;
return typeof model.baseUrl === "string" && meridianBaseUrls.includes(normalizeUrl(model.baseUrl));
}

const modelKey = (model: ModelLike & { provider?: string }) => `${model.provider ?? ""}/${model.id ?? ""}`;

interface RequestContext {
readonly model?: ModelLike | undefined;
readonly modelRegistry?: { getAll?: () => readonly ModelLike[] } | undefined;
}

/**
* Pi gives the handler no request model, only the selection (`ctx.model`). The payload names the
* model it is for, so a request for another model (a summary, a routed request) is left alone. A
* virtual selection routes each request; then every registry model with the payload's id must be
* a Meridian model, which gives the same answer on the first request as on later ones.
* `headerSeen` holds the models whose request headers had `x-meridian-agent`: Pi resolves provider
* headers at request time, so `ctx.model.headers` does not show them.
*/
export function requestGoesToMeridian(ctx: RequestContext, payload: unknown, meridianBaseUrls: readonly string[], headerSeen: ReadonlySet<string> = new Set()): boolean {
const id = payload && typeof payload === "object" ? (payload as { model?: unknown }).model : undefined;
const selected = ctx.model;
if (typeof id !== "string" || !selected) return false;
if (selected.api !== VIRTUAL_API) {
return selected.id === id && (isMeridianModel(selected, meridianBaseUrls) || (selected.api === "anthropic-messages" && headerSeen.has(modelKey(selected))));
}
const candidates = (ctx.modelRegistry?.getAll?.() ?? []).filter((model) => model.id === id && model.api !== VIRTUAL_API);
return candidates.length > 0 && candidates.every((model) => isMeridianModel(model, meridianBaseUrls));
}

type ToolLike = Record<string, unknown> & { name?: unknown };

function deferrable(tool: unknown, names: ReadonlySet<string>): tool is ToolLike {
if (!tool || typeof tool !== "object") return false;
const { name } = tool as ToolLike;
return typeof name === "string" && names.has(name) && !neverDeferred(name)
// A value someone set stays.
&& !Object.hasOwn(tool, "defer_loading");
}

/**
* A listed tool, deferred. Pi puts `cache_control` on the last tool only while cache retention is
* not `none`, so keeping such a tool loaded would tie the deferred set to that setting. Anthropic
* rejects `cache_control` on a deferred tool, so the key goes; Meridian (the only target) rebuilds
* tools through MCP and never forwards a client's tool `cache_control`, so nothing changes upstream.
*/
function deferred(tool: ToolLike): ToolLike {
const { cache_control: _dropped, ...rest } = tool;
return { ...rest, defer_loading: true };
}

/**
* The payload with each listed tool marked `defer_loading: true`, or undefined to keep Pi's payload:
* when nothing matches, or when no tool would stay loaded (Anthropic needs one). Tool order and key
* order stay; unchanged tools are the same objects; the input is not changed.
*/
export function deferPayload(payload: unknown, names: readonly string[]): unknown {
if (!payload || typeof payload !== "object" || names.length === 0) return undefined;
const tools = (payload as { tools?: unknown }).tools;
if (!Array.isArray(tools)) return undefined;
const wanted = new Set(names);
const marks = tools.map((tool) => deferrable(tool, wanted));
if (!marks.includes(true)) return undefined;
const stillLoaded = tools.some((tool, index) => !marks[index] && (tool as { defer_loading?: unknown } | null)?.defer_loading !== true);
if (!stillLoaded) return undefined;
return { ...payload, tools: tools.map((tool, index) => marks[index] ? deferred(tool as ToolLike) : tool) };
}

type SessionStore = Map<string, DeferConfig>;

/** One record per process, so a `/reload` copy finds the list each session froze. */
function processStore(): SessionStore {
const slot = globalThis as { [STORE_SLOT]?: SessionStore };
return (slot[STORE_SLOT] ??= new Map());
}

export interface DeferToolsOptions {
/** The `deferTools` section; read from pi-extras.json when omitted. */
readonly config?: Record<string, unknown>;
readonly env?: Record<string, string | undefined>;
/** Each session's frozen config; the process-wide one when omitted. */
readonly store?: SessionStore;
}

interface SessionContext extends RequestContext {
readonly sessionManager?: { getSessionId?: () => string; getBranch?: () => readonly unknown[] };
}

type BranchEntry = { type?: unknown; customType?: unknown; data?: unknown; message?: { role?: unknown } } | null;

/**
* What the session's branch says: its frozen config (the latest entry on the path, so a fork or a
* tree branch keeps its source's), "turns" when it had a provider turn with no entry, or undefined
* for a session that has not talked to a model yet.
*/
export function branchState(branch: readonly unknown[]): { readonly frozen: Record<string, unknown> } | "turns" | undefined {
let turns = false;
for (let i = branch.length - 1; i >= 0; i--) {
const entry = branch[i] as BranchEntry;
if (entry?.type === "custom" && entry.customType === FROZEN_ENTRY) {
const data = entry.data && typeof entry.data === "object" ? entry.data as Record<string, unknown> : {};
return { frozen: data };
}
if (entry?.type === "message" && entry.message?.role === "assistant") turns = true;
}
return turns ? "turns" : undefined;
}

export default function deferTools(pi: ExtensionAPI, options: DeferToolsOptions = {}): void {
const env = options.env ?? process.env;
// The kill switch is the process's own; no earlier copy in it can have run with deferral.
if (env[KILL_SWITCH]?.trim() === "0") return;
const store = options.store ?? processStore();
// Decided once per session, at its start (or its first request), and never again for it.
const frozenFor = (ctx: SessionContext): DeferConfig => {
const id = ctx.sessionManager?.getSessionId?.() ?? "";
const known = store.get(id);
if (known) return known;
const state = branchState(ctx.sessionManager?.getBranch?.() ?? []);
let config: DeferConfig;
if (state === "turns") config = freezeDeferConfig({ names: [] }, env);
else if (state) config = freezeDeferConfig(state.frozen, env);
else {
config = freezeDeferConfig(options.config ?? readSection("deferTools"), env);
// Off writes nothing: a later resume finds turns and no entry, so it stays off.
if (config.names.length > 0) pi.appendEntry(FROZEN_ENTRY, { names: [...config.names], meridianBaseUrls: [...config.meridianBaseUrls] });
}
store.set(id, config);
return config;
};
// Pi assembles a request's headers before its payload, so the first request already counts.
const headerSeen = new Set<string>();
pi.on("session_start", (_event, ctx) => { frozenFor(ctx as SessionContext); });
pi.on("before_provider_headers", (event, ctx) => {
const model = (ctx as RequestContext).model;
if (model && model.api !== VIRTUAL_API && hasMeridianHeader(event.headers)) headerSeen.add(modelKey(model));
});
pi.on("before_provider_request", (event, ctx) => {
const config = frozenFor(ctx as SessionContext);
if (config.names.length === 0 || !requestGoesToMeridian(ctx as RequestContext, event.payload, config.meridianBaseUrls, headerSeen)) return undefined;
return deferPayload(event.payload, config.names);
});
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"pi": {
"extensions": [
"extensions/tab-status.ts",
"extensions/defer-tools.ts",
"extensions/status-plus.ts",
"extensions/usage-guard.ts",
"extensions/rate-limit-recovery.ts",
Expand Down
Loading
Loading