## 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.
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.
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.
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>
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.
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.
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.
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.
Closes#11360.
## Summary
- Cap combined stdout/stderr with `ExecOptions.maxBuffer`, defaulting to
the smaller of 256 MiB and the runtime string limit. Invalid explicit
limits reject before spawning.
- Terminate oversized commands and return `truncated: true`, `killed:
true`, and a nonzero exit code. Close output pipes so writing
descendants cannot keep the call pending, and discard incomplete UTF-8
suffixes rather than adding replacement characters.
- Fix SIGKILL escalation for children that ignore SIGTERM, and simplify
completion to `await` with one cleanup block and result.
- Document the API and add regressions for byte limits, shell
descendants, UTF-8 chunk boundaries, and 32-bit runtime limits.
`ExecResult.truncated` is required. Existing extensions reading
execution results continue working; typed mocks or wrappers constructing
results must add the field.
## Verification
- `npm run build` and the post-rebase `npm run build:offline`
- `npm run check`
- 39 focused execution and extension tests
- `./test.sh` passes after building the env daemon. Earlier runs hit
remote-cleanup and reftable-watcher timeouts; both passed in focused
reruns and the final full run.
## Summary
The docs refresh (#9898) cut `docs/extensions.md` from about 3,100 lines
to 130, dropping API reference and tool guidance. #11039 restored part
of it. This PR restores the full guide and corrects it against current
source.
- **Structure:** the original sections, with the ASCII lifecycle diagram
replaced by three Mermaid diagrams (run, turn, session) and ordered hook
lists, plus a handler composition table.
- **Merged:** content added after the cut: tool exposure, calling other
tools, structured output, MCP servers, `registerToolRenderer`, virtual
models, `provider_stream_event`, `before_agent_start` `messageType`.
- **Corrected:** removed or renamed APIs (`modelRegistry.stream()`,
prompt sections are `<tools>`/`<rules>`, `builtin:read` paths,
`highlightCode`), wrong behavior claims (errors only by throwing,
`model_select` on restore, `sendUserMessage` always triggering a turn,
override warning, `truncateLine` bytes), examples that did not
type-check, links to removed `tui.md` patterns and `rpc.md` anchors, and
wrong API lists in the examples table.
- **Kept:** anchors that other docs and changelogs link to, as `<a id>`
tags.
- **Fixed example:** `reload-runtime.ts` tool queued `/reload-runtime`
with `deliverAs: "followUp"`. Queued text reaches the model as a plain
user message, so the command never ran and Pi never reloaded. The tool
now sets a flag and returns `terminate: true`; an `agent_settled`
handler sends `/reload-runtime` with `expandPromptTemplates: true`,
which runs after the settled handlers finish, while the agent is idle.
In the long run it may be better to revisit how API reference is,
provided to the agent, especially since more capable models do explore
the source without it.
## Validation
- `npm run check`
- Edited TypeScript examples type-check against current types
- All relative links and anchors in `extensions.md` resolve, including
inbound anchors from other docs
- `reload-runtime.ts`: faux-provider harness test (temporary, not
committed) shows one reload after the run settles and no extra model
request; the old version never reloads. Also verified in interactive
mode with a real model.
- Reviewed by GPT-6 Astra and GPT-5.6 Sol against source; all findings
applied
Fix docs and `--help` drift: update the provider checklist to the
`src/api` layout, fix broken MCP anchors, and document `/mcp`, `pi mcp
add|remove`, `PI_CLEAR_ON_SHRINK`, and `"disabled"` timeouts.
closes#11243
Runs started by pi.sendMessage(..., { triggerTurn: true }) skipped before_agent_start. After their first tool call, the next-turn refresh fell back to the base prompt options and patched away the sections handlers had added, changing the system prompt mid-run and breaking the provider prompt cache.
These runs now go through the same setup as prompt(): compaction check, before_agent_start, tool loadout, nextTurn messages, handler messages, and the system patch. BeforeAgentStartEvent is a union on messageType ("user" or "custom"); custom events carry the starting message.
The session counts as busy from the start of setup, so messages sent while handlers run go into the starting run instead of starting a second one, and an abort requested meanwhile aborts the run once it starts.
closes#10267, closes#5581, closes#10639, closes#10289
partly fixes#10606
Tools that reach other tools declare it with reachesTools(input); MCP uses it to wait for the servers a call needs and to decide which codemode or tool_search tool to activate, instead of parsing codemode scripts and comparing parameter schemas. Namespaces get aliases, replacing codemode's MCP-specific __ suffix rule.
pi-codemode no longer knows MCP: MCP_TYPESCRIPT_PREAMBLE and mcpStructuredContentSchema() are removed, and the MCP result schema describes itself, with the server's local $refs relocated so they resolve as before.
Rules and the tool list follow the declared tools (active minus
hiddenDeclarations). The skills hint names no tool when the reader is
hidden. Codemode shows each tool's prompt guidelines with its
declaration, via the new ToolLoadout.getPromptGuidelines().
closes#10343
Add pi.registerToolRenderer((toolName, next) => renderers), which chooses the
renderers for calls to any tool, including tools that are not registered.
Resolvers run in extension load order; next() returns what the remaining
resolvers, then the registered tool, would use. Interactive mode and HTML
export both resolve through it.
The MCP extension uses it to render every mcp__<server>__<tool> call with the
MCP renderers, so calls in a resumed session no longer render fully expanded
before their server connects, or for good if it never does.
closes#10285
The first prompt waits only for servers with direct tools. Other servers
connect in the background; codemode scripts, tool_search, and the
resource tools wait for the servers they need. codemode and tool_search
are activated from the config.
Tool declarations no longer depend on connected servers: the codemode
description leaves out deferred tools, and the tool_search description
no longer lists sources. Servers are listed in an mcp_servers system
prompt section instead, set in before_agent_start from the config and
connected state, so changes are appended to the conversation.
describeNamespace() and searchTools() accept mcp__dev-radius,
mcp__dev_radius, dev-radius, and dev_radius.
closes#10212
MCP codemode exposure no longer lists tools, tool counts, or server
instructions in the codemode description. Servers are listed by name and
an optional mcp.json description; scripts find tools with searchTools()
and read instructions with describeNamespace(). codemode-deferred is an
alias for codemode.
refs #10212
Codemode sums the usage of models.classify() calls into its tool result and shows each call's cost. Usage of tools called through ctx.executeTool() is added to the calling tool's result usage instead of being dropped.
This adds codemode and MCP support to pi. Codemode (packages/codemode) runs
model-written JavaScript in a QuickJS wasm VM inside a worker. From there the
script calls pi's tools as async functions, uses a per-session store, and can
read the model catalog and run classifiers. The codemode tool in coding-agent
is compatible with Codex's exec tool, and codemode.mode works like Codex's
tool modes (on or only). Large results are written to a JSON temp file. MCP
(packages/mcp) is a standalone client for stdio and streamable HTTP servers,
with OAuth.
The core only gets general mechanisms; codemode, tool_search and MCP are
built-in extensions that use them. Other extensions can replace these
built-ins. Tools declare an exposure: direct, model-only, codemode, deferred
or hidden. prepareLoadout() lets an orchestrating tool change which tool
declarations the model sees. ctx.executeTool() runs nested calls through
validation and the tool_call/tool_result hooks. Those calls emit events with
parentToolCallId and are recorded as bounded nestedCalls on the parent
result, which compaction and HTML export use. Tools can also return
structuredContent with an outputSchema, and can report errors with isError
instead of throwing. MCP servers come from mcp.json (global, or per project
after the project is trusted) or from pi.registerMcpServer(). They are
managed with /mcp and pi mcp list|login|logout, and their runtimes load on
first use.
Closes#10040
This adds experimental support for virtual models. A virtual model is a catalog entry that an extension registers with pi.registerVirtualModel(). It does not talk to a provider itself; for every request it picks a physical model and thinking level. The motivation is to make routing policies pluggable: plan on a strong model and implement on a cheap one, pick a model with a classifier, or move to a larger window when the context grows. Today this needs model switching by hand or hacks in prepareRequest.
The selection stays virtual: model_change records it, resume restores it, and each response records the physical model and thinking level that produced it. Router state can be stored on the session branch, so it follows /tree and forks and survives compaction. Context limits, compaction and image sizing use the physical model a request was routed to. examples/extensions/jev-router.ts shows a full router that uses the Jev classifier to pick Sol or Terra for planning and hands off to Luna after the first edit. docs/virtual-models.md describes the API.
Since 0.86 the prompt and tool declarations live in transcript system
messages, so a context handler that filters or slices messages could drop
them. After extension-driven compaction this sent requests without built-in
tools and made Codex emit raw tool-call text.
context handlers now see the conversation only. An unchanged list keeps the
transcript as is; a changed list gets the replayed prompt sections and tool
declarations as one leading system message. Handler-added system messages
are kept after it.
Add context_with_system, which runs after every context handler on the full
transcript and sends its result verbatim, for extensions that need to edit
system messages per request. Dropping the leading system message is reported
as an extension error.
closes#9789, closes#9822
Make SessionManager projections authoritative for provider requests, add append-only context edits and actionable turn boundaries, and preserve existing queue scheduling during continuation and recovery.
Remove SystemMessage.replace. A prompt forced from before_agent_start is a rendering
of the current prompt for the run, not conversation state: persisting it as a
replacing system message wrote the full prompt and tool list on every change,
lost the structured sections while forced, and disabled mid-conversation system
messages for the rest of the session once a replacement existed.
The transcript now always records section patches. AgentSession wraps
transformContext and, when a forced prompt is active, collapses the system
messages into one head carrying the forced text and the current tools. Providers
receive the same request as before; nothing is persisted and the projection ends
with the run.
The evals harness validated the without_docs variant from the transcript, which
relied on the persisted replacement. Its transform extension now records the
prompt it forced and validates that instead.
Add SystemMessage.replace: replaying a system message with the flag discards the
accumulated prompt content, sections, and tools before applying it, and providers
collapse the transcript into one leading system message whenever a later message
replaces it.
A forced prompt from before_agent_start is opaque, so it is persisted as a replace
message holding the text in content with no sections (the agent loop fills in the
full tool set). Leaving force mode persists another replace message with the
structured sections. Previously the forced prompt became a preamble section patch,
so models with native mid-conversation system messages kept the original prompt
as their leading system prompt and received the forced one as a later update.
* fix(coding-agent): fail closed on user bash hook errors
Prevent failed execution-routing hooks from falling back to the local shell.
Fixes#9068
* fix(coding-agent): validate user bash hook results
* docs(coding-agent): clarify user bash result semantics
* docs(coding-agent): streamline user bash semantics
* fix(coding-agent): strengthen user bash regressions
* docs(coding-agent): clarify user bash breaking change
This change makes system prompt text and tool changes part of the transcript rather than silently rewriting its starting conditions. This lets Pi record when instructions changed or tools became available, restore that state after resuming or navigating branches, and preserve cached prompt prefixes where the upstream supports it.
Follow-up to e687434a6.
Recheck compaction state after navigation dialogs and response cancellation before replacing status indicators or escape handlers. Document navigation conflicts and add regression coverage.
Extensions have no way to read the session's scoped model set (the models
resolved from --models / enabledModels against the available catalogue --
the same list /scoped-models shows). The only model surfaces on
ExtensionContext are ctx.model (active) and ctx.modelRegistry.getAvailable()
(the whole catalogue, unfiltered), so an extension that wants a scoped
model picker must either enumerate everything or re-implement pi's scope
resolution (minimatch on provider/modelId / bare modelId + enabledModels +
--models) from disk -- duplicating core logic.
Add ctx.scopedModels: readonly ScopedModel[] to ExtensionContext, mirroring
how ctx.model is exposed (field -> bindCore -> lazy getter in createContext()).
Empty when no scoping is configured (all available models usable). Each entry
is { model, thinkingLevel? }.
- types.ts: ExtensionContext.scopedModels + ExtensionContextActions.getScopedModels
- runner.ts: field, bindCore wiring, lazy scopedModels getter
- agent-session.ts: provide getScopedModels: () => this._scopedModels
- test: assert ctx.scopedModels reflects the action (default [] + live ref)
- docs/extensions.md: document the new field
Co-authored-by: ngSoftware <alessandro@pungitore.ch>