## Summary
Treat `TERM_PROGRAM=Orca` as Kitty-image capable in `pi-tui`'s terminal
capability detection, so image components render inline instead of
falling back to text when running inside Orca.
- Recognize Orca as supporting Kitty inline images, true color, and OSC
8 hyperlinks.
- Preserve the existing tmux and screen precedence guards.
- Gate images on Orca's capability advertisement: require both
`TERM_PROGRAM=Orca` and Orca's `ORCA_IMAGE_PROTOCOL=kitty` signal before
enabling Kitty output, so existing Orca releases that do not yet
advertise image support safely keep the text fallback.
- Add a regression test and the package/CLI changesets.
## Related
- Requested by maintainer approval in
[earendil-works/pi#7357](https://github.com/earendil-works/pi/issues/7357)
(lgtm).
- Depends on the Orca-side capability advertisement, which has landed:
[stablyai/orca#19512](https://github.com/stablyai/orca/pull/19512)
(merged 2026-09-18, `09073086a8`) exports `ORCA_IMAGE_PROTOCOL=kitty` on
the daemon, local, and relay PTY paths, and forwards it across the WSL
boundary. Supersedes
[stablyai/orca#11706](https://github.com/stablyai/orca/pull/11706),
which was closed in favor of #19512.
## Testing
- `pnpm --filter @moonshot-ai/pi-tui test` — focused regression suite
passes.
- `pnpm --filter @moonshot-ai/pi-tui typecheck` — passes.
Fixes#10925.
## Problem
`splitIntoTokensWithAnsi()` attaches pending escape codes to the next
visible character. The code that closes a styled word therefore lands on
the following space: `**commodo** consequat` tokenizes as
`\x1b[1mcommodo`, `\x1b[22m `, `consequat`.
`wrapSingleLine()` detected whitespace with `token.trim() === ""`, which
is false for `"\x1b[22m "`. When the styled word ended exactly at the
wrap width, that space was treated as a word and moved to the next line:
```
before: ["aaaa \x1b[1mbold", "\x1b[1m\x1b[22m next", "word"] (width 9)
after: ["aaaa \x1b[1mbold\x1b[22m", "next word"]
```
Because the closing code moved to the next line, the style also stayed
open into the right padding of the wrapped line. Separately,
`getLineEndReset()` closed only underline at line end, so strikethrough
that spans a wrap continued into the padding.
## Fix
- Detect whitespace tokens after stripping escape codes.
- When a whitespace token is dropped at a wrap, append its escape codes
to the wrapped line and update the tracker, so the style closes there
and is not re-opened on the next line.
- Close strikethrough (`\x1b[29m`) at line end, like underline.
`getActiveCodes()` re-opens it on the next line. Background is
unaffected.
- Whitespace wider than an empty line that carries codes (e.g.
background spaces followed by a trailing reset) keeps only the codes.
Without this, treating it as whitespace skipped the long-token path, and
the trailing reset blocked the final `trimEnd()`, leaving a line wider
than `width`.
- When a long word follows, the line before it is now trimmed before the
line-end reset is added. Otherwise the reset (now also emitted for
strikethrough) blocks the final `trimEnd()` and leaves trailing spaces.
## Tests
- `wrap-ansi.test.ts`: boundary closing code, dropped space with a color
reset, strikethrough reset and restore across a wrap, strikethrough
reset with background preserved.
- `markdown.test.ts`: padded Markdown with a background at an exact
boundary for bold and strikethrough.
- The new tests fail without the fix. With the old code, rendering the
issue text through `UserMessageComponent` (dark theme, padding 1) at
widths 40-120 produced 5 bad lines at width 78. With the fix it produces
none.
- Fuzzing over 1.2M random styled inputs with SGR and OSC 8 codes found
no line wider than `width`, and no case where the old wrapper laid out
text the same as the unstyled input but the new one does not.
- Benchmark (`wrapTextWithAnsi`, width 77, about 5-18k chars, median of
6 processes) against `main`: plain -5%, light styling -0.5%, heavy
styling +1%, long strikethrough +0%, all within noise. The whitespace
check scans character codes and stops at the first visible text instead
of allocating a stripped copy.
- `npm run check` passes. All `packages/tui` tests pass. `./test.sh`
shows no new failures; the remaining failures are environment-specific:
the clipboard tests also fail on `main` here, and the durable, env
(ssh), and chord failures are in packages that do not depend on
`pi-tui`.
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.
A temporary -e git source with a ref was treated as pinned and never refreshed,
so git:host/repo@main stayed on its first checkout. Refresh refs too, skipping
only a commit-hash ref that is already checked out.
Temporary -e npm: sources were installed once and reused forever. Like
temporary git sources, check npm on load (unless offline) and reinstall
with name@latest or the configured spec when a newer version exists,
keeping the cached install if the refresh fails.
A saved Do not trust decision was ignored when the project had no
trust-requiring resources, so ctx.isProjectTrusted() returned true.
The saved decision now takes precedence over auto-trust.
closes#10947
Some Apple Terminal setups send ESC+CR for Shift+Enter, which parsed as
alt+enter and queued a follow-up. The native Shift fallback now covers
ESC+CR as well as plain CR.
fixes#11559
When an image needs resizing, `resizeImageInProcess` builds a list of
encodings: PNG first, then JPEG at decreasing qualities. It returned the
first one under `maxBytes`. Since PNG was always first, it was used
whenever it fit, even when it was many times larger than the JPEG. The
doc comment says "Try both PNG and JPEG formats, pick the smaller one",
and the code worked that way before v0.62.0. Because of this, photos
were sent as multi-MB PNGs, which made requests large enough to fail or
time out.
This change compares PNG with JPEG at the configured quality at each
size step and tries the smaller one first. It tries the other one next,
and the lower JPEG qualities only after that. Screenshots and other
images where PNG is smaller still go out as PNG.
**Testing**
- Added a regression test to `test/image-processing.test.ts`: a
generated photo-like 1200×800 JPEG resized to 1000 px must come back as
`image/jpeg`. The test fails without the fix (PNG is returned) and
passes with it.
- `node ../../node_modules/vitest/dist/cli.js --run
test/image-processing.test.ts test/image-resize-callers.test.ts`: all
pass.
- `npm run check`: no issues in the changed files. It does report type
errors, all in `packages/ai/test` (model ID unions), which this change
does not touch.
- Reproduction with a photo-like 2400×1620 JPEG (460 KB base64):
- before: `image/png`, 4,008,480 bytes base64, at 2000×1350
- after: `image/jpeg`, 255,316 bytes base64, at 2000×1350
Fixes#11551
Requested by @davidbrai
Co-authored-by: triagent <triagent@localhost>
## What was wrong
`pi update --extensions` first looks up the newest version of each npm
package with `npm view`. If that version is newer than the installed
one, pi runs `npm install <name>@latest`. The two commands read
different registry documents, and each is cached separately. Shortly
after a publish, `npm view` can already report the new version while
`npm install` still resolves the old one. npm then exits successfully,
pi prints "Updated packages", and the startup banner lists the same
package again on the next launch. The user can't tell that nothing was
actually updated.
## What the change does
- `shouldUpdateNpmSource` now returns the installed version and the
registry version along with its decision. Both are carried on
`NpmUpdateTarget`.
- After `npm install`, `updateNpmBatch` re-reads each package's
installed version. If the registry lookup succeeded and the version did
not change, it emits a `progress` event with the message: `<source> is
still X (registry reports Y); npm registry metadata may be stale, try
again in a few minutes`.
- The package CLI now prints `progress` events in yellow. Until now
nothing emitted them.
Install behavior is unchanged: pi still installs `@latest`.
## Testing
- New test in `test/package-manager.test.ts`: two packages are flagged
for update and only one is actually upgraded. The warning is emitted
only for the unchanged one. The test fails without the change.
- `test/package-manager.test.ts` passes (129 tests), and `npm run check`
passes.
- Ran the built CLI with an `npmCommand` stub whose `view` reports
`1.0.1` and whose `install` changes nothing:
```
Updating npm:demo-pkg...
npm:demo-pkg is still 1.0.0 (registry reports 1.0.1); npm registry
metadata may be stale, try again in a few minutes
Updated packages
```
Fixes#11536
Requested by @davidbrai
Co-authored-by: triagent <triagent@localhost>
When a Zhipu/Z.ai (`open.bigmodel.cn`) plan hits its usage cap, the API
returns HTTP 429 with error code `1308`. The cap resets after hours, but
`isRetryableAssistantError()` still marked the error as retryable,
because the bare `429` in the message matched the transient-error
pattern. The agent-level retry then re-sent a request that could not
succeed and slowed down the final error. With default settings that is 3
attempts, about 14 s.
This change adds the Zhipu `"code":"1308"` error code to the
non-retryable provider-limit patterns in
`packages/ai/src/utils/retry.ts`, next to the existing quota and
subscription-limit entries. The pattern matches the JSON code field, not
a bare number, so request IDs or byte counts that contain 1308 do not
trigger it.
Not changed: the provider-level HTTP retry (`retry.provider.maxRetries`,
off by default). It still retries every 429 based on the status code
alone.
Testing:
- `npm run check` passes.
- `test/retry.test.ts` passes.
- Calling `isRetryableAssistantError` directly on the reported message,
`429 {"code":"1308","message":"已达到 5 小时的使用上限。…"}`, returned `true`
before the change and returns `false` after.
Fixes#11537
Requested by @davidbrai
Co-authored-by: triagent <triagent@localhost>
At startup, interactive mode printed every skill diagnostic under the
heading `[Skill conflicts]`. Most of these are validation warnings for
skills that still load, for example `description exceeds 1024 characters
(1508)` or name-format warnings. The heading made them look like name
collisions.
This change renames the heading to `[Skill warnings]`. Validation and
loading behavior are unchanged. The prompt and theme headings are left
as they are.
**Testing**
- Updated the assertion in
`packages/coding-agent/test/interactive-mode-status.test.ts` ("still
shows diagnostics on quiet startup when requested") to expect `[Skill
warnings]`. It fails against the old heading and passes with the change.
All 37 tests in that file pass.
- `npm run check` reports six type errors in `packages/ai` tests about
model IDs. Unchanged `origin/main` reports the same six errors, so they
are unrelated to this change. It reports nothing in `coding-agent`.
Fixes#11521
Requested by @davidbrai
Co-authored-by: triagent <triagent@localhost>
In regular TUI mode, user and assistant messages wrote OSC 133
(`A`/`B`/`C`) shell-integration markers straight to the terminal.
Terminals that display OSC 7501 program status (Rex, iTerm2 3.7.5+) read
`133;A` as a new shell prompt and drop the current status. As a result,
the `working` indicator disappeared right after a run started and only
`done` showed up at the end. Fullscreen mode was not affected because it
already strips these markers before writing.
## Change
- `TuiMainScreen.doRender()` (`packages/tui/src/tui-main-screen.ts`) now
strips OSC 133 markers from the start of each rendered line before
compositing and writing. It uses the same pattern as fullscreen
(`OSC133_ZONE_PREFIX`).
- The message components still produce the markers. Fullscreen uses them
internally for prompt navigation and already strips them before writing,
so its behavior is unchanged.
- In regular mode, the terminal's own jump-to-prompt shortcuts no longer
stop at pi messages (added in #1805).
## Testing
- New test in `packages/tui/test/tui-render.test.ts`: no `ESC]133;`
reaches the terminal across a full render and a differential render,
with both BEL and ST terminators. It fails without the change and passes
with it.
- These pass:
- `packages/tui` `npm test`
- `test/user-message.test.ts` and `test/assistant-message.test.ts` in
coding-agent
- `npm run check`
- I ran `pi -ne --tui-mode regular` with `PI_PROGRAM_STATUS=1` and a
streaming test provider, capturing all terminal output with `script`.
- Before: `7501 working`, then repeated `133;A`/`133;B`/`133;C`, then
`7501 done`.
- After: only `7501 idle` → `working` → `done`, and no OSC 133.
Fixes#11519
Requested by @davidbrai
Co-authored-by: triagent <triagent@localhost>
A reference is a request that outlives its provider: a missing provider or a version that does not fit leaves
it unbound, waiting for the name (failed { waiting }, a new FailureCause kind), local and remote alike. An
attempt parks until its dependencies are live, with no timer and no backoff, woken on every state change of a
dependency; its readyTimeout runs only for its own steps. Requests wait only for the host's publication (the
startup deadlock is gone); load resolves at the first outcomes of its host-lifetime objects. Getters wait through
waiting until their readyTimeout. Registry readers are waiting dependencies, woken at every usable value; a last
good value is dropped when a contribution it was built from leaves. Unbound holds count for keep-alive and are
revoked; retry passes on to failed blockers once per operation (operation id and visited list on the wire); the
dependency deadline is gone and parked_long reports a long wait on a remote attempt.
In sessions created by `createAgentSession`, compaction, branch
summaries and bug-report summaries resolved the stored OAuth credential
in `AgentSession._getSummarizationRequestAuth`, then passed the access
token on as `options.apiKey`. The SDK's stream function calls
`modelRuntime.streamSimple`, which resolves auth again. Because it was
given an explicit `apiKey`, `resolveProviderAuth` took the API-key
override branch instead of using stored OAuth. Built-in providers accept
the token either way. A provider whose API-key path differs from its
OAuth path, such as one that refuses explicit keys, failed every summary
while normal prompts worked.
`createAgentSession` now marks its stream function with
`markRuntimeStreamFunction`. This records it in a module-level `WeakMap`
keyed by the function, mapped to its `ModelRuntime`.
`_getSummarizationRequestAuth` skips pre-resolution when the agent's
stream function is marked for the session's own runtime, so that runtime
resolves auth once, as it does for normal prompts. The check runs after
virtual-model routing. Bare `streamSimple` and custom `streamFn`s still
receive resolved credentials, so the fixes for #6324 and #7579 are
unchanged. If a caller replaces `agent.streamFunction`, the old behavior
applies.
**Testing**
- Added `test/suite/regressions/11520-sdk-summary-oauth-auth.test.ts`.
It builds an SDK session on a provider that has stored OAuth and also an
API-key method, runs a branch summary, and asserts two things: no
`getAuth` call carries an `apiKey` override, and the provider receives
the OAuth access token. It fails without the change and passes with it.
- `npm run check` passes and `./test.sh` passes. I also ran the related
tests: 6324 branch-summary auth, compaction model overrides,
auto-compaction cancellation, SDK stream options, virtual models, and
the compaction and branch-summary tests.
- Manual reproduction: an SDK script with an `openai` OAuth credential
that logs `ModelRuntime.getAuth` during `navigateTree(..., { summarize:
true })`. Before the change there were two calls, the second with
`apiKey = <oauth token>`. After the change there is one call, with no
override.
Fixes#11520
Requested by @badlogic
Co-authored-by: triagent <triagent@localhost>
The contract field placement is hostAddress, required on every remote() (type and declaration error); the
fallback for remote types without one is gone. invalid/placement is invalid/host_address. host/placement.ts
(Placement) is host/destination.ts (Destination); here is decided by address alone, and a host without an
address refuses to serve a remote type. A hold always carries its address on the wire (the binary codec's
ADDRESS flag is gone, the text codec requires it). Tests and examples declare their host addresses.
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.
Registries: shared tokens (defineRegistry), one provider per host (host.serve(R, { compose, accepts })),
contributions from facets and host-lifetime objects, plain values or followed Chord watchables, a table
sorted by plugin, lazy compose in the batch that changed the table, failures left out and reported with
the last good value kept; self.registry as a declared dependency, host.registry outside objects, older
readers mapped by accepts[old].value; keyedEntries and orderedEntries. Native sources: self.writable,
registry values and ref.state. self.retryOn wakes failed objects on registry values. host.root gives code
outside objects a call scope sealed at settle, abort and host.close.
Durable: registryReader(value, context); a Session close ends synchronous observers failed
("session closed"), watches still end ended.