Commit graph

1266 commits

Author SHA1 Message Date
Petr Houška
a69ff894e4 fix(coding-agent): edit keeps line endings of untouched lines, diffs before writing
Edits are spliced into the original text, so lines the edit did not touch
keep their CRLF, LF or lone CR endings. The diff and patch are computed
before the file is written, so a failure there, such as running out of
memory on a large file, leaves the file unchanged.

fixes #8544
2026-11-06 00:34:41 +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
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
df0d88aac1
fix(coding-agent): keep model selector selection when the catalog refresh lands (#11810)
Opening the selector starts a background catalog refresh. When it
finished, the selection jumped back to the current model, or to the top
row with a search query, discarding any browsing done in the meantime.

Pass the selected model into filterModels() so the refresh keeps it
selected by provider and ID. If it is gone, the existing fallback
applies. The index is chosen once and the list is rebuilt once.

Based on #11758.

closes #9109

---------

Co-authored-by: bermudi <github.igizp@dabg.uk>
2026-11-04 22:15:02 +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
Danila Poyarkov
0f4d5e960a
fix(ai): allow prompt cache keys for compatible proxies (#9442) 2026-11-04 11:36:35 +01:00
Mario Zechner
a3a1005053 fix(coding-agent): fix CI type check and image-only steering test
- type-check subagent-extension.test.ts against the extension view like the other subagent test
- update the test to current ToolRenderContext and ExtensionToolContext types
- use a decodable PNG in the image-only steering test; the fake data was omitted by image processing
2026-11-04 10:34:45 +00:00
Dale Lowe
3e4f872654
feat(ai): add Qwen Token Plan Individual CN provider (#7989)
Closes #7847
2026-11-04 11:32:45 +01:00
Michael Renner
aba42ae6b9
fix(coding-agent): make subagent progress and failures reliable (#8250)
The shipped subagent example could say a child was done while it was
still working, lose the useful part of process failures, and return a
normal-looking tool result when the delegated work had actually failed.
Single and chain output could also exceed the normal tool limit.

This also adds a dedicated regression suite covering progress state,
cancellation, process failures, output limits, cwd-based agent
selection, and persisted tool-result error state.

After this change:

- queued, running, completed, failed, and aborted work stay distinct
throughout the run;
- interrupting the parent run preserves partial child results, stops
queued work from starting, and escalates when a child ignores SIGTERM;
- signal exits, spawn failures, provider errors, stderr, and assistant
output remain visible instead of collapsing to `(no output)`;
- failed subagents become failed tool results, so the parent model can
react instead of treating them as successful calls;
- single and chain results stay within the normal 50 KB / 2,000-line
limit;
- project agents are selected and confirmed from each task's actual
working directory rather than the dispatcher's directory.

## Further potential improvements

These are useful follow-ups, but they are separate product and storage
decisions rather than part of the failure-handling fixes in this PR.

<details>
<summary>Potential follow-ups</summary>

### Keep persisted transcripts bounded

Subagent tool results persist the full child message history in
`details`, which can make parent session files large and slow to reload.
A follow-up could compact that history or spill it to disk.

### Define an aggregate parallel-output budget

Parallel mode still gives every task its own 50 KB budget. A follow-up
could keep every task represented while enforcing one overall
tool-result limit.

### Report child usage to the calling session

Per-child token and cost usage is visible in result cards but is not
added to the calling session's totals.

### Isolate malformed agent definitions

One malformed YAML agent can still break discovery for every valid agent
in the same directory.

### Limit subagent recursion

A nesting limit would prevent accidental process and paid-request
multiplication when subagents can invoke the subagent tool again.

* https://github.com/earendil-works/pi/issues/8195

### Add a configurable timeout

Unattended runs would benefit from a generous opt-in timeout for
children that never complete.

</details>

## Related issues

### Spawn failures lose their diagnostics

This directly addresses the reported case where a nonexistent child
working directory produces `(no output)` instead of the actual spawn
error. The regression suite includes that same reproduction.

* https://github.com/earendil-works/pi/issues/8129

### Persisted child history can bloat sessions

This PR intentionally keeps full child history in tool-result details,
so session compaction or spill-to-disk remains follow-up work rather
than something fixed here.

* https://github.com/earendil-works/pi/issues/7452
2026-11-04 11:24:12 +01:00
wren
51313540a4
fix(coding-agent): preserve pasted text during editor restoration (#10146)
Closes #9809
2026-11-04 11:20:58 +01:00
Gangqiang Yao
8a3e163b0d
fix(coding-agent): prevent prompt and tree navigation overlap (#9155)
Closes #9154
2026-11-04 11:18:25 +01:00
Alexey Zaytsev
aceb1bd128
feat(coding-agent): upgrade Mermaid terminal rendering (#8158)
Replaces grok-mermaid with lovely-mermaid 0.3.3. Closes #8157
2026-11-04 11:06:51 +01:00
Pier Carlo Cadoppi
e6865ec7e1
fix(coding-agent): realpath extension entries before jiti import (#8112)
Closes #8092
2026-11-04 11:04:24 +01:00
Giannis Papakostas
ee962fb4db
fix(plan-mode): make progress tracking robust and tolerant (#7950)
## What

Hardens the plan-mode example extension's step-progress tracking.
Previously, completion markers were only recognized in the exact
`[DONE:n]` form inside visible text blocks, so steps frequently never
got checked off during execution.

## Changes

- **`getTextContent`** now reads `thinking` blocks as well as text
blocks, so `[DONE:n]` tags emitted during reasoning are honored.
- **Marker tolerance** (`extractDoneSteps`): accepts `[DONE: 1]`, `[DONE
1]`, `Done: n`, and prose phrasings like "Step n done / is complete /
finished" — all adjacency-anchored so negations ("Step 9 is not done")
never match.
- **Whole-plan fallback** (`allStepsComplete`): if the run's final
message says the plan is done (e.g. "All steps complete"), all remaining
steps are marked at `agent_end`.
- **Plan extraction dedup**: only the last `Plan:` section is extracted,
so a plan drafted in a thinking block no longer duplicates the steps
from the text block.
- **No more truncation**: step text is kept in full (previously cut at
50 chars); the widget truncates for display only. The execution prompt
now shows the complete step text.
- **No more dropped steps**: steps whose text starts with a backtick
(e.g. `1. \`index.ts\` — …`) are no longer skipped.
- **Widget refresh** happens on every turn instead of only when a marker
matched.
- **Plan-complete card** is delivered with `nextTurn` instead of
triggering an extra LLM turn.

## Tests

`packages/coding-agent/test/plan-mode-utils.test.ts` updated for the new
behavior (truncation, backtick steps, marker-count semantics) and
extended with coverage for the new cases (marker variants, prose
phrasings, negation safety, thinking-draft dedup, markdown titles,
whole-plan completion).

All 43 tests in `plan-mode-utils.test.ts` +
`plan-mode-extension.test.ts` pass (`npx vitest run`).

Note: the repo-wide type-check currently fails on `main` with
pre-existing `ModelCatalog`/`never` errors unrelated to this change;
this branch adds none (same count with and without the diff).
2026-11-04 10:53:52 +01:00
robopipi[bot]
c13f6acfbe
Highlight .dart, .nix, and .groovy files in read/write views (#11790)
The `read` and `write` tool views find a file's language from its
extension with `getLanguageFromPath()`. That function's extension table
had no entries for Dart, Nix, or Groovy. Their highlight.js grammars are
loaded at startup, but the lookup returned `undefined`, so these files
were shown as plain uncolored text.

This change adds `dart → dart`, `nix → nix`, `groovy → groovy`, and
`gradle → groovy` to the table in
`packages/coding-agent/src/modes/interactive/theme/theme.ts`.

HTML export keeps its own separate extension map and a vendored
highlight bundle without a Dart grammar. This change does not touch
either.

## Testing

- Added a test to `test/syntax-highlight.test.ts` that checks the new
extension mappings. It fails without the fix and passes with it.
- `node ../../node_modules/vitest/dist/cli.js --run
test/syntax-highlight.test.ts` passes.
- Before, `getLanguageFromPath("a.dart")` (and `.nix`, `.groovy`,
`build.gradle`) returned `undefined` from the built dist. After, they
return `dart`, `nix`, `groovy`, and `groovy`.
- `npm run check` reports 6 type errors in `packages/ai/test` (model ID
types). Main without this change has the same 6 errors.

Fixes #11643

Requested by @badlogic

Co-authored-by: triagent <triagent@localhost>
2026-11-04 10:45:55 +01:00
梧桐雨
a63c4ec5c8
fix(coding-agent): clear delivered image-only queue entries (#8612)
## Summary

- remove delivered queue entries even when the user message has no text
- keep image-only steering and follow-up pending counts in sync with the
agent queue
- cover image-only steering delivery with a regression test

## Testing

- `npm run check`
- `./test.sh`

Fixes #8581
2026-11-04 10:24:43 +01:00
Sai Mallikarjun
f3a64af07b
fix(ai): preserve aborted stop reason during lazy setup (#8635)
Fixes #8409

## Summary
- Pass the request abort signal through lazy stream setup wrappers.
- Report setup failures as aborted when the request signal is already
aborted.
- Added a regression test for aborting during a tool execution before
next auth setup.
2026-11-04 10:23:35 +01:00
Gangqiang Yao
7a53c310a3
fix(coding-agent): settle tool results before disposal (#9126)
Fixes #9124.

Disposing a runtime during tool execution disconnects persistence before
the interrupted turn settles, leaving a stored assistant tool call
without its result. Await `session.abort()` before shutdown and disposal
so the interrupted tool result and final assistant message are
persisted.

This matches `teardownCurrent()`. `AgentSession.dispose()` remains
synchronous. Like the existing replacement path, the wait has no
timeout; handling tools that ignore cancellation is outside this change.

Related: #9986 concerns results for tool calls skipped during a
mid-session abort. This fix preserves the result of an already-running
tool during runtime shutdown.

Validation on main `f1b2e77f5`:

- The regression fails without the fix and passes with it, checking the
durable message order and `Operation aborted` result immediately after
`dispose()` returns, before awaiting the prompt.
- `npm run check`, offline build, and `./test.sh` pass.
2026-11-04 10:21:16 +01:00
Sviatoslav Abakumov
2cfab7c269
fix(coding-agent): render session tree search cursor (#9157)
This PR adds the cursor block to the **Type to search** input in
**Session Tree**. This is similar to the search input in **Resume
Session**.

Before:

<img width="1120" height="778" alt="Screenshot 2026-09-04 at 19 25 30"
src="https://github.com/user-attachments/assets/8f2e0ba9-8476-452b-a6be-1ad9b8d7d6c8"
/>

After:

<img width="1120" height="778" alt="Screenshot 2026-09-04 at 19 25 50"
src="https://github.com/user-attachments/assets/7f6ffcbf-db5a-4732-84fe-6ee0544116fa"
/>
2026-11-04 10:19:38 +01:00
Gangqiang Yao
c3b938e3e9
fix(coding-agent): reject reload while running or compacting (#9222)
Fixes #9221.

An extension tool can lose its context when an RPC command or SDK caller
reloads the session during tool execution: the tool awaits work, reload
invalidates its runner, then `ctx.getSystemPrompt()` throws a
stale-context error. The failed tool result is stored in session
history.

Reject reload when `isStreaming` or `isCompacting` is already set,
before shutdown or runner invalidation. This gives RPC and direct
callers the same entry checks as the TUI. The production change remains
seven lines.

#9548 removed the wrapper access behind the original reproduction. The
regression exercises the tool's own context access after an await,
through both an RPC-style extension command and direct
`AgentSession.reload()`. Rejection preserves the successful result in
session history and the next provider request. Compaction, tree
navigation, and idle reload are also covered.

Scope: this guards operations already marked running or compacting. It
does not serialize reload with prompt preflight or prevent a new prompt
from starting during an ongoing reload.

Related: #5735 proposes a broader deferred-reload API. This patch only
adds the existing TUI entry checks to the core reload method.

Validation on main `f1b2e77f5`:

- Without the guard, four regression cases fail and idle reload passes.
With it, all five pass.
- `npm run check`, offline build, and `./test.sh` pass.
2026-11-04 10:18:59 +01:00
Danila Poyarkov
dca1f55527
fix(coding-agent): preserve indentation in rendered diffs (#9274)
## Problem

The edit tool's intra-line renderer can drop indentation from a removed
line when text is inserted before otherwise unchanged content.

For example, this input:

```diff
-83   await commitRecordingChunk({
+83   const result = await commitRecordingChunk({
```

was rendered as:

```diff
-83 await commitRecordingChunk({
+83   const result = await commitRecordingChunk({
```

`diffWords()` groups the shared leading spaces into the added segment
(`" const result = "`). The existing highlighting logic moved those
spaces outside inverse styling, but only appended them to the added
line.

## Solution

Extract whitespace shared by both lines before calling `diffWords()`,
then initialize both rendered lines with that prefix. This preserves
indentation without changing the existing word-level highlighting
behavior. The existing handling for whitespace that belongs to only one
side remains unchanged.

Added regression coverage for:

- insertion before indented content;
- replacement within indented content;
- keeping shared indentation outside inverse highlighting.

## Validation

- `npm run check`
- `./test.sh`
- targeted `packages/coding-agent/test/diff.test.ts` (3 tests)
2026-11-04 10:17:34 +01:00
Gangqiang Yao
66c6556771
fix(coding-agent): track discarded user bash output (#10165)
Closes #10164.

A user `!` command can discard earlier chunks but report `truncated:
false` when the retained tail fits within the output limits. The model
then receives no truncation notice or full-log path.

Track discarded chunks and include that flag in the executor's result
for both completion and cancellation. This applies to the current shared
finalization path and preserves streaming ANSI handling. Output limits
and message format stay unchanged.

Related: #2852. Here the complete log is already saved; the incorrect
flag hides its path from the model.

Validation on main `f1b2e77f5`:

- Two deterministic 64 KiB + 44 KiB cases fail without the fix. All
three regressions pass with it, checking completion, cancellation, the
complete log, model-facing path, and short-output behavior.
- Regressions are in `test/bash-executor-truncation.test.ts`; that file
and `test/tools.test.ts` pass all 88 tests.
- `npm run check`, offline build, and `./test.sh` pass.
2026-11-04 10:16:53 +01:00
Christian Tietze
3b57ed7e27
fix: limit selector window height (#11072)
With this PR, we stop  rendering every option.
Instead, we render a centered, clamped window of up to 10 options and
show `(selection/total)` when the list overflows.

This keeps the selected option visible in long extension-provided lists
without changing navigation, keybindings, callbacks, countdowns, or
short-list rendering.

Fixes #6688.

## Merge Danger

**Door:** Two-way

The change is localized to `ExtensionSelectorComponent.updateList()` and
can be reverted without migration or persisted-state concerns.

**Blast Radius:** Low

Only extension selectors with more than 10 options change appearance;
they now render a window and position indicator instead of every option.

---------

Co-authored-by: Pi via openai-codex (gpt-5.6-sol) <noreply@openai-codex>
2026-11-04 10:15:27 +01:00
robopipi[bot]
b0c64f33c8
fix(coding-agent): load user keybindings in pi config (#11788)
`pi config` ignored `~/.pi/agent/keybindings.json`. `selectConfig()`
never called `setKeybindings()`, so the config selector's
`getKeybindings()` fell back to the default TUI bindings. Remaps such as
`"tui.select.down": ["down", "ctrl+n"]` did nothing there, although they
work in `/settings` and the `--resume` picker.

`selectConfig()` now installs
`KeybindingsManager.create(options.agentDir)` before it builds the UI,
the same way the session picker and startup UI do.

**Testing**
- Added `test/config-selector-keybindings.test.ts`. It runs
`selectConfig()` with a stubbed terminal and component and an agent dir
whose `keybindings.json` remaps `tui.select.up` and `tui.select.down`,
then checks that the active keybindings include `ctrl+p` and `ctrl+n`.
It fails without the change and passes with it.
- Manual check: ran `pi config` in tmux with that `keybindings.json`.
Before the change, Ctrl+N did nothing. After it, Ctrl+N moves the
selection like Down.
- `npm run check` reports only two errors, both missing modules in
`packages/schema/test/bench/candidates.ts`. They occur on `main` without
this change.

Fixes #11651

Requested by @badlogic

Co-authored-by: triagent <triagent@localhost>
2026-11-04 09:59:49 +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
robopipi[bot]
37b38e9f65
fix(coding-agent): keep ctrl+v as a paste key on Windows and WSL (#11780)
On Windows and WSL, `app.clipboard.pasteImage` was bound only to
`alt+v`. Most Windows terminals handle Ctrl+V themselves and send a
bracketed paste. Some terminals have no Ctrl+V paste action and pass
Ctrl+V to the program as the raw `0x16` key instead; Tern's embedded
terminal is one. In those terminals nothing in pi was bound to `ctrl+v`,
so pressing it did nothing and showed no message.

This change makes the Windows/WSL default `["alt+v", "ctrl+v"]`. `alt+v`
keeps working as before. In terminals that handle Ctrl+V themselves, pi
never receives the key, so the extra binding has no effect there. No
other default binding uses `ctrl+v`. `docs/keybindings.md` is updated to
match.

Testing:
- Added a test to `test/keybindings.test.ts`. It sets `WSL_DISTRO_NAME`,
re-imports the keybindings module, and checks that the paste defaults
are `["alt+v", "ctrl+v"]`. It fails without the change and passes with
it.
- Updated the existing defaults assertion in the same file to match the
new value.
- `node ../../node_modules/vitest/dist/cli.js --run
test/keybindings.test.ts`: 6 passed.
- `npm run check`: passes.

Fixes #11765

Requested by @badlogic

Co-authored-by: triagent <triagent@localhost>
2026-11-04 09:33:31 +01:00
robopipi[bot]
ea0f49a365
fix(coding-agent): name the llama.cpp server and cause when it cannot be reached (#11779)
When `/login llama.cpp` could not connect to the server URL, pi showed
only `Error: Failed to save API key for llama.cpp: fetch failed`. That
happens on a refused connection, a wrong port, or `https://` against a
plain-HTTP server. Node's `fetch` keeps the real reason in
`error.cause`, and pi dropped it, so the user had nothing to go on.

`LlamaClient` now sends its requests, including the `/models/sse` watch,
through a small `fetchServer` helper. When `fetch` fails at the network
level, the helper throws `Could not reach llama.cpp server at <url>:
<cause>` and keeps the original error as `cause`. Aborts and timeouts
are rethrown unchanged. HTTP error responses are handled as before.

The same message also improves `/llama` and the model catalog refresh,
because they use the same client.

**Testing**
- Added a test in `test/llama-extension.test.ts` that calls
`LlamaClient.list()` against a closed local port and expects the server
URL and `connect ECONNREFUSED ...` in the error. It fails without the
change (`got 'fetch failed'`) and passes with it. The whole file passes
(16 tests).
- `npm run check` passes.
- Manual check, `/login llama.cpp` with `http://127.0.0.1:8099` (nothing
listening):
  - Before: `Error: Failed to save API key for llama.cpp: fetch failed`
- After: `Error: Failed to save API key for llama.cpp: Could not reach
llama.cpp server at http://127.0.0.1:8099: connect ECONNREFUSED
127.0.0.1:8099`

Fixes #11745

Requested by @badlogic

Co-authored-by: triagent <triagent@localhost>
2026-11-04 09:32:51 +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
76c040a2ad feat(mcp): shared tool naming, schema, output limit and config helpers
createMcpToolName, mcpNamespace, normalizeMcpInputSchema,
limitMcpContent (with a caller-provided saver) and validation of the
standard mcpServers shape move from coding-agent into pi-mcp, so other
applications name and limit MCP tools exactly like pi. Applications
validate their own fields on the tagged result; coding-agent keeps
exposure, auth.provider and its Client ID Metadata Document rule.
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
Vegard Stikbakke
62752fd4c3
fix(coding-agent): serve catalogs without version redirect (#11690)
Update the model catalog protocol to pi.dev after a change to stop
pi.dev redirecting released clients to `?pi-version=` URLs and serve
their catalog directly. The released CLI is unaffected.
2026-11-02 14:42:17 +01:00