Closes#11597
## Problem
On Windows, `pi update` stopped with `git clean -fdx failed with code 1`
for git extensions such as pi-lens.
1. Another pi session has the extension loaded, so a native module
(`@ast-grep/napi` `.node`) or `ast-grep.exe` is open in that process.
2. npm renames the in-use package dir to
`node_modules/@ast-grep/.napi-win32-x64-msvc-<hash>`. Windows allows the
rename, but npm cannot delete the dir afterwards.
3. The next `pi update` runs `git reset --hard`, then `git clean -fdx`.
git prints `warning: failed to remove ...: Invalid argument` and exits
1.
4. pi threw, leaving the checkout at the new commit with the old
dependencies.
The suggested `-ffdx` does not help. Reproduced locally (git 2.52,
Windows): with a running `.exe`, or a `.node` loaded by another node
process and its dir renamed, both `-fdx` and `-ffdx` fail with `Invalid
argument`. Once the process exits, `-fdx` succeeds.
## Fix
In `cleanAndInstallGitDependencies`, a failed `git clean` no longer
aborts the update. pi:
- prints a warning that files may be in use by another pi session
- still runs `npm install`, so dependencies match the new commit
- keeps the `.pi-update-incomplete` marker, so the next update retries
the clean
## Tests
- `test/git-update.test.ts`: new regression test. A failing clean still
updates and installs dependencies, warns, and keeps the marker. The next
update cleans and removes the marker.
- `test/package-manager.test.ts`: updated the test that expected the
throw.
`npm run check` reports `install-lock is out of date`, and
`package-manager.test.ts > should expand ~ in exclusion and exact
patterns` fails on Windows. Both also happen on `main` without this
change.
Restructures external limitations in the tmux and Windows guides as
"Known issues" with a remedy for each entry.
- `tmux.md`: the tmux 3.6 color reply section becomes a Known issues
table (issue, affected versions, remedy).
- `windows.md`: the conhost problem table moves to a Known issues
section at the end of the page, with a Remedy column instead of the
cause column. The terminal table links to it. Issue links are reduced to
one per row.
Known issues are limited to things outside Pi's control or deliberate
limitations. Bugs Pi can fix stay in the issue tracker.
Replace build-time shell utilities with Node filesystem operations and retire the native sandbox example in favor of existing isolation options. Upgrade glob matching and worker-test dependencies and refresh the lockfiles.
Adds a troubleshooting section to the tmux guide
(`packages/coding-agent/docs/tmux.md`, published at
https://pi.dev/docs/latest/tmux) for the color-code text that tmux 3.6
types into the editor at startup.
tmux 3.6 forwards Pi's palette queries to the outer terminal. When a
reply reaches tmux split across reads, tmux treats its bytes as key
presses and sends them to Pi as typed text. tmux 3.7 fixes this
([tmux#4749](https://github.com/tmux/tmux/issues/4749),
[tmux#4793](https://github.com/tmux/tmux/issues/4793)). Pi cannot filter
the text because it arrives exactly like typing.
The section also notes that switching to a fixed theme does not help:
`applyFromSettings()` queries terminal colors regardless of the theme,
which reporters confirmed in #10679 (`--use-theme dark`) and #10256
(`"theme": "dark"`).
Related: #10250, #10679, #10256.
Adds a "Choose a terminal" section to
`packages/coding-agent/docs/windows.md`
(https://pi.dev/docs/latest/windows), with two tables (last updated
October 2026):
- Terminals and which conhost they use: Windows Terminal (recommended),
VS Code, WezTerm nightly (bundled, up to date); WezTerm 20240203
(bundled, old); Alacritty (built-in unless `conpty.dll` +
`OpenConsole.exe` are provided); classic console window, mintty,
ConEmu/Cmder, Windows OpenSSH (built-in).
- Known problems by conhost type:
- Built-in: crash when switching fullscreen -> regular or using `Ctrl+G`
from fullscreen (#9186, microsoft/terminal#17515).
- Built-in behind another terminal (mintty, OpenSSH): query replies leak
into the prompt, BEL opens the external editor (#10256, #10362, #10742).
- Old bundled (WezTerm 20240203): reply fragments in the prompt (#10717,
#10970).
- Built-in, classic console: reported regular-mode redraw issue (#6300).
- Notes that the fixes exist upstream and reach the built-in conhost
with a future Windows update, timing unknown.
Sources: VS Code defaults `terminal.integrated.windowsUseConptyDll` to
`true` (bundled conpty v1.25). Alacritty loads `conpty.dll` only if
present (`alacritty_terminal/src/tty/windows/conpty.rs`) and its
installer ships only `alacritty.exe`.
The crash was verified locally in the classic console window on conhost
10.0.26100.9807: fullscreen -> regular crashed 3/3 runs and `Ctrl+G`
from fullscreen crashed 2/2 runs (`0xC0000005`).
After every commit that changed a task, the scheduler re-checked every
conversation with a kept context range (every conversation read in the last
contextRetentionMs) by walking its subtree, scanned them again for the next
expiry, and wake() scanned every live task. CPU per run grew linearly with the
number of conversations.
Now a commit re-checks only the conversations above tasks that became live or
ended (collected before the sweep; a chain load that resolves an unknown for a
live unloaded task falls back to a full check), idle context ranges expire
from an index ordered by idleSince (re-sorted when the clock goes back; zero
retention drops all idle ranges; reconcile passes still expire for hosts that
decline timers), and wake() takes the earliest sleep from the sleeping tasks
and scans live tasks only while nothing runs.
One Session, one-tool runs, no storage latency: 2048 conversations 100 -> 805
runs/s, 4096: 46 -> 772; local SQLite at 2048: 88 -> 459; CPU per run flat at
about 1.6 ms.
This PR uses unicode-aware segmentation and normalization to handle tool
search for multilingual text.
Fixes#10816
---------
Co-authored-by: Petr Houška <houskape@gmail.com>
Ports the coding agent's tool ranking fixes for #11331 (#11440, #11625)
to durable, which keeps its own copy of the ranker in `src/search.ts`.
Problem: durable's `rankDocuments()` was still the coding agent's ranker
from before both fixes. `thread_read` with `limit: 1` returned
`queue_read`, and a tool name among other words (`peekaboo app`) lost to
tools that only mention the words in their descriptions.
Change:
- `src/search.ts`: same scoring as the coding agent's `Bm25Ranker`, plus
coverage first: a tool matching more of the query's terms (in any field)
ranks above one matching fewer, whatever the scores. Without it, name
weighting regressed `screenshot window` (1 to 2, `window` beat `image`)
and `describe app` (1 to 2, `app` beat `see`). The name is a separate
field (3x IDF per term, above any number of description mentions), a
tool whose whole local name is in the query gets a bonus worth one more
match of every query term, a query that is exactly a tool's name ranks
it first, and namespace text is a separate 0.5x field that is not
length-normalized.
- `tool_search`: the search text no longer repeats the name; the ranker
reads it from `name`.
- `searchTools()`: same, and the namespace text now includes the
namespace instructions, as in the coding agent.
- `test/search.test.ts`: the coding agent's ranker cases.
Remaining difference from the coding agent: durable's `tool_search` has
no namespaces (they are a code mode option), and `searchTools()` ranks
on the tool sample from the start data (description plus TypeScript
declaration) rather than description plus schema text.
`searchTools()` results are journaled, so replaying a script started
before this change returns the old results; no `CODEMODE_MODULE_VERSION`
bump.
## Before/after
`searchTools()` ranking on a made-up set of 16 MCP tools from 5 servers.
Each tool is searched with its description; each server is a code mode
namespace (prefix `mcp__<server>__`) with the description and
instructions below. Rank of the wanted tool among all matches, with the
top result when it is another tool.
| Server | Description | Instructions | Tools: description |
|---|---|---|---|
| `github` | GitHub repositories, issues, and pull requests | Use
owner/repo for repositories. Issues and pull requests share numbers. |
`list_issues`: List issues in a repository, filtered by state, labels,
or assignee.<br>`get_issue`: Get an issue with its comments by
number.<br>`create_issue`: Open a new issue in a
repository.<br>`search_code`: Search code across
repositories.<br>`create_pull_request`: Open a pull request from a
branch. |
| `linear` | Linear issue tracker | Search issues before creating new
ones. Issues belong to teams. | `search_issues`: Search Linear issues by
text.<br>`create_issue`: Create a Linear issue in a team. |
| `peekaboo` | macOS automation | Peekaboo controls macOS apps. Use app
names or bundle IDs. Peekaboo can capture any app window. | `app`:
Launch, quit, relaunch, focus, hide, or switch applications. Targets an
application by name, bundle identifier, or process ID, and can wait
until it is ready.<br>`see`: Capture the app window and describe the UI
elements of the app.<br>`window`: Move, resize, or close a window of an
app.<br>`menu`: Click a menu item in an app.<br>`image`: Take a
screenshot of the screen, an app, or a window. |
| `app` | Thread and queue state of the app | Threads hold messages; the
queue holds pending messages of a thread. | `queue_read`: Read a queued
message of this thread.<br>`thread_read`: Read durable state and a
paginated timeline from any thread. The default view returns user
messages, assistant messages and plans. Continue with afterPosition
until hasMore is false. |
| `docs` | Project documentation | Search the docs before answering
questions about the API. | `search`: Search the
documentation.<br>`read_page`: Read a documentation page by path. |
Before, a tool's search text was its name, the name with `_` as spaces,
its description, and the namespace name and description (no
instructions). After, the name and the namespace (with instructions) are
separate fields.
| Query | Wanted | Before: rank | After: rank | Change |
|---|---|---|---|---|
| `thread_read` | `app__thread_read` | 2 (top: `app__queue_read`) | 1 |
$\color{green}{\textsf{better}}$ |
| `mcp__app__thread_read` | `app__thread_read` | 2 (top:
`app__queue_read`) | 1 | $\color{green}{\textsf{better}}$ |
| `peekaboo app` | `peekaboo__app` | 5 (top: `peekaboo__see`) | 1 |
$\color{green}{\textsf{better}}$ |
| `app` | `peekaboo__app` | 4 (top: `app__queue_read`) | 1 |
$\color{green}{\textsf{better}}$ |
| `read thread messages` | `app__thread_read` | 2 (top:
`app__queue_read`) | 1 | $\color{green}{\textsf{better}}$ |
| `open the peekaboo app tool` | `peekaboo__app` | 5 (top:
`peekaboo__see`) | 1 | $\color{green}{\textsf{better}}$ |
| `launch app` | `peekaboo__app` | 1 | 1 | $\color{gray}{\textsf{same}}$
|
| `create linear issue` | `linear__create_issue` | 1 | 1 |
$\color{gray}{\textsf{same}}$ |
| `search documentation` | `docs__search` | 1 | 1 |
$\color{gray}{\textsf{same}}$ |
| `read docs` | `docs__read_page` | 1 | 1 |
$\color{gray}{\textsf{same}}$ |
| `pull request` | `github__create_pull_request` | 1 | 1 |
$\color{gray}{\textsf{same}}$ |
| `get issue comments` | `github__get_issue` | 1 | 1 |
$\color{gray}{\textsf{same}}$ |
| `screenshot window` | `peekaboo__image` | 1 | 1 |
$\color{gray}{\textsf{same}}$ |
| `describe app` | `peekaboo__see` | 1 | 1 |
$\color{gray}{\textsf{same}}$ |
| `create issue` | `github__create_issue` | 2 (top:
`linear__create_issue`) | 2 (top: `linear__create_issue`) |
$\color{gray}{\textsf{same}}$ |
| `search github issues` | `github__list_issues` | 5 (top:
`github__search_code`) | 5 (top: `github__search_code`) |
$\color{gray}{\textsf{same}}$ |
No regressions on this set. `search github issues` stays at 5:
`list_issues` does not contain "search", so it ties on coverage (2 of 3
terms) with `search_code` and `linear__search_issues`, and their scores
win.
Follow-up to #11625 (#11331). Same change in durable's copy: #11631.
Problem: #11625 made a query term in a tool's name outweigh any number
of description mentions. That regressed queries where another tool
matches more of the query in its description:
- `screenshot window`: `peekaboo__window` (name has `window`) beat
`peekaboo__image` ("Take a screenshot of the screen, an app, or a
window.").
- `describe app`: `peekaboo__app` (whole name `app`) beat
`peekaboo__see` ("... describe the UI elements of the app.").
Fix: `Bm25Ranker` sorts by coverage first: a tool matching more of the
query's distinct terms, in any field (name, description and schema,
namespace), ranks above one matching fewer, whatever the scores. Within
the same coverage, the score decides as before. A query that is exactly
a tool's name still ranks that tool first.
On the 16-tool set from #11631 (5 servers, 16 queries), this fixes both
regressions and changes nothing else; `search github issues` stays at
rank 5, since `list_issues` lacks "search" and ties on coverage with
`search_code` and `linear__search_issues`.
Changelog: updates the unreleased #11331 entry from #11625.
Fixes#4748.
## Problem
Extensions that import `keyText`/`keyHint` from
`@earendil-works/pi-coding-agent` can resolve their own copy of the
package (their own `node_modules`, or the bundled `cli.js` vs
`dist/index.js`). That copy's keybindings are never set by the host, so
it falls back to a manager with only `tui.*` bindings, and
`keyHint("app.tools.expand", "to expand")` renders as ` to expand`.
## Change
The host passes its own `keyText` and `keyHint` to extension renderers,
the same way it passes `theme`:
- `ToolRenderContext` (`renderCall`/`renderResult` context):
`context.keyHint(...)`
- `MessageRenderOptions` and `EntryRenderOptions`:
`options.keyHint(...)`
All three extend a new exported `KeybindingHints` interface. The docs
and the `debug-provider` example now use the injected functions and warn
against importing them in renderers.
Not done:
- Moving the `pi-tui` keybindings singleton to `globalThis`. Per the
discussion in #7011, extension-facing state should be host-owned and
passed through the extension API instead of shared through process-wide
module state.
- `ctx.ui.keyText`/`keyHint`. No reported case needs them outside
renderers, RPC and print modes never install app keybindings, and adding
them to `ExtensionUIContext` would break SDK hosts that implement it.
## Tests
- `test/extension-keybinding-hints.test.ts`: tool, message, and entry
renderers format a user-overridden `app.tools.expand` binding.
- `npm run check` passes.
Refs #7756, #5607.
Problem: when a wrapper or another package manager provides Pi (mise, a
`PNPM_HOME` wrapper such as nub, Nix), `pi update` cannot update it and
prints "pi cannot self-update this installation." Users with several
machines have to remember which tool updates Pi on which machine.
Example:
```sh
export PI_SELF_UPDATE_COMMAND="mise use -g npm:{packageName}@{version}"
pi update
# Updating pi with mise use -g npm:@earendil-works/pi-coding-agent@1.4.0...
# Updated pi from 1.3.0 to 1.4.0
```
or in `~/.pi/agent/settings.json`:
```json
{ "selfUpdateCommand": ["nix", "profile", "upgrade", "pi"] }
```
Behavior:
- `pi update` still checks pi.dev first. The command runs only when a
newer release exists, or with `--force`, and the release note prints
first.
- Placeholders come from that release: `{packageName}`, `{version}`,
`{installSpec}` (`<packageName>@<version>`). A package rename announced
by pi.dev flows into them.
- `PI_SELF_UPDATE_COMMAND` takes precedence and is split on whitespace
(no quoting). The setting is argv, for arguments with spaces, and is
read only from global settings, so a project's `.pi/settings.json`
cannot inject a command.
- The configured command replaces Pi's own update logic for every
install method, including installer-managed installs: it is explicit
user intent.
- Exit code 0 does not prove an update. Pi reports "Updated pi from X to
Y" when its own `package.json` now shows Y (in-place updates) or when
`pi --version` from PATH reports Y (wrappers such as mise or Nix that
install each version into a new directory). Otherwise it warns: ``Ran
<cmd>, but `pi --version` reports X, not Y.``
- "cannot self-update this installation" now mentions the env var and
setting.
Docs: `docs/cli.md` (new "Custom update command" section),
`docs/settings.md`, `docs/environment-variables.md`, `--help` env list,
regenerated `schemas/settings.schema.json`.
Tests: unit tests for argv resolution and placeholders in
`test/config.test.ts`; CLI tests in `test/package-command-paths.test.ts`
for env precedence over the setting and the managed install, global-only
setting gated by the version check, and command failure. `npm run check`
passes.
Manual end-to-end check with real mise 2026.10.23 in a sandboxed HOME,
running this branch as the mise-installed 1.3.0 (`PI_PACKAGE_DIR`
pointing at mise's install):
| Scenario | Result |
|---|---|
| `mise use -g npm:{packageName}@{version}` | mise installs 1.4.0;
`Updated pi from 1.3.0 to 1.4.0` |
| Already 1.4.0 | `pi is already up to date (v1.4.0)`, mise not run |
| In-place `npm install -g --prefix <dir> {installSpec}` via the setting
| `Updated pi from 1.3.0 to 1.4.0` |
| Command `true` (exit 0, installs nothing) | ``Ran true, but `pi
--version` reports 1.3.0, not 1.4.0.`` |
This PR is AI-generated.
Follow-up to #11440 for the latest comment on #11331.
Problem: the ranker scored a tool as one bag of words. A query term in
the tool's name counted like a mention in its description, and the
server's instructions were copied into every tool's text, which
lengthened it and lowered its score. Models often add words to a tool
name, so `peekaboo app` ranked `mcp__peekaboo__app` 14th: other peekaboo
tools mention "app" in shorter descriptions, and its own description
says "applications".
Fix: `Bm25Ranker` scores three fields separately:
- name: a query term in the name counts 3x its IDF; the description part
of BM25 is at most 2.2x, so a name match beats any number of description
mentions;
- description and schema text: BM25 as before, length-normalized on this
field only;
- namespace name, description, and instructions: 0.5x IDF per matching
term, without length normalization.
A tool whose whole local name (last `__` segment) is in the query gets a
bonus worth one more match of every query term, so it mostly wins
(`peekaboo app`, `describe app`, `read thread_read state`). A query that
is exactly a tool's name still ranks it first (#11440). Applies to
`tool_search` and codemode `searchTools()`.
Not included: the comment's second point (loading a deferred tool on a
direct call instead of `Tool X not found`). That error comes from
`prepareToolCall` in `packages/agent`, which has no hook for unknown
tools; it needs an agent-loop API change.
Fixes#11592.
## Problem
WSL starts Windows programs in the current directory, translated to
`\\wsl.localhost\<distro>\<cwd>`. When that path exceeds MAX_PATH, every
Windows program fails to start with `Invalid argument`. pi inherits its
cwd when spawning `powershell.exe`, so in a deep directory:
- clipboard image paste returns `null` (the PowerShell backend fails,
stderr is discarded)
- copy via `powershell.exe` (WSL without WSLg) fails
- opening the browser for sign-in fails (`xdg-open` on WSL runs
`explorer.exe`)
Reproduced on WSL (FedoraLinux-44): `powershell.exe` and `cmd.exe` work
from a 225-char cwd and fail from 230. With an image on the Windows
clipboard, `readClipboardImage()` returns the PNG from `/tmp` and `null`
from a 245-char cwd; with this change it returns the PNG from both.
`LongPathsEnabled=1` does not help.
## Changes
- `runClipboardCommand` and `openBrowser` run from `/` on POSIX. These
tools never use the cwd (temp files are passed by absolute path), and
`/` is always the shortest UNC path. `tmpdir()` was not used because
`$TMPDIR` can itself be deep.
- The external editor keeps pi's cwd on purpose, so project-relative
paths and integrated terminals keep working. A Windows editor such as
`code` can still fail from a deep WSL directory, as with git's
`core.editor`. A comment documents this.
- External editor failures were silent: the UI redrew over the error
immediately. pi now shows `Failed to launch external editor: <cmd>`,
`External editor exited with code N; prompt unchanged`, or the signal
name. This shows in the status line for the main editor and inline in
the extension editor dialog.
## Testing
- New tests: clipboard commands run from `/` (POSIX); an editor that
cannot be launched is reported (POSIX); the existing failure test checks
the message.
- Verified on WSL by running the real modules: clipboard image paste
from a deep cwd, and editor success, exit code 1, launch failure, and
SIGTERM.
- On Windows, the 3 existing `external-editor.test.ts` tests already
fail on `main`: the editor command is split on spaces, so `C:\Program
Files\nodejs\node.exe` breaks. Not addressed here.
This PR is AI-generated.
Fixes#7756.
Problem: `detectInstallMethod()` classifies any path containing `/pnpm/`
as pnpm. An install that only lives under `PNPM_HOME`, for example a
wrapper under `$PNPM_HOME/global-nub/5/node_modules/...`, is detected as
pnpm. `pi update` then correctly refuses to self-update (the path is not
under `pnpm root -g`), but `getSelfUpdateUnavailableInstruction()`
printed:
```
This installation is not managed by a global pnpm install. Update it with the package manager, wrapper, or source checkout that provides it.
```
Fix: name the package manager only when it owns the install (managed but
not writable). All other cases fall through to the neutral message:
```
Update @earendil-works/pi-coding-agent using the package manager, wrapper, or source checkout that provides this installation.
```
`detectInstallMethod()` is unchanged: every user-facing path already
goes through the ownership check, and returning `unknown` would trip the
Windows "only supported for npm and pnpm" guard with a worse message.
The update banner already says `Run pi update` and no longer suggests
`pnpm install -g`.
The pi.dev installer is not affected: it verifies npm ownership with
`npm ls -g` and never infers pnpm from paths.
Tests: regression test in `test/config.test.ts` (fails without the fix).
`npm run check` passes.
This PR is AI-generated.
Plugin discovery, esbuild builds (optional peer dependency), vm evaluation with
pluginFile and mapStack, dependency installs, a watcher with dirty marks and
rollout across hosts; host.load and host.reload per plugin with the closure rule;
examples 35-39 as folders with real plugin directories.
Closes#11532.
The docs and CLI help did not say which working directory a resumed
session runs in. That directory controls tools, `@path` references,
project configuration, and context files.
## Changes
- `docs/sessions.md`: states that a session keeps the working directory
it was created in, adds a table mapping each entry point to its working
directory, and describes what happens when that directory no longer
exists. Removes a paragraph that repeated the `--session`/`--fork`
descriptions.
- `docs/cli.md` and `--help`: clearer `--continue`, `--resume`,
`--session`, `--session-id`, and `--fork` descriptions. The help only
mentions the directory where it is not obvious (`--fork`).
- `/resume` and `/import` command descriptions, mirrored in
`docs/slash-commands.md`. `/import` notes that it preserves the session
ID and working directory.
| Entry point | Working directory |
|---|---|
| `--continue` | The current directory |
| `--resume`, `/resume`, `--session`, `/import` | The session's working
directory, which can differ from the current directory |
| `--fork`, or a `--session` ID from another directory that you choose
to fork | The current directory, in a new session |
Behavior was verified against `createSessionManager` in `src/main.ts`,
`SessionManager.open`/`continueRecent`/`forkFrom`, and
`AgentSessionRuntime.importFromJsonl`.
Adds a `beforeProviderRequest` generation hook so extensions can replace
the provider request body before it is sent. It is the durable
counterpart of coding-agent's `before_provider_request`.
Durable calls `streamSimple()`, so apps can't set provider-specific
fields such as Anthropic's `thinking.display: "updates"` (#10804)
without wrapping `Models`. Now it's an extension:
```ts
hook(GenerationTask, {
beforeProviderRequest: ({ model, payload }) => {
if (model.api !== "anthropic-messages") return undefined;
// change payload
return { payload };
},
})
```
The owner's audit decisions (decisions.md, "Simplification audit"): cut the reload-only
installing state (staging is synchronous) and the static registry cycle edges (the
runtime guard stays); retry propagation by a visited set and a hop budget (retry.hops)
instead of operation ids and through lists; a pure swap sends holds a fresh bound; one
rule after a live failure (every member followed until the admitted commits published);
parked_long for any long park, naming the first blocker in declaration order with its
state; one seal token per attempt. Keys follow the old instance during a restart's drain
like members. src/next: about 255 code lines fewer.