When a partial assistant reply fails and automatic retry succeeds, pi
appends a `context_edit` that hides the failed attempt from model
context. Branch summarization (`/tree` with summarize) built its input
from raw session entries through `getMessageFromEntry`, which ignored
`context_edit` entries. As a result, the summarizer received the hidden
attempt again. Content replacements were ignored the same way.
What the change does:
- `prepareBranchEntries` now collects the latest `context_edit` for each
target in the branch.
- It applies those edits to the entries it converts, using the same
`projectContextEntry` logic as the live context and compaction:
- an omission (`replacement: null`) drops the entry;
- a replacement swaps its content.
- `projectContextEntry` is now exported from `session-manager.ts` so
both paths share it.
- The existing branch-summary behavior is unchanged: tool results are
still skipped, and compaction or branch-summary entries still become
summary messages.
Testing:
- Added `applies context edits to branch entries` to
`test/branch-summarization.test.ts`. It covers one omitted assistant
entry and one replaced assistant entry. The test fails without the
change and passes with it.
- Ran `branch-summarization`, `branch-summary-extensions`,
`agent-session-branching` and the compaction tests: all pass.
- `npm run check`: biome and the coding-agent checks pass. `tsc` reports
6 errors in `packages/ai` model-catalog tests, and the same 6 errors
occur on unmodified `main`.
- End-to-end reproduction with the faux provider and `AgentSession`: a
failed attempt, a successful retry, then `navigateTree(target, {
summarize: true })`. Before the change, the summary request contained
the failed attempt's text. After the change, it contains only the
successful retry.
Fixes#11602
Requested by @davidbrai
Co-authored-by: triagent <triagent@localhost>
Fixes#10488
## Problem
On Windows, `c:\Users\me` and `C:\Users\me` are the same directory, but
pi compared paths as exact strings (`===`, `startsWith`, map and set
keys). `realpathSync` keeps the input casing, so canonicalizing does not
help.
The reported case, with cwd `c:\Users\me\project` and home
`C:\Users\me`:
1. Ancestor discovery finds `c:\Users\me\.agents\skills`. The exclusion
of the user dir `C:\Users\me\.agents\skills` does not match, so global
skills load as project skills.
2. The same dir loads again as user skills. Dedup does not match either,
so every skill is reported as a name collision.
3. `hasTrustRequiringProjectResources` treats `~/.agents/skills` as a
project resource that requires trust.
The same pattern affected other places:
- Saved trust decisions in `trust.json` were not found when cwd casing
differed, so pi asked for trust again.
- With a custom session dir, `--continue`, resume by id, and the session
list hid sessions recorded with different cwd casing.
- Git worktree `AGENTS.md` shadowing failed when cwd casing differed
from git's paths.
- Local package matching (`pi remove c:\pkg` vs configured `C:\pkg`) and
package identity across global and project settings.
- Extension preload matching, skill scope classification, resource
source info, the config and session selector keys, and the session
import self-check.
Separately, home-to-`~` shortening used an unanchored `startsWith(home)`
in four places, so `/home/me2/x` was shown as `~2/x`.
## Fix
New helpers in `utils/paths.ts`, all based on one comparison key:
- `getPathComparisonKey(path)`: the resolved path, lowercased on
Windows. It does not touch the filesystem.
- `pathsEqual(a, b)`
- `isPathWithin(parent, child)`: true for equal paths or paths inside
`parent`, matching whole path segments.
- `shortenHomePath(path, home?)`: replaces a leading home dir with `~`.
The helpers pick `path.win32` or `path.posix` from `process.platform` at
call time, so tests can exercise real Windows paths (drive letters,
UNC).
`getResourcePathKey(path)` in `source-info.ts` is the dedup key for
resources. Synthetic identifiers (`builtin:<name>`, `<inline:...>`) stay
unchanged, because they are not filesystem paths and their lookup is
case-sensitive. File paths go through realpath plus the comparison key.
The helpers are used only for comparison; stored and displayed paths are
unchanged. Trust store lookups and `set` match keys case-insensitively
on Windows.
Unchanged on purpose:
- The package install-root guards (`resolveManagedPath`,
`pruneEmptyGitParents`, the `node_modules` dependency check) keep exact
string checks. Both sides come from the same root string, and exact is
the stricter option.
- Checks based on `path.relative()` (`getCwdRelativePath`, the changelog
containment check) were already correct: Node's `path.win32.relative`
ignores case.
NTFS can enable per-directory case sensitivity, but it is rarely used
outside WSL interop, so it is ignored (see the comment on
`getPathComparisonKey`).
## Tests
- `test/paths.test.ts`: Windows drive-letter, UNC and root cases, POSIX
case sensitivity, segment-boundary containment, and home shortening,
including root and UNC homes.
- `test/source-info.test.ts`: file paths fold case on Windows; builtin
and inline identifiers keep their case.
- `test/trust-manager.test.ts`: the `.agents/skills` exclusion with a
lowercase `HOME`, plus saved trust decisions matched and replaced across
casing (simulated win32).
An end-to-end test of the reported loader scenario is not feasible on
Linux: the loader resolves paths with POSIX `node:path`, and the
filesystem is case-sensitive.
`npm run check` passes. The coding-agent suite passes, except for 13
failures that also occur without this change: clipboard tool detection
in this environment, and one test that needs built `dist/` output.
When writing a session entry failed (ENOSPC, EBUSY, ETIMEDOUT on network
drives), `SessionManager._appendEntry` had already added the entry to
memory and made it the current leaf. The process kept running, and the
next entry written to disk pointed to a parent that was not in the file.
On resume, pi follows the history only back to that gap: everything
before it disappears. In #11460 that also dropped the first system
message, which declares the tools, so resume turned off every built-in
tool.
`_appendEntry` now undoes the in-memory append if writing fails, then
rethrows. The error reaches the caller as before; later entries chain to
the last entry actually on disk.
Reports of this problem:
- #11460: ENOSPC; resume dropped all built-in tools.
- #10166: failure injected with EBUSY; the next entry on disk points to
a missing parent.
- #10661: ETIMEDOUT on Windows + NFS; resumed sessions showed only the
last few messages. That report also describes NUL holes in the file,
which this change does not address.
**Testing**
- Two tests in `test/session-manager/file-operations.test.ts` make
writes fail with real file permissions: a read-only session file (failed
append) and a read-only session directory (failed first file creation).
They check that the entry is not kept, the leaf is unchanged, the next
entry chains to the last written one, and the reopened session's branch
contains both entries. Both fail without the change and pass with it.
- `test/session-manager/` and `test/default-tools-setting.test.ts` pass
(140 tests).
- `npm run check` passes.
Fixes#11460
Requested by @davidbrai
Co-authored-by: triagent <triagent@localhost>
Theme previews re-render the whole transcript, and streaming re-renders
every frame, so per-line and per-session work dominated in long sessions.
- visibleWidth: fast path for ANSI-styled ASCII, allocation-free escape scanning
- Box: compare unpadded child lines and skip a second width measurement
- Markdown: reuse parsed tokens across invalidation
- footer: cache usage totals and context usage per session state
- bash results: cache the collapsed preview including the expand hint
- sanitizeBinaryOutput: single regex replace
A new session was only written to disk once the first assistant message
existed. If pi exited during the first turn (Ctrl+C twice while streaming,
or the process was killed), the user's prompt was lost and the session
never appeared in /resume.
The file is now created as soon as the session has a user or assistant
message. A session with only setup entries (model, thinking level) still
stays in memory, so starting pi and quitting without chatting leaves no
file. Forking uses the same rule through a shared helper, so the two paths
cannot drift apart and write the header twice (#1672).
closes#10000
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.
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.
* feat(coding-agent): ingest external entries in in-memory sessions
SessionManager.inMemory() takes the entries to start from, so a session
held outside the filesystem can be resumed as it was stored.
Replaying through appendMessage/appendCompaction cannot do this: ids are
minted fresh, so every stored firstKeptEntryId, targetId and fromId has to
be remapped, timestamps are rewritten, and parentId always comes from the
current leaf, so a branched tree needs the original branch() choreography
reproduced call by call.
The entry-adoption half of _setSessionFile becomes _loadEntries, which the
constructor also uses when there are entries but no file. Entries carrying
a session header keep it; without one, the header comes from the options
and the entries become its body.
* fix(coding-agent): carry the stored version through restored entries
inMemory() took SessionEntry[], which excludes SessionHeader, so an
external store could not pass the header it saved and the header branch of
_loadEntries was unreachable through the public API. A restore therefore
always synthesised a current-version header, and the migration that ran
next read that header and skipped: a v2 session kept its hookMessage roles
forever.
Widening to FileEntry[] lets the stored header through, version included.
Migration moves inside the header branch, where a version exists to compare
against; without a header the entries are adopted as current-version, which
is the only thing they can mean.
APP_NAME is derived from piConfig.name so a rebranded distribution shows
its own name, and roughly 90 call sites already use it. Twelve
user-facing strings hardcoded "pi" instead, so those messages named the
upstream binary regardless of piConfig.name:
- the trust project folder prompt
- "Restart pi for this to take effect." after a saved trust decision
- the "Start without extensions" hint on an extension load failure
- the invalid session file error
- the executable location in the self-update-unavailable notice
- the three auth command usage strings and the unknown auth command
error, which are printed next to `${APP_NAME} --help` in main.ts and
so contradicted it
- "is configured outside pi." in the provider setup dialog
- the uncaughtException line
Output is unchanged for pi itself: piConfig has no name, so APP_NAME
falls back to "pi".
* add usage info to branch summary entries
* add usage to compaction entries
* allow custom tools to report llm usage in tool results
* allow observing and patching usage in tool_result hooks
* agent-harness: save usage in entries
for compaction, branch summaries and tool results
The Message types require content to always be present, but untyped JS
extension tools, hand-built histories, and old or hand-edited session
files can violate that contract, crashing rendering, compaction, and
provider request conversion with 'content is not iterable'.
Normalize null/missing content to an empty array at the ingestion
boundaries instead of guarding every consumer:
- transformMessages (choke point before every provider request)
- createToolResultMessage in the agent loop
- session entry loading (message and custom_message entries)
- extension custom messages (sendCustomMessage, before_agent_start)
- message_end extension replacements
fixes#6259, fixes#6276
Avoid quadratic path construction when walking deep session branches. On a 600k-entry pathological session, path construction improved from about 20.3s with Array.unshift() to about 35ms with push() plus reverse().
Refs #5804.
* feat(session-manager): allow supplying custom session ID in newSession()
Add optional `id` field to `NewSessionOptions`. When provided, this ID
is used as the session ID instead of generating a random UUID. Existing
callers are unaffected since the field is optional and falls back to
`randomUUID()`.
Closes#2097
* test(session-manager): add tests for custom session ID in newSession()
Verify that newSession() uses the provided id when supplied, falls back
to randomUUID() when omitted, and includes the custom id in the session
header.
createBranchedSession() wrote the file and set flushed=true even when the
branched path had no assistant message. The next _persist() call saw no
assistant, reset flushed=false, and the subsequent flush appended all
in-memory entries to the already-populated file, duplicating the header
and entries.
Fix: defer file creation when the branched path has no assistant message,
matching the newSession() contract. _persist() creates the file on the
first assistant response.
closes#1672
- Store previousSessionFile before creating branched session
- Update sessionFile after writing new branch file
- Pass parentSession when forking from first message
- Add --local to git config in tests to prevent repo escape
Adds 'Threaded' as a new sort mode (now default) that displays sessions
in a tree structure based on parent-child relationships. The tree is
sorted by root session's modified date. Other sort modes (Recent, Fuzzy)
continue to show flat lists.
Sort mode cycles: Threaded -> Recent -> Fuzzy -> Threaded