Commit graph

2172 commits

Author SHA1 Message Date
Petr Houška
6d3e342b25
fix(coding-agent): follow local schema refs in tool search and codemode summaries (#11850)
Fixes #11803.

**Problem**

`tool_search` builds a tool's search text from its parameter schema but
never followed `$ref`. MCP servers (Pydantic, FastMCP) often put nested
models in `$defs`:

```json
{ "properties": { "options": { "$ref": "#/$defs/options" } },
  "$defs": { "options": { "properties": { "mode": { "description": "reconciliation" } } } } }
```

Search text was `Inspect records options`, so
`tool_search("reconciliation")` found nothing. Durable's copy of
`tool_search` had the same gap. The codemode `resolves to` summary had
the same blind spot for output schemas: a field behind a `$ref` showed
as `structuredContent?` with no field names.

**Fix**

- pi-codemode exports its existing local JSON Pointer resolver as
`resolveSchemaRef(ref, root)`. A malformed percent escape now resolves
to nothing instead of throwing `URIError` (this also affected
`schemaToType()`).
- coding-agent and Durable `schemaText()` follow local `$ref`s. Each
referenced subschema is read once, so recursive and shared definitions
neither loop nor inflate BM25 term counts. Unreferenced `$defs` and
remote refs stay out.
- coding-agent `describeFields()`/`describeUnionFields()` resolve `$ref`
chains against the output schema root.

**Tests**

- `packages/codemode/test/declarations.test.ts`: escaped, remote,
missing, malformed refs.
- `packages/coding-agent/test/tool-search.test.ts`: refs, escaped
segments, recursion and shared definitions, unused definitions.
- `packages/coding-agent/test/suite/agent-session-codemode.test.ts`:
summary through root, field, const, and union refs.
- `packages/durable/test/tool-search.test.ts`: end-to-end load via a
ref'd description; unused definition does not match.

`npm run check` passes.
2026-11-06 00:19:58 +01:00
Petr Houška
850ea2eae2 docs(coding-agent): changelog for MCP OAuth callback close hang
closes #11311
2026-11-05 23:57:00 +01:00
Petr Houška
80d4d3c869
fix(coding-agent): remove all extension overlays on /reload and session switch (#11853)
## Problem

`InteractiveMode.resetExtensionUI()` runs on `/reload` and before a
session is invalidated (session switch, new session, fork). For overlays
it called `this.ui.hideOverlay()` once, which pops only the topmost
entry of the TUI overlay stack.

Trace: an extension opens two overlays with `ctx.ui.custom({ overlay:
true })`, then the user runs `/reload`. Reset removes the upper overlay;
the lower one stays on screen, gets focus back and keeps receiving
input, although the extension instance that created it is gone. A hidden
overlay (`handle.setHidden(true)`) also stays in the stack and can be
shown again by stale code.

Blindly clearing the whole stack is not correct either: pi opens its own
overlays (the header logo easter egg in `components/easter-egg-3d.ts`).
With the old code, a reset while the easter egg was topmost removed the
easter egg and left the extension overlay.

## Fix

`showExtensionCustom` records each overlay it opens in
`extensionOverlays` (handle -> removal function). The same function
serves `done()` and `resetExtensionUI()`: it hides the overlay, clears
its blocked program status (#11851) and disposes its component. Deleting
the map entry first makes a second call a no-op, so a late `done()`
after reset does not dispose twice. Reset calls it for every recorded
overlay, newest first, including hidden ones. Overlays not opened by
extensions are left alone. This replaces the separate
`blockingOverlaySources` set and reset loop added in #11851.

Pending `ctx.ui.custom()` promises of removed overlays stay unsettled,
matching how reset already treats inline dialogs.

## Testing

- New regression test in `test/interactive-mode-status.test.ts`: a
pi-internal overlay plus three extension overlays (one hidden), then
reset. Checks that the extension overlays are gone and disposed, the
internal overlay remains and receives input. Fails without the fix.
- `test/interactive-mode-status.test.ts`,
`test/interactive-mode-extension-dialogs.test.ts`,
`test/interactive-mode-paste.test.ts`,
`test/suite/regressions/5943-session-start-notify.test.ts` pass.
- `npm run check` clean.
2026-11-05 23:30:54 +01:00
Petr Houška
7f47a2ed09
feat(coding-agent): let ctx.ui.custom() overlays report blocked program status (#11851)
Closes #11816.

## Problem

`ctx.ui.custom()` accepted `blocked` only for inline components.
Overlays ignored it, so an extension question shown as an overlay left
pi reporting `working` over OSC 7501. Example: pi-ask-user in its
default overlay mode inside Herdr App never shows the session as blocked
and never notifies.

## Change

- Each overlay opened with `blocked` registers its own program status
source (`extension-overlay:N`) after `showOverlay()` and clears it when
the overlay calls `done()`.
- The status stays while the overlay is hidden with `setHidden()` or
covered by another overlay: the question is still unanswered.
- Separate sources per overlay, so closing one keeps other open
questions, dialogs and login prompts blocked. With several blocking
prompts, the most recently shown one supplies `kind` and `message`.
- Overlays without `blocked` (loaders etc.) register nothing and cannot
clear another overlay's status.
- `/reload` and session switches drop overlays without calling `done()`;
`resetExtensionUI()` now clears their blocked status so pi does not stay
blocked.
- Inline components are unchanged: they report the dialog shown in the
editor slot, so a non-blocking component shown in its place clears the
status until it closes.
- Docs (`docs/tui.md`, `custom()` JSDoc) and changelog updated.

## Testing

- `npm run check`
- `test/interactive-mode-extension-dialogs.test.ts`: new regression test
(two blocking overlays, one non-blocking overlay, hide, close in order)
and one for clearing the status on reset.

pi-ask-user still needs to pass `blocked` for its overlay prompts.
2026-11-05 23:23:58 +01:00
Armin Ronacher
36142c1c7e fix(ai,coding-agent): remove "(legacy)" marker from OpenAI Codex provider name 2026-11-05 22:51:36 +01:00
Vegard Stikbakke
9b9cb30ae1 feat(coding-agent): add pi auth login and logout
pi auth login <provider> logs in from the shell without starting a
session, like pi mcp login. Account sign-in is the default and takes
the provider's browser method; when the provider has another method,
it prints a hint to rerun with --no-browser. --no-browser skips the
browser method and uses the other one, such as device code, asking
only when there are several. --api-key saves an API key instead and
cannot be combined with --no-browser. After logging in, it fetches the
provider's model catalog, and for Radius it points the Radius MCP
server in the global mcp.json at the login without asking.

pi auth logout <provider> removes the stored credentials like /logout
and succeeds when nothing is stored. For MCP servers that use provider
credentials, pi mcp login and logout now refer to these commands
instead of /login and /logout.

/login shares the check for the Radius MCP server entry and the web
URL helpers with the new command.
2026-11-05 12:07:14 +01:00
robopipi[bot]
5732bac060
fix(coding-agent): cycle to the only model in scope when the active model is outside it (#11832)
Ctrl+P did nothing when the scope held exactly one model and the active
model was a different one. It showed "Only one model in scope" and kept
the current model, because `_cycleScopedModel` returned early for any
list with one entry. `_cycleAvailableModel` had the same early return,
so it also failed when the active model was no longer available and one
other model was.

A second bug had the same cause. When the active model was not in the
list, the code treated it as if it were at index 0, so cycling forward
skipped the first scoped model. With `--models A,B` and another model
active, Ctrl+P went to B.

This change adds a small `getNextCycleIndex` helper that both functions
now use:
- When the active model is not in the list, cycling forward selects the
first entry and cycling backward selects the last.
- It returns `undefined` (the "Only one model…" message) only when the
list is empty, or when its single entry is already the active model.

**Testing**
- Added a regression test to
`test/suite/agent-session-model-extension.test.ts`. It covers a
one-model scope, a repeated cycle once that model is active, and forward
and backward cycling into a two-model scope from outside it. The test
fails without the change ("expected undefined to be 'faux-2'") and
passes with it.
- `npm run check` passes.
- Manual check: `pi --model anthropic/claude-sonnet-4-5 --models
anthropic/claude-opus-4-5`, then Ctrl+P.
  - Before: "Only one model in scope".
  - After: "Switched to Claude Opus 4.5".
- With `--models anthropic/claude-opus-4-5,anthropic/claude-haiku-4-5`,
Ctrl+P now selects opus instead of haiku.

Fixes #11806

Requested by @davidbrai

Co-authored-by: triagent <triagent@localhost>
Co-authored-by: David Brailovsky <davidbrai@gmail.com>
2026-11-05 10:39:18 +01:00
Vegard Stikbakke
a013448fb5 feat(coding-agent): merge consecutive cache warming notices into one line
Back-to-back cache warms update a single 'Cache warmed: $<total>' line with the cumulative cost instead of adding one line per warm. Each warm is still persisted as its own usage entry.
2026-11-05 09:24:57 +01:00
Petr Houška
b2cb629237
fix(coding-agent): wait for a started OAuth refresh to be saved before exit (#11812)
Fixes #11692.

## Problem

In RPC mode the startup catalog refresh runs in the background and also
refreshes an expired OAuth credential. If pi exits after the token
request went out but before the new token is written, the provider has
already spent the old single-use refresh token and auth.json keeps only
that one. The next request fails with `invalid_grant`.

Trace: `pi --mode rpc` starts, `get_state` is answered, catalog refresh
sends the token request, the client sends SIGTERM (or closes stdin),
`shutdown()` runs `runtimeHost.dispose()`, which does not know about the
refresh, and the process exits. Same for print and interactive mode, and
for a prompt-time refresh cut off by an exit.

## Fix

- `RuntimeCredentials.modify()` tracks every pending modification (same
idiom as the MCP `track()` / `McpAuthProvider.settled()`).
- `RuntimeCredentials.settled()` /
`ModelRuntime.settleCredentialWrites()` wait for them.
- `AgentSessionRuntime.dispose()` waits for them. All modes exit through
`dispose()`. No extra timeout: the OAuth refresh (15 s) and the
auth.json lock wait (30 s) are already bounded, and a second signal
still exits at once.

Not changed: the catalog refresh still refreshes expired OAuth
credentials, also for providers whose catalog does not use them (the
pi.dev catalog for `openai`, `anthropic`, ...). Skipping that would need
a per-provider "catalog needs auth" signal in pi-ai; it is optional now
that exits wait.

`docs/rpc.md` now says that pi waits for pending work before exiting.

## Testing

- `test/runtime-credentials.test.ts`: `settled()` waits until a pending
modification is written, and resolves after a failed one.
-
`test/suite/regressions/11692-dispose-waits-for-oauth-refresh.test.ts`:
`dispose()` waits until the token from a background catalog refresh is
saved. Fails without the fix.
- Real process: `pi --mode rpc` with an extension OAuth provider
(expired credential, 400 ms refresh), SIGTERM about 100 ms after the
token request started. Without the wait: 3 of 3 runs kept the spent
refresh token. With the fix: 3 of 3 saved the new one (exit 143).
Closing stdin instead of SIGTERM: new token saved, exit 0.
- `npm run check`; `test/rpc*`, `test/print-mode`,
`test/model-runtime*`, runtime and regression suites pass.
2026-11-05 00:32:41 +01:00
Petr Houška
06aa6563e4
fix(coding-agent): report the shell exit code when bash times out after the shell exited (#11813)
Fixes #11613.

## Problem

The bash tool waits for stdout/stderr to close, not only for the shell
to exit (#5303). A background process that inherited those pipes and
keeps writing keeps the tool waiting after the shell exited. When the
timeout then hits, the tool reported only `Command timed out after N
seconds`.

Example, timeout 3s:

```bash
bash -c 'while true; do echo tick; sleep 0.05; done' & sleep 1; echo boom >&2; exit 1
```

The shell exits with code 1 after ~1s. The tool still times out at the
deadline, because the loop is still writing to its output. Both facts
are true: the command failed with code 1, and something it started was
still running at the timeout. The old message dropped the first, so the
model read a failed command as a slow one and retried with a longer
timeout, which never helps here.

## Fix

The timeout stays a timeout, since the tool did hit its deadline while
background processes were still writing. When the shell had already
exited before the deadline, the message now also says so:

```
Command timed out after 3 seconds. The shell exited with code 1 after 1.1 seconds, but background processes it started kept writing output until the timeout.
```

A shell still running at the deadline keeps the plain `Command timed out
after N seconds`.

The local backend throws a module-private `ShellTimeoutError` with the
message `timeout:<seconds>` as before, so custom `BashOperations` that
throw `timeout:N` are unaffected.

## Behavior per platform

- Linux and macOS: the shell runs in its own process group (`detached:
true`). At the deadline `kill(-pgid)` still reaches the background
processes after the shell exited, the pipes close, and the tool returns
at the deadline. Tested on Linux; macOS uses the same mechanism and
matches the macOS repros in the issue.
- Windows: still not fully solved. At the deadline `taskkill /T /PID
<shell>` fails with `process not found`, because the shell pid is gone.
Walking parent pids does not help: Git's `bin\bash.exe` is a launcher
for `usr\bin\bash.exe`, and the orphaned loop's parent is that
already-exited inner bash, not the pid pi holds. The tool keeps reading
until the output pauses for 100ms. Measured with Git Bash: the message
is now correct, but the tool returned after 14s instead of 3s; with a
loop that never pauses it never returns. This PR only fixes the
reporting.

## Same pattern elsewhere

- `packages/durable/src/env/node.ts` (`NodeExecutionEnv.exec`): same
code, same false `timeout:N` after the shell exited. Same Windows
limitation.
- `packages/coding-agent/src/core/exec.ts` (`pi.exec` for extensions):
the timeout sends SIGTERM/SIGKILL to the direct child only, no process
group. A descendant that keeps writing keeps the call waiting past the
timeout on every platform, and the result has `killed: true` even when
the child had already exited with its own code.
- `packages/coding-agent/src/core/package-manager.ts` (capture commands
for git/npm): waits for `close` and kills only the direct child on
timeout, so a descendant holding the pipes makes the timeout ineffective
on every platform, even when the descendant writes nothing.
- `packages/mcp/src/transports/stdio.ts`: on Windows, closing a server
that already exited skips taskkill, so children holding its pipes
survive. The code documents this as a known limitation.

Possible follow-up for Windows and the cases above: once the deadline
passed, stop waiting for the pipes (destroy our ends). On Windows this
also ends most writers, which die from a broken pipe (tested with Git
Bash).

## Tests

- `test/suite/regressions/11613-bash-timeout-after-shell-exit.test.ts`
(Unix only; it would hang on Windows for the reason above): background
writer after the shell exits with code 3, and a shell still running at
the deadline. The first case fails without the fix.
- Checked manually on Windows 11 with Git Bash through the built bundle:
both messages are as above.
- `npm run check`, `test/tools.test.ts`, the #5303 and #5208
regressions, `bash-close-hang-windows`, `powershell-tool`,
`shell-kill-process-tree`.

This PR description is AI-generated.
2026-11-04 23:47:16 +01:00
Petr Houška
3a7bb6f48c
fix(coding-agent): stream the read tool instead of loading whole files (#11750)
## Problem

`read` loads the whole file before it applies `offset`/`limit`:
`ops.readFile` -> `toString("utf-8")` -> `split("\n")`. A one-line read
still holds the whole file three times: as a Buffer, as a string and as
an array of lines.

Measured on main with a 200 MB log (2.36M lines):

- `offset: 1, limit: 1` peaks at 638 MB RSS. With
`--max-old-space-size=256` it crashes in `Runtime_StringSplit` (OOM).
- Files over about 512 MB fail with `Cannot create a string longer than
0x1fffffe8 characters`. Files over 2 GB fail with
`ERR_FS_FILE_TOO_LARGE`.
- Aborting only rejects the promise; the read keeps running in the
background.

## Solution

- `ReadOperations` gets a new optional `createReadStream(absolutePath, {
signal })`, which returns an `AsyncIterable<Uint8Array>` (a Node
`Readable` works). The default local operations open the file once,
`fstat` the handle, and stream from it. Like `fs.readFile`, they stop a
regular file at its size when opened: text appended during the read is
not counted, and a file that keeps growing still finishes. A file that
reports size 0 (such as `/proc` files) and other file types (FIFOs,
devices) are read until EOF, as `fs.readFile` reads them. No existing
member changed.
- The text path decodes the chunks with a streaming UTF-8 `TextDecoder`
(non-fatal, `ignoreBOM: true`). That matches `Buffer.toString("utf-8")`,
including invalid bytes and a UTF-8 byte order mark kept as U+FEFF.
- It counts every line but stores only the selected lines `truncateHead`
can show. Storing stops once the stored lines are past
`DEFAULT_MAX_LINES` or `DEFAULT_MAX_BYTES`, plus one more line, and the
existing `truncateHead` runs on the stored lines.
`totalLines`/`totalBytes` are then patched from the counts, as grep and
find do on main.
- The extra line is needed because `truncateHead` drops a trailing empty
line. Example: 2001 short lines, then an empty line, then more text. If
storing stopped at the empty line, the stored text would end in `\n`.
`truncateHead` would count 2000 lines and report "not truncated", but
the full text is truncated by lines.
- A line over 50KB is never stored whole; only its size is tracked. A
first line of 1 GB therefore still gives today's `[Line 1 is ...,
exceeds 50.0KB limit ...]` notice.
- Abort: the signal is passed to the stream, and the loop also checks it
between chunks. Leaving the loop closes the stream.
- The whole-file path stays unchanged for custom operations without
`createReadStream`, and for fractional `offset`/`limit` or negative
`limit`. Array slicing defines what those odd ranges select; for
example, `limit: -1` selects all lines but the last.
- Images are unchanged.

Output is the same as main's, byte for byte, including line counts,
notices, details (including key order) and errors. A file appended to
during the read gives the same output too: both read a regular file up
to its size when opened. Out of scope: the trailing-newline off-by-one
in line counts from #7329 is kept as is.

## Testing

- `test/read-streaming.test.ts` keeps a copy of the old text algorithm
as the reference. It compares the streaming path against it on text,
details and error messages, with chunks of 1 and 4096 bytes and offsets
and limits around the boundaries. The cases are: empty file, trailing
newline or not, CRLF, multi-byte characters and a UTF-8 BOM, 2001 lines,
and a first line over 50KB. There are also edge cases where the line or
byte cap falls exactly on an empty line, and the 50KB + trailing newline
case that `truncateHead` reports as `truncatedBy: "lines"`. Further
tests:
  - details key order
  - multi-byte characters split across chunks, and invalid UTF-8
  - the `readFile` fallback, including fractional and negative ranges
  - local files through the default stream, including EISDIR
  - abort: the stream is closed and no more chunks are pulled
- a local file appended to mid-read (a decode hook in the test appends
after the first chunk): `limit: 1` still says `[1 more lines ...]`. This
test fails with an unbounded `fs.createReadStream`.
- bounded memory: a child process with `--max-old-space-size=64` reads a
200 MB single line, generated in memory as 64 KB chunks from a custom
`createReadStream`, and must print the size notice. It takes about 0.8 s
and uses 17 MB of heap. If over-long lines are stored whole, it runs out
of memory. 64 KB chunks matter here: V8 keeps decoded strings of 1 MB
outside the heap limit, so with 1 MB chunks the test would not catch
that regression.
- With main's `read.ts`, 5 of the 8 tests fail: matrix, key order,
chunked decoding, bounded memory and abort. The fallback, local-file and
append tests pass on main because they check behavior that did not
change. Removing the one-line-past-the-cap rule, or the
trailing-empty-line adjustment of `totalLines`, makes the matrix fail.
- `test/tools.test.ts`, `read-line-range-display`,
`tool-execution-component`, `block-images` and `image-resize-callers`
pass. `npm run check` is clean.

Measured with an ad-hoc script on a 200 MB log. Baseline RSS after
loading modules is about 121 MB.

| Case | main | this PR |
| --- | --- | --- |
| offset 1, limit 1 | 1028 ms, 638 MB peak RSS | 478 ms, 131 MB |
| offset 2000000, limit 10 | 926 ms, 637 MB | 486 ms, 132 MB |
| no offset/limit (counts all lines) | 1561 ms, 1010 MB | 524 ms, 132 MB
|
| offset 1, limit 1, 256 MB heap | OOM crash | 131 MB |
| 1.1 GB single-line file | string length error | 2.2 s, 130 MB,
line-size notice |

Remaining difference: custom operations that only implement `readFile`
keep the whole-file memory use, and their reads continue after an abort,
as before.

This PR is AI-generated.
2026-11-04 23:14:52 +01:00
Petr Houška
9e870f6f00
fix(coding-agent): do not split surrogate pairs when truncating text (#11757)
Several places truncate text by UTF-16 code units with `slice()`. When
the cut lands between the two halves of a surrogate pair (an emoji or
other character outside the Basic Multilingual Plane), the result keeps
a lone high or low surrogate. That is invalid UTF-16.

Example: a grep match line with 499 ASCII characters followed by `😀` is
cut at 500 units, so the output ends in `\uD83D... [truncated]`.

The bundled providers strip lone surrogates before sending
(`sanitizeSurrogates` in pi-ai), so the usual effect is a dropped
character. But the broken text still reaches the session file, RPC and
JSON clients (strict UTF-8 encoders such as Rust's serde_json reject
lone surrogates), extensions, and custom providers.

This adds `sliceHead(text, n)` and `sliceTail(text, n)` to
`core/tools/truncate.ts`. They behave like `slice(0, n)` and `slice(-n)`
but return one unit fewer when the cut would split a pair, and are used
where truncated text reaches the model:
- `truncateLine` (grep match lines), the case reported in #7121
- codemode `truncateOutput` head and tail, and its argument and error
previews
- compaction `truncateForSummary` (tool results sent to the summarizer)
- MCP server summaries in the system prompt and stderr tails in
connection errors

The cost is two `charCodeAt` checks per truncation, and nothing on the
untruncated path.

The surrogate check follows the fix in #7122 by @IKEASven69, which
@cristinaponcela reviewed in a follow-up branch. That PR was closed
before merging.

Not changed: TUI-only previews (renderers, visual truncation) and
line-based slicing. pi-private's pi-durable codemode `output.ts` has the
same head/tail slicing and needs a separate change there.

**Testing**
- New `test/truncate-surrogates.test.ts`: an emoji at the cut in
`truncateLine` is dropped whole, an emoji ending exactly at the limit is
kept, `sliceHead`/`sliceTail` never leave a lone surrogate for any
length, and they match `slice` on text without surrogates.
- `test/tools.test.ts`, compaction, MCP extension, and codemode tests
pass.
- `npm run check` passes.

fixes #7121

This PR is AI-generated.
2026-11-04 20:59:46 +01:00
Armin Ronacher
d84123b326 feat(coding-agent): move provider defaults to newer models at the same or a lower price 2026-11-04 20:08:36 +01:00
Armin Ronacher
b425754a23 feat(coding-agent): default Anthropic model is Claude Opus 5.5 2026-11-04 20:00:55 +01:00
Armin Ronacher
4d72d05bcd fix(coding-agent): send provider tokens in pi mcp list
Servers with auth.provider, such as Radius, were reported as needing sign-in because the shell command sent no token. pi mcp now reads and refreshes the provider's /login token, and its hints and login/logout point to /login <provider>.
2026-11-04 17:59:41 +01:00
robopipi[bot]
081a6e87f6
fix(coding-agent): share the auth.json lock across symlinked paths (#11787)
`FileAuthStorageBackend` locked `auth.json` with proper-lockfile and
`realpath: false`, so the lock directory was `<path as given>.lock`.
Reads and writes follow symlinks to the same real file, but each path
alias got its own lock. For example, an agent dir whose `auth.json` is a
symlink to a shared file used a different lock than the real file. Two
pi processes could then enter the credential-modification section at the
same time and refresh the same OAuth token concurrently.

This change drops `realpath: false` from the sync and async lock calls,
so proper-lockfile resolves the path and every alias of the file uses
one lock. Resolving the path needs the file to exist, which is already
the case: `ensureFileExists()` runs before either lock is taken.

The same `realpath: false` pattern is also in `settings-manager.ts`,
`trust-manager.ts`, and `package-manager-cli.ts`. This change leaves
those alone.

## Testing

- I added a test to `test/auth-storage.test.ts`. It holds the lock on
the real `auth.json`, then checks that a backend opened through a
symlink alias waits in `withLockAsync` until aborted, and gets `ELOCKED`
from `withLock`. Neither path runs the mutation. Without the fix the
test fails because the async call enters the lock; with the fix it
passes.
- `node ../../node_modules/vitest/dist/cli.js --run test/auth-storage
test/settings`: 151 tests pass.
- `npm run check` passes.
- With the reproduction script from the issue, current main prints
`symlink alias: ENTERED`; with this change it prints `symlink alias:
BLOCKED`. The same-path control prints `BLOCKED` in both cases.

Fixes #11655

Requested by @badlogic

Co-authored-by: triagent <triagent@localhost>
Co-authored-by: Mario Zechner <contact@badlogicgames.com>
2026-11-04 09:57:47 +01:00
robopipi[bot]
4cf69332c3
fix(coding-agent): resize images in queued steering and follow-up messages (#11782)
Images in idle prompts go through `processImage`: they are resized to
the model's limits, and a coordinate hint is added to the text. Images
in messages queued while the agent was busy skipped this step. That
covers `steer()`, `followUp()`, `prompt()` with `streamingBehavior`, and
RPC `steer`/`follow_up`. Those images were sent and saved at full size.
For example, a 1200x9000 PNG stayed 1200x9000, which is over Anthropic's
8000 px limit, and because it is saved in the session it was replayed on
every later request.

What the change does:
- `_queueSteer`/`_queueFollowUp` now mark queued user messages that
carry images.
- When such a message is delivered (`message_start`), `AgentSession`
normalizes its images in place with `_normalizePromptImages`, the same
code the idle prompt path uses, and appends the resize hints to its
text. This runs before extensions and listeners see the message, before
it is saved, and before it is added to the request context.
- Doing it at delivery keeps enqueueing synchronous. A slow resize
therefore cannot leave a message queued after the run has ended. I hit
exactly that when I first tried normalizing at enqueue time. Delivery is
also when the resize uses the model current at that moment.
- The queue display still matches and removes the message, because
matching runs before the text is changed.

Testing:
- New `test/agent-session-queued-images.test.ts`: a 1200x9000 PNG is
queued while the agent is streaming, through `steer()`, `followUp()` and
`prompt(..., { streamingBehavior: "followUp" })`. The test checks three
things: each saved message has a resized image and the "original
1200x9000, displayed at 267x2000" hint; no request sent to the model
contains the original image; and the queue is empty afterwards. It fails
without the change and passes with it.
- `npm run check` passes. The `agent-session*` tests, the queue- and
image-related tests and `test/suite` pass.
- Before the change, the saved steer and follow-up messages had
`image/png 1200x9000` while the idle prompt had a resized JPEG. After
it, all three are resized.

Fixes #11738

Requested by @badlogic

Co-authored-by: triagent <triagent@localhost>
2026-11-04 09:36:54 +01:00
Petr Houška
676af1d777
fix(coding-agent): find and ls limit edge cases (#11747)
Some checks failed
CI / build-check (push) Waiting to run
CI / test (coding-agent) (push) Waiting to run
CI / test (durable) (push) Waiting to run
CI / test (other) (push) Waiting to run
CI / mcp-conformance (push) Waiting to run
Environments / daemons (push) Has been cancelled
Environments / tests (macos-latest) (push) Has been cancelled
Environments / tests (ubuntu-24.04-arm) (push) Has been cancelled
Environments / tests (ubuntu-latest) (push) Has been cancelled
Environments / tests (windows-11-arm) (push) Has been cancelled
Environments / tests (windows-latest) (push) Has been cancelled
Environments / check (push) Has been cancelled
Environments / release-daemons (darwin-arm64, macos-latest) (push) Has been cancelled
Environments / release-daemons (darwin-x64, macos-latest) (push) Has been cancelled
Environments / release-daemons (linux-arm64, ubuntu-24.04-arm) (push) Has been cancelled
Environments / release-daemons (linux-x64, ubuntu-latest) (push) Has been cancelled
Environments / release-daemons (windows-arm64, windows-11-arm) (push) Has been cancelled
Environments / release-daemons (windows-x64, windows-latest) (push) Has been cancelled
Three limit problems in the find and ls tools.

**1. `limit: 0` and other values below 1.** find passed `limit` to fd
unchanged. fd treats `--max-results 0` as no limit, so `find({ pattern:
"*", limit: 0 })` returned every match, followed by `[0 results limit
reached. Use limit=0 for more, or refine pattern]`. ls with `limit: 0`
listed nothing and printed `(empty directory)`. A fractional limit such
as `1.5` printed `1.5 results limit reached. Use limit=3`.

Fix: both tools use `Math.max(1, Math.floor(limit ?? DEFAULT_LIMIT))`.
grep already clamps to at least 1. The fd path and the custom `glob`
path share this value.

**2. Notice at exactly N results (#7121).** With 3 matching files and
`limit: 3`, fd returned 3 results. find checked `results >= limit` and
printed `[3 results limit reached. Use limit=6 for more, or refine
pattern]`, even though there were no more files.

Fix: find asks fd for `limit + 1` results, shows the first `limit`, and
prints the notice only when the extra result exists. ls already worked
this way. Custom `FindOperations.glob` now also gets `limit + 1`. The
tool shows at most `limit` of the returned results and prints the notice
only when more were returned. The interface is unchanged. This follows
closed PR #7122 without its unrelated changes.

**3. Results past the 50KB cap.** find kept every fd line in memory.
`truncateHead` then dropped everything past 50KB. find now handles each
line as it arrives. It keeps lines up to the first one past the cap and
only counts the rest, as grep does in #11705. Because that first line
past the cap is kept, `truncateHead` makes the same truncation decision
as before. `details.truncation.totalLines` and `totalBytes` come from
the counts, so the notices and `details` are unchanged.

Behavior differences:
- A custom `glob` that ignores `options.limit` and returns more results
used to have all of them shown. Now the tool shows the first `limit`
results with the limit notice.
- Custom `glob` implementations get `limit + 1` instead of `limit`.
- In the fd path, output made only of blank lines with exit code 0 now
gives `No files found matching pattern` instead of empty text. fd does
not produce such output.

**Testing**
- New tests in `test/tools.test.ts`:
- find: exactly N matches gives no notice, and N+1 matches give the
notice with N lines (#7121). Fails on main.
- find: `limit` 0, -5, 0.5 and 1.9 each show 1 result with the `1
results` notice. Fails on main.
- find with custom `glob`: the operation gets `limit + 1`, and exact and
over-limit results give the right output. Fails on main.
- find past the byte cap: 300 names of 200 bytes each.
`details.truncation` equals `truncateHead` of the full output, except
for `content`. Passes on main too; it guards the counting. It fails if
the `totalLines` override is removed.
- ls: `limit` 0, -5, 0.5 and 1.9 each list 1 entry with the notice.
Fails on main.
- ls: exactly N entries gives no notice. Passes on main too; it guards
the existing behavior.
- `test/tools.test.ts` and the find regression suites 3302, 3303 and
6104 pass (110 tests). `npm run check` passes.

fixes #7121

This PR is AI-generated.
2026-11-03 23:56:31 +01:00
Petr Houška
8da141c2ad
fix(coding-agent): take grep context lines from ripgrep instead of reading matched files (#11705)
When `context` is set, the grep tool read every matched file whole and
split it into lines, only to print the lines around each match. Memory
grew with the size of the matched files, not the output. In the issue's
reproduction (8 log files of about 95 MB, one match each, `context: 2`,
`--max-old-space-size=1024`), the process ran out of heap after about 10
s.

ripgrep already supports context. This change passes `--context N` to rg
and formats the `context` events from its JSON output. The tool no
longer opens matched files.

Details:
- After the last allowed match (`limit`), rg still sends that match's
trailing context. The tool keeps those lines, then stops rg. A match
inside that window is shown as a context line, as before.
- Lines past the 50 KB output cap are counted but not stored, since
truncation drops them anyway. Match counts, the match-limit notice, and
`details.matchLimitReached` stay as before.
- rg merges overlapping context, so lines shared by nearby matches are
printed once instead of repeated per match. This is the one intended
output change.
- Context lines are decoded by rg, so UTF-16 files with a BOM show
correctly with context (#11423, closed as no-action). Non-UTF-8 lines
and paths, which rg sends base64-encoded, are decoded too.

Two related grep fixes in the same code:
- **Exact match limit:** with exactly N matches and `limit: N`, grep
said "N matches limit reached. Use limit=2N". It now sets the notice and
`details.matchLimitReached` only after rg sends one more match. Cost:
when no further match exists, rg finishes the search instead of being
stopped at the Nth match, the same as a search with fewer than N
matches.
- **Lone CR:** grep removed every `\r` in a line, so `one\rtwo needle`
showed as `onetwo needle` and text copied from grep failed to match in
edit. It now strips only the line terminator (`\n` or `\r\n`), like the
read tool. The TUI still hides CRs when rendering. This updates the
expectation in the #11398 test from `beforeprogress` to
`before\rprogress`.

Breaking: `GrepOperations.readFile` is removed because nothing uses it
anymore. rg always ran locally, so this hook never made grep work on a
remote backend. Docs now say to override `execute()` for remote search,
as the Gondolin example does. Code that passes `readFile` still runs,
and the value is ignored. Only code that type-checks an object literal
against `GrepOperations` fails to compile.

Known remaining differences and limits:
- Memory no longer grows with file size or match count, but a single
huge matching line (tens of MB) is still buffered whole before being cut
to 500 characters. That was already true for `context: 0` on main.
Bounding it would mean discarding oversized rg JSON records, which is a
separate change.
- In a directory search, rg stops reading a file once it finds a NUL
byte, so context past that point in a text-looking file that turns
binary later is omitted. main reread the file and printed it. Explicit
file paths are unaffected.
- `details.truncation.totalLines`/`totalBytes` count formatted rows.
With deduplicated context, or file names containing newlines, they can
differ from main. No renderer or notice displays them.

**Testing**
- Issue reproduction: before, `FATAL ERROR ... JavaScript heap out of
memory`. After, correct output in about 60 ms with 135 MB RSS.
- `pattern: "line", context: 3, limit: 100000` over the same 770 MB of
logs reports both limit notices and peaks at 154 MB RSS.
- New tests in `test/tools.test.ts`:
- Merged overlapping context, plus trailing context at the limit
including a match inside it. Fails on main.
  - UTF-16 with context. Fails on main.
- Truncation reported when stored output ends exactly at 50 KB. Guards
the counting logic.
- Matches still counted past the byte cap. Passes on main too; it guards
against stopping rg early at the cap.
  - Huge `context` value accepted.
- No limit notice with exactly `limit` matches (with and without
context), notice with more. Fails before this change.
- Lone CR kept and CRLF stripped, with and without context. Fails before
this change.
- All 94 tests in the file pass. `npm run check` passes.

fixes #9276

This PR is AI-generated.
2026-11-03 23:43:40 +01:00
Petr Houška
eebd5d90ca
feat(coding-agent): let Markdown transformers opt into more surfaces (#11756)
Problem: `pi.registerMarkdownTransformer()` only runs on user, assistant
and thinking Markdown. Pi renders other Markdown that extensions cannot
reach: custom messages, skill blocks, branch and compaction summaries,
and the changelog notices (What's New, `/changelog`, Extension Updates,
the Update Available note). For example, a transformer cannot rewrite
text in an extension's changelog shown under "Extension Updates",
because that call site passes no transform.

Running every transformer everywhere would break existing ones. The
documented example only skips thinking blocks and replaces `-->` with
`→`; on a changelog it would turn `<!-- note -->` into `<!-- note →`.

Solution: an optional `{ messageTypes }` lists the surfaces a
transformer runs on. Without options it runs on the same three types as
before.

| `messageType` | Surfaces |
|---|---|
| `user`, `assistant`, `assistant-thinking` | transcript messages
(default) |
| `custom` (new) | custom messages without a message renderer; expanded
skill blocks |
| `summary` (new) | expanded branch and compaction summaries |
| `changelog` (new) | What's New, `/changelog`, Extension Updates, the
Update Available note |

```typescript
pi.registerMarkdownTransformer((markdown) => markdown.replaceAll("⚠️", "⚠"), {
  messageTypes: ["changelog"],
});
```

Details:
- Filtering happens in one place,
`ExtensionRunner.getMarkdownTransformers()`: each transformer is wrapped
so Markdown of unlisted types passes through unchanged. Components still
take a plain list of transformers.
- Skill blocks and summaries pass only their content to transformers; pi
adds its bold header after the transform.
- Custom, skill and summary messages now get `messageId` and `timestamp`
when loaded from the session. Changelog notices have no session entry,
so both are `undefined`.
- The built-in Mermaid transformer renders in user and assistant text
and now also in summaries, which are model-written like assistant text.
It still skips thinking, custom messages and changelogs.
- `/hotkeys` does not run transformers.
- New exports: `MarkdownMessageType`, `MarkdownTransformerOptions`,
`MarkdownTransformerRegistration`.

Tests: runner type filtering (default vs. opted in), custom message,
skill and summary components (type, header excluded, `messageId`),
Mermaid on the new types, and startup notices running `changelog`
transformers on What's New and Extension Updates while default
transformers are skipped.

Related: #11710 (HTML comments in Extension Updates). #11755 already
fixed it in the renderer; this PR lets extensions transform those
notices in general.
2026-11-03 23:10:47 +01:00
Petr Houška
ace560c302
fix(coding-agent): let a started RPC or print shutdown finish; only a repeated signal forces exit (#11752)
Follow-up to #11746 for the case @fcatuhe reported on #11599: a second
shutdown trigger cut off cleanup.

Problem: Paseo stops pi by closing stdin and sending SIGTERM in the same
tick (SIGKILL 2 s later); systemd sends SIGTERM and stdin closes after.
In RPC mode:

- stdin EOF, then SIGTERM: `shutdown()` removed pi's SIGTERM handler
when it started, so the signal killed pi (exit 143) mid-`dispose()`.
- SIGTERM, then stdin EOF: the second `shutdown()` call ran
`process.exit()`.

A pending MCP token refresh was lost: the server had rotated the refresh
token, the next pi sent the old one, and the server revoked the grant.

Rule after this change: once a shutdown has started, a stdin EOF or a
single signal does not interrupt it; a second signal exits at once.
SIGKILL still always works.

- **RPC mode** (`rpc-mode.ts`): signal handlers stay registered until
exit; `shutdown()` runs once and later calls do nothing; the signal
handler counts signals and exits only on a repeated one.
- **Print mode** (`print-mode.ts`): the final `finally` removed the
signal handlers before `disposeRuntime()`, so a signal during the final
disposal killed pi. Handlers are now removed after it,
`disposeRuntime()` returns the shared in-flight disposal (a signal then
waits for it), and a repeated signal exits at once, as before.
- **Interactive mode**: unchanged. It already lets a started shutdown
finish.

Docs (`docs/rpc.md`) and the CHANGELOG are updated.

## Testing
- `test/rpc-prompt-response-semantics.test.ts`: EOF then SIGTERM,
SIGTERM then EOF (both wait for a slow dispose, exit once with 0 and
143), and EOF then two SIGTERMs (exits at once with 143). All three fail
on main.
- `test/print-mode.test.ts`: SIGTERM during the final disposal waits for
it, a second SIGTERM exits with 143, handlers are removed afterwards.
Fails on main.
- Real process, `pi --mode rpc` with a `session_shutdown` handler that
takes 1 s and real signals:
  - EOF then SIGTERM: cleanup done, exit 0 (main: no cleanup, exit 143)
  - SIGTERM then EOF: cleanup done, exit 143
  - EOF then two SIGTERMs: exit 143 at once
- `npm run check` passes; all `test/rpc*` and `test/print-mode` tests
pass.
2026-11-03 22:21:44 +01:00
Petr Houška
9a19539617
fix(coding-agent): ignore prerelease suffix of last seen changelog version (#11753)
Fixes #11703.

**Problem:** `getNewEntries()` parsed the saved version with
`split(".")`. `0.1.5-20261020.7` became `["0", "1", "5-20261020", "7"]`,
`Number("5-20261020")` is `NaN`, and `|| 0` turned it into `0.1.0`. The
`[0.1.1]` to `[0.1.5]` notes counted as new, were shown, and the same
prerelease string was saved again, so they repeated on every fresh
launch.

**Fix:** read only the leading `major.minor.patch` of the saved version,
the same way changelog headings are parsed. `0.1.5-20261020.7` and
`0.1.5+build.3` now count as `0.1.5`.

If the saved version has no `major.minor.patch` (for example `latest`),
nothing is shown and the installed version is saved in its place, like
on a fresh install. Before, it was read as `0.0.0` and the whole
changelog was shown. Saving the installed version matters because
callers only save when there are new entries, so an unreadable value
would otherwise silence the changelog for good.

Alternatives considered (noted in a code comment):
- plain string comparison: orders `0.10.0` before `0.9.0`
- `localeCompare` with `numeric: true`: works only because `0.1.5-x`
happens to sort after `0.1.5`, and depends on locale collation
- strict semver: orders `0.1.5-x` before `0.1.5`, so the `[0.1.5]` notes
would still repeat forever

Tests added in `test/changelog.test.ts` and
`test/interactive-mode-extension-changelog.test.ts`. `npm run check`
passes.
2026-11-03 21:59:05 +01:00
robopipi[bot]
1708b47889
fix(coding-agent): process idle ctx.shutdown() requests in RPC mode (#11746)
In RPC mode, `shutdownHandler` only set a `shutdownRequested` flag. That
flag was checked in two places: after a stdin command was handled and on
`agent_settled`. If an extension called `ctx.shutdown()` while the
session was idle (for example from a timer), neither check ever ran. The
process stayed alive until the client sent another command. Interactive
mode already shuts down right away in this case.

Changes in `packages/coding-agent/src/modes/rpc/rpc-mode.ts`:
- **One check for all paths:** `checkShutdownRequested()` shuts down
only when a request is pending, input is attached, no command is being
handled, and the session is idle. Each of those ending calls it again:
command completion, prompt settle, `agent_settled`, and input attach.
- **Idle requests:** the shutdown handler schedules the check on the
next tick, so the requesting handler finishes first. The conditions are
checked when the tick runs, not when it is scheduled: a stdin command
that arrives in between is handled first, and shutdown follows when it
finishes.
- **Requests during startup:** a request made before input is attached
(for example a synchronous `ctx.shutdown()` in `session_start`) is
checked once the input reader is attached.
- **Requests during a command:** shutdown waits until the command
finishes, including extension commands. A `prompt` command returns
before `session.prompt()` settles, so it stays counted as in flight
until the prompt settles, and the shutdown check runs then. This also
fixes an existing ordering problem: an extension command that called
`ctx.shutdown()` could exit the process before its `prompt` response was
written.
- **Repeated requests:** `checkShutdownRequested()` does nothing once
shutdown has started, so a second `ctx.shutdown()` does not call
`process.exit` in the middle of async cleanup. A second stdin EOF or
signal still forces exit, as before.

Shutdown requested during a running turn is still handled on
`agent_settled`, as before. A request made while an extension-started
compaction runs (not idle, no run to settle) still waits for the next
command or run; `docs/rpc.md` and `docs/extensions.md` note this.

Docs (`docs/rpc.md`, `docs/extensions.md`) and the CHANGELOG are
updated.

## Testing
- Added three tests to `test/rpc-prompt-response-semantics.test.ts`:
  - an async `ctx.shutdown()` on an idle session exits;
- an extension command that calls `ctx.shutdown()` gets its `prompt`
response written before exit;
- a command that arrives after an idle request but before the scheduled
check runs gets its response written before exit.

  All three fail without the change and pass with it.
- `npm run check` passes. All `test/rpc*` tests pass.
- In `./test.sh`, all coding-agent tests pass. The only failures were
timeouts in durable/harness tests (`store-storage`, `tools-images`,
connection close), which this change does not touch.
- Reproduction from the issue: run `pi --mode rpc --no-session -ne -e
./shutdown-probe.mjs` with stdin held open and nothing sent.
- Before the change, the process stayed alive until a timeout killed it
(exit 124).
- After the change, `session_shutdown` fires and the process exits with
code 0.
- A synchronous `ctx.shutdown()` in `session_start` also exits with code
0.

Fixes #11599

Requested by @petrroll

---------

Co-authored-by: triagent <triagent@localhost>
Co-authored-by: Petr Houška <houskape@gmail.com>
2026-11-03 21:27:14 +01:00
Armin Ronacher
797e122cb1 fix(mcp): refresh with the app's redirect URI, cancel sign-in during the lock wait
Tokens from a Client ID Metadata Document sign-in through an https
callback route were refreshed with the loopback redirect URI, which the
documents do not list, so every expiry needed a browser sign-in.
createMcpAuthProvider takes the app's redirectUrl and startMcpSignIn
passes its own.

startMcpSignIn's wait for the refresh lock now ends when the signal
aborts. Only McpRefreshLockBusyError (coding-agent maps proper-lockfile's
ELOCKED to it) falls back to the browser step; other lock errors fail
the sign-in.
2026-11-03 14:17:21 +01:00
Armin Ronacher
bb266bf359 feat(mcp): MCP OAuth sign-in in two halves, moved from coding-agent
pi-mcp now has startMcpSignIn and completeMcpSignIn, so a server app can
start a sign-in in one request and complete it in another (a callback
route, a pasted redirect URL, or a loopback server via
listenForLoopbackSignIn/waitForLoopbackSignIn), plus createMcpAuthProvider.
Application specifics (client name, Client ID Metadata Document base URL
and listed redirect URIs) are options. coding-agent's signInMcpServer is a
thin wrapper over them.

authorizeMcp gains refreshOnly: a failed refresh no longer registers a
client or replaces a pending sign-in's PKCE verifier and state. Sign-in
refreshes with the stored client before dropping it for another redirect
URI.
2026-11-03 14:17:21 +01:00
David Brailovsky
a758f3ae4c
Anthropic message threads (#11545)
Anthropic can keep a conversation on the server (message threads beta).
A request can then continue from the previous reply and send only the
new messages, instead of the whole history every turn.

pi uses this when a session id is set and caching is on. It still builds
the full request, compares it with the previous one, and only sends the
new part if nothing earlier changed.

- Default: on only for the anthropic provider on api.anthropic.com.
  Gateways that drop unknown fields would lose the conversation.
  Override with compat.supportsMessageThreads.
- If the server rejects a continuation, pi resends the full request
  as a new thread. Other errors turn threads off for the session.
- New option StreamOptions.disableContinuation. The cache warmer sets
  it so warm requests don't replace the session's thread.
- cleanupSessionResources(sessionId) frees a session's thread state.
2026-11-03 14:07:57 +01:00
Petr Houška
806cdd68e3
fix(coding-agent): only report packages as up to date when pi update checked them (#11711)
## Problem

#11707 prints `Packages are already up to date` whenever no package
changed. In two cases that claim is false.

**1. npm left the old version installed.** Extension updates install
`@latest` or the configured range. If npm resolves stale registry
metadata, it reinstalls the old version and exits 0. The existing check
warns, then the summary contradicts it:

```
Updating npm:foo...
npm:foo is still 1.2.0 (registry reports 1.3.0); npm registry metadata may be stale, try again in a few minutes
Packages are already up to date
```

**2. Offline mode.** With `--offline` or `PI_OFFLINE=1`,
`updateConfiguredSources()` returns before checking anything, and the
CLI still prints `Packages are already up to date`.

## Change

The CLI only prints "already up to date" when the update ran and printed
no warning:

| Situation | Output |
|---|---|
| Packages changed | `Updated N packages:` and the list (unchanged) |
| Nothing changed, no warnings | `Packages are already up to date`
(unchanged) |
| Nothing changed, a warning was printed | `No packages were updated,
see the warnings above` (or `<source> was not updated, ...`) |
| Offline mode | `Skipped package updates (offline mode)` |

The CLI counts `progress` update events, which are always warnings, and
does not try to map them to packages. That is deliberate: the git
warning about untracked files that could not be cleaned is printed even
though the commit did change, so a warning does not mean "this package
was not updated".

In offline mode, `update()` still runs, so `pi update <unknown source>`
still fails with "No matching package". `isOfflineModeEnabled()` is now
exported from `core/package-manager.ts` (not from the package index) so
the CLI uses the same check.

The changelog entry from #11707 is adjusted, since it is unreleased.

## Tests

`test/package-command-paths.test.ts`, both through `main()`:

- npm `view` reports 1.3.0 but `install` leaves 1.2.0: the
stale-metadata warning is printed, then `No packages were updated, see
the warnings above`, and no "up to date".
- `PI_OFFLINE=1`: `Skipped package updates (offline mode)`, no "up to
date".

Both fail without the fix. `cycles project package overrides in config
local mode`, `prints a pnpm metadata hint when self-update fails` and
`should expand ~ in exclusion and exact patterns` fail locally on
Windows, on `origin/main` as well.
2026-11-02 21:49:07 +01:00
Petr Houška
342514bdf8
feat(coding-agent): list updated packages with version changes in pi update (#11707)
## Problem

`pi update`, `pi update --all` and `pi update --extensions` print only
`Updating <source>...` progress lines and a final `Updated packages`.
Users cannot see which extensions changed or to which version. Several
outdated npm packages in the same scope are installed as one batch, so
the progress output does not even name them.

## Change

`pi update` now prints a summary of what actually changed:

```
Updated 2 packages:
  npm:foo 1.2.0 -> 1.3.0
  git:github.com/user/bar (project) abc1234 -> def5678
```

When nothing changed it prints `Packages are already up to date` (or
`<source> is already up to date`).

- npm: compares the installed `package.json` version before and after
the batch install. The "before" version comes from the existing
`shouldUpdateNpmSource` check, so this adds no extra registry calls.
- git: compares `git rev-parse HEAD` before and after `updateGit`.
Commits are shortened to 7 characters.
- Packages whose version or commit did not change are omitted, including
npm installs that hit stale registry metadata (those already get the
existing warning).

## API

`PackageManager.update()` still returns `Promise<void>`.
`ProgressEvent.type` gains a `"result"` member. Each changed package is
reported through the existing progress callback as:

```ts
{ type: "result", action: "update", source: "npm:foo", message: "npm:foo 1.2.0 -> 1.3.0" }
```

The CLI collects these messages to print the summary. A separate type
keeps the existing contract that each `start` is followed by exactly one
`complete` or `error` for the same source. Reusing `complete` would have
produced a second `complete` for git packages and a `complete` without a
`start` for batched npm packages. The only compatibility cost is for
TypeScript consumers with an exhaustive `switch` on `event.type`.

## Tests

- `test/package-manager.test.ts`: npm update emits `start`, `complete`,
then a `result` with `npm:example@^1.0.0 (project) 1.0.0 -> 1.2.0`; an
up-to-date package emits no result.
- `test/git-update.test.ts`: git update emits a result with the old and
new commit; an up-to-date clone emits no result.

`should expand ~ in exclusion and exact patterns` fails locally on
Windows. It tests path resolution and is unrelated to this change.
2026-11-02 21:21:23 +01:00
Armin Ronacher
db6c084455 Add [Unreleased] section for next cycle 2026-11-02 11:38:21 +01:00
Armin Ronacher
f9a2636556 Release v1.5.0 2026-11-02 11:38:21 +01:00
Armin Ronacher
7e7c98a0f5 docs(coding-agent): note branch summary context edits 2026-11-02 11:38:20 +01:00
Armin Ronacher
ddbe02cd20 docs: audit changelogs and add release highlights 2026-11-02 11:03:52 +01:00
Armin Ronacher
0f21a3319d fix(coding-agent,tui): reuse syntax highlighting and rendered Markdown blocks
Codemode and write rows keep their last highlighting in a CodeHighlighter
that checks theme and grammar changes itself. Markdown reuses the top-level
blocks of its last render, held weakly, and assistant messages reuse their
Markdown instances while streaming. Text.setText() keeps its wrapped lines
for unchanged text.

closes #10971
2026-11-01 14:40:56 +01:00
Mario Zechner
6536f80129 feat(ai,agent,coding-agent): validate tool arguments with pi-schema, drop TypeBox
pi-schema: opt-in coercion, compile(schema, { coerce: true }) with coerce() and
parse(), the superset of TypeBox's Value.Convert rules and pi-ai's old coercion;
unresolvedRefs option; url and json-pointer-uri-fragment formats.
pi-ai: Tool takes any JSON Schema; validation through pi-schema; Type, Static
and TSchema are no longer exported (breaking). pi-agent-core and pi-durable:
tools on pi-schema, no TypeBox dependency. Coding agent: extensions keep Type,
Static, TSchema and StringEnum through an extension view of pi-ai; built-in
tools stay on TypeBox.
2026-11-01 10:08:42 +00:00
Petr Houška
e3da248ea9
fix(coding-agent): keep updating git packages when git clean fails (#11648)
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.
2026-10-31 18:19:44 +01:00
Armin Ronacher
b4c2e92acb fix: remove vulnerable dependency chains
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.
2026-10-31 17:54:29 +01:00
Armin Ronacher
705d047cb5 fix(coding-agent): tighten system prompt 2026-10-31 16:11:43 +01:00
xu0o0
79006f0619
fix(durable,coding-agent): support Unicode word segmentation in tool search (#11037)
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>
2026-10-30 23:34:00 +01:00
Petr Houška
e033f255f0
fix(coding-agent): rank tools matching more query terms first in tool search (#11632)
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.
2026-10-30 23:07:43 +01:00
Petr Houška
9376c8d17c
fix(coding-agent): pass host keybinding hints to extension renderers (#11630)
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.
2026-10-30 23:01:20 +01:00
Petr Houška
8bc94b19ff
feat(coding-agent): add PI_SELF_UPDATE_COMMAND and selfUpdateCommand for pi update (#11629)
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.
2026-10-30 22:30:39 +01:00
Petr Houška
cc44fa2169
fix(coding-agent): weigh tool names above descriptions in tool search (#11625)
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.
2026-10-30 21:56:03 +01:00
Petr Houška
6454d07ec3
fix(coding-agent): start WSL interop helpers from / and report external editor failures (#11627)
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.
2026-10-30 21:47:58 +01:00
Petr Houška
c2760d32cc
fix(coding-agent): name the package manager in pi update only when it owns the install (#11628)
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.
2026-10-30 21:23:10 +01:00
Armin Ronacher
be15fe3c17 fix(coding-agent): accept -l in pi mcp login and logout
Report unknown options as typed and suggest a configured server for a
mistyped name.
2026-10-30 14:15:25 +01:00
Armin Ronacher
908aa7b74b feat(coding-agent): prefer the client ID metadata document for MCP OAuth
oauth.clientRegistration defaults to auto: pi's Client ID Metadata Document
when the authorization server supports it for public clients, otherwise
dynamic client registration. Registered clients stay in use. pi mcp add
gains --oauth-client-registration.

closes #11426
2026-10-30 10:36:22 +01:00
Petr Houška
1be312b46a
fix(coding-agent): compare paths case-insensitively on Windows (#11588)
Fixes #10488

## Problem

On Windows, `c:\Users\me` and `C:\Users\me` are the same directory, but
pi compared paths as exact strings (`===`, `startsWith`, map and set
keys). `realpathSync` keeps the input casing, so canonicalizing does not
help.

The reported case, with cwd `c:\Users\me\project` and home
`C:\Users\me`:

1. Ancestor discovery finds `c:\Users\me\.agents\skills`. The exclusion
of the user dir `C:\Users\me\.agents\skills` does not match, so global
skills load as project skills.
2. The same dir loads again as user skills. Dedup does not match either,
so every skill is reported as a name collision.
3. `hasTrustRequiringProjectResources` treats `~/.agents/skills` as a
project resource that requires trust.

The same pattern affected other places:

- Saved trust decisions in `trust.json` were not found when cwd casing
differed, so pi asked for trust again.
- With a custom session dir, `--continue`, resume by id, and the session
list hid sessions recorded with different cwd casing.
- Git worktree `AGENTS.md` shadowing failed when cwd casing differed
from git's paths.
- Local package matching (`pi remove c:\pkg` vs configured `C:\pkg`) and
package identity across global and project settings.
- Extension preload matching, skill scope classification, resource
source info, the config and session selector keys, and the session
import self-check.

Separately, home-to-`~` shortening used an unanchored `startsWith(home)`
in four places, so `/home/me2/x` was shown as `~2/x`.

## Fix

New helpers in `utils/paths.ts`, all based on one comparison key:

- `getPathComparisonKey(path)`: the resolved path, lowercased on
Windows. It does not touch the filesystem.
- `pathsEqual(a, b)`
- `isPathWithin(parent, child)`: true for equal paths or paths inside
`parent`, matching whole path segments.
- `shortenHomePath(path, home?)`: replaces a leading home dir with `~`.

The helpers pick `path.win32` or `path.posix` from `process.platform` at
call time, so tests can exercise real Windows paths (drive letters,
UNC).

`getResourcePathKey(path)` in `source-info.ts` is the dedup key for
resources. Synthetic identifiers (`builtin:<name>`, `<inline:...>`) stay
unchanged, because they are not filesystem paths and their lookup is
case-sensitive. File paths go through realpath plus the comparison key.

The helpers are used only for comparison; stored and displayed paths are
unchanged. Trust store lookups and `set` match keys case-insensitively
on Windows.

Unchanged on purpose:

- The package install-root guards (`resolveManagedPath`,
`pruneEmptyGitParents`, the `node_modules` dependency check) keep exact
string checks. Both sides come from the same root string, and exact is
the stricter option.
- Checks based on `path.relative()` (`getCwdRelativePath`, the changelog
containment check) were already correct: Node's `path.win32.relative`
ignores case.

NTFS can enable per-directory case sensitivity, but it is rarely used
outside WSL interop, so it is ignored (see the comment on
`getPathComparisonKey`).

## Tests

- `test/paths.test.ts`: Windows drive-letter, UNC and root cases, POSIX
case sensitivity, segment-boundary containment, and home shortening,
including root and UNC homes.
- `test/source-info.test.ts`: file paths fold case on Windows; builtin
and inline identifiers keep their case.
- `test/trust-manager.test.ts`: the `.agents/skills` exclusion with a
lowercase `HOME`, plus saved trust decisions matched and replaced across
casing (simulated win32).

An end-to-end test of the reported loader scenario is not feasible on
Linux: the loader resolves paths with POSIX `node:path`, and the
filesystem is case-sensitive.

`npm run check` passes. The coding-agent suite passes, except for 13
failures that also occur without this change: clipboard tool detection
in this environment, and one test that needs built `dist/` output.
2026-10-29 21:53:06 +01:00
Petr Houška
1a09925d39 docs(coding-agent): add changelog entry for -e package refresh
closes #11375
2026-10-29 21:43:11 +01:00
Petr Houška
31ade0e0dc fix(coding-agent): honor saved project trust for projects without trust-requiring resources
A saved Do not trust decision was ignored when the project had no
trust-requiring resources, so ctx.isProjectTrusted() returned true.
The saved decision now takes precedence over auto-trust.

closes #10947
2026-10-29 21:10:03 +01:00
Petr Houška
86dc686cf2 fix(tui): treat ESC+Return with native Shift held as Shift+Enter
Some Apple Terminal setups send ESC+CR for Shift+Enter, which parsed as
alt+enter and queued a follow-up. The native Shift fallback now covers
ESC+CR as well as plain CR.

fixes #11559
2026-10-29 20:17:54 +01:00