std::env::vars() panics the moment a host env key or value is not valid
Unicode, before any command can run. A corrupt GHOSTTY_BIN_DIR (bytes 9d d9 50)
staged by cmux/Ghostty tripped both sinks:
- pi-shell's session env copy in create_session_for_run (also merged PATH)
- brush-core's get_host_env_vars, which process builtins (sleep, timeout,
pgrep, ...) use to inherit the host env into the shells they build
Both now read via std::env::vars_os() and skip entries that cannot be decoded
as Unicode: a corrupt entry carries no usable meaning. PATH merge behavior is
unchanged. Regression tests inject a non-UTF-8 key and value and assert the
shell still starts and PATH survives.
Reported in issue #8925.
CoreWeave Serverless Inference (W&B Inference) is a reseller with a
rotating model menu, but its catalog entry omitted
dynamicModelsAuthoritative. Runtime /v1/models discovery therefore
merged into the frozen bundled slice instead of replacing it, so stale
ids (e.g. moonshotai/Kimi-K3, Kimi-K2.5) stayed selectable and 404 at
request time. Matches sibling resellers (baseten, gmi-cloud, aiand,
bedrock-mantle). Bundled models remain the offline/failure fallback via
the authoritativeFreshProviders gating in ModelRegistry.
A session that crossed a provider boundary compacted 90 times in three days
without ever succeeding: every attempt asked the summarizer to read the whole
re-expanded span in one call (2.33M tokens on 08-15, 3.03M by 08-17, against a
1M cap), and every rejection was retried ten times.
Three independent defects:
1. `generateSummary` serialized the entire span into one prompt with no budget
check. It now plans windows that fit the summarizer's context and folds them
with the update prompt that iterative compaction already uses, so a stranded
boundary is recovered instead of rejected. A provider that rejects a window
the catalog said would fit (claude-sonnet-4-5 advertises 1M but is
beta-gated to 200k on OAuth credentials) halves what was actually sent and
re-plans, because only the rejection knows the real cap.
2. `TRANSIENT_TRANSPORT_PATTERN` matched bare status codes, so the random id in
the `raw-http-request=.../1787022540720-3o503gxo48bvb.json` pointer omp
appends to its own errors classified a deterministic 400 as a transient 503.
Statuses are now word-boundaried, matching AUTH_FAILURE_PATTERN.
3. Neither retry layer vetoed ContextOverflow, so one failure became up to 30
identical calls (10 outer x 3 oneshot). A oneshot replays a fixed prompt, so
an input that does not fit never fits; both layers now fail fast to the next
candidate.
The boundary scan that decides which compaction entry a model can actually read
is extracted as `findReadableCompactionIndex`, since the fold and
`prepareCompaction` both need it.
Verified by replaying the session that failed: 7,096 messages summarize in 3
calls with a largest prompt of 773,705 tokens under the real 1M cap, and in 15
calls with a largest prompt of 196,148 tokens under a simulated 200k cap.
Some providers (notably Gemini) serialize array tool arguments with
flattened property paths — questions[0].id, questions[0].options[0].label —
instead of a nested questions array. The schema sees only unrecognized extra
keys and rejects the call (e.g. the ask tool).
Add a pre-validation normalization pass (alongside the existing LLM-quirk
passes) that rebuilds the nested structure. Conservative: fires only when a
key is a well-formed array-index path, preserves non-flattened siblings, and
aborts wholesale on any shape conflict so genuine schema mistakes still
surface as validation errors.
Fixes#8886
The composer accepts three encodings for Shift+Enter (kitty CSI-u, the
legacy \x1b[13;2~ form, and a bare LF from the iTerm2 mapping e.g. Claude
Code's /terminal-setup). The /tree selector only handled the kitty form and
silently routed a bare LF into the plain-Enter branch, so summarize-and-
switch never fired for those terminals.
Mirror the composer: fall through a bare LF to summarize-and-switch while
plain CR (or the decoded Enter key) still does a plain switch.
Fixes#8821
Persistence is lazy: getSessionFile() returns an allocated path from the
start, but the JSONL is only materialized once an assistant message (or an
explicit ensureOnDisk()) crosses the persistence gate. The shutdown banner
printed the resume hint based solely on the path, so any session that ended
before the first assistant message advertised a copy-pasteable command that
always fails with Session not found.
Gate the hint on the new SessionManager#isSessionOnDisk() (file exists in
the active storage backend) and add unit tests.
Fixes#8860
A boolean latch survived /new and unnamed session switches, so the
replacement session skipped titling and could inherit the previous
skill's title. Bind the latch and the apply check to the originating
session id.
maybeStartTitleGeneration used to fire again for every untitled skill
prompt, so a queued /skill: during the first title request could race
and rename the session. Latch until the first request settles.
Session titles ignored /skill:<name> <args> because the invocation is a
custom skill-prompt, not a user turn. Feed the chip or reconstructed
/skill line into first-title generation and replan context, never the
expanded SKILL.md body.
The post-interrupt immune-window downgraded end-of-turn blockers to non-interrupting asides, so a blocker that means the agent handed off broken work never woke a new turn. Concerns keep the cooldown; blockers now bypass it and steer a triggered turn, consistent with the #5628 blocker-after-terminal-answer exception.
Apply the same recursive placeholder expansion used by native MCP configs before extension-package servers are validated and surfaced. This prevents stdio credentials and remote headers from reaching servers as literal placeholders.\n\nSolves: Extension-package MCP environment expansion\nTests: bun test packages/coding-agent/test/discovery/omp-plugins.test.ts
Keep host-specific secret injection in the maintained plugin layer instead
of carrying a behavioral divergence in the OMP fork.
Solves: Unwanted fork maintenance for MCP injection
Tests: Reverts only drycode/oh-my-pi PR #1
Claude marketplace plugins may reference runtime secrets in stdio
server environment values. Resolve those placeholders after plugin-root
substitution so child processes receive credentials instead of literal
template strings.
Solves: Stdio MCP credentials remain unexpanded
Tests: Claude plugin discovery tests; coding-agent check; live CH auth
The idle watchdog aborts the request signal and cursor.ts closes
the Connect stream, so there is no in-flight server exec to race.
Unmarked MCP/todo blocks can continue once every emitted call has
a matching result, same as HTTP/2 RST.
Cursor hosted web search / Exa / unnamed field-9 WebFetch send
interaction_query and block the Run RPC until the client writes
interaction_response. Dropping the frame leaves the HTTP/2 stream
alive on heartbeats that are not semantic progress, so the 300s
idle watchdog aborts with "Provider stream stalled while waiting
for the next event".
Approve network permission gates and reject interactive
ask / switch-mode / create-plan. Leave VM setup unanswered
rather than inventing a success result.
A single comment on a line outside a diff hunk 422s the whole review on
GitHub, which the 422/500 fallback (ported here from the Forgejo work,
including payload shaping and commit_id) then degraded into plain issue
comments. Now we fetch /pulls/{n}/files, compute per-hunk anchorable line
sets, drop non-anchorable comments, fold them into the review summary, and
only fall back to issue comments for genuine 422/500s that survive
filtering. Files-fetch failure fails open (submit unfiltered).
Retry: widened agent dequeue-hook deadline budgets from 25ms to 1s — the run loop checks the deadline before invoking dequeue hooks, so a cold or CPU-starved mock roundtrip expired the deadline first and the hooks never ran (deterministic failure in isolation, flaky under CI parallel load).
Workers spawned via resolveWorkerSpawnCmd ran with cwd anchored at the CLI
install directory and shared the agent's foreground process group. Terminal
cwd heuristics such as kitty's new_tab_with_cwd read the newest process in
that group, so new terminal tabs opened in
~/.bun/install/global/node_modules/@oh-my-pi/pi-coding-agent/dist while any
worker was alive. Spawn workers with the absolute host entry and inherit the
agent cwd instead; the bun-test fallback branch keeps its cwd-relative form.
Retry: deflaked tui IME preedit border test (#5563) — VirtualTerminal.waitForRender's fixed 40ms sleep raced TUI's throttled render timer on starved CI runners, reading the pre-input frame; waitForRender now takes an optional settle predicate polled up to 2s and the test keys on the rendered content.
browseHtmlPage wrapped its navigations in untilAborted(signal) but awaited
applyViewport, applyStealthPatches, and the finally-block page.close() raw.
When the shared headless daemon or the page target dies mid-setup, those
puppeteer calls never settle: the search hard timeout fires into a signal
with no listener at those await points, the provider promise hangs past
SEARCH_HARD_TIMEOUT_MS, and the turn never ends (only kill -9 recovers).
- Wrap applyViewport/applyStealthPatches in untilAborted(signal) so the
existing hard timeout can abort a dead-session setup.
- Bound the teardown page.close() with a fresh 5s deadline; .catch() only
covers rejection, not a hang, and the caller signal may already be fired.
Fixes#8865
The startup graphics probe only ran on ConPTY hosts with WT_SESSION, so a
SIXEL-capable terminal that exposes no identifying environment variable
(foot exports TERM=foot and COLORTERM=truecolor only) resolved the
trueColor capability row, kept imageProtocol null, and rendered every
image as the "[Image: …]" text card.
The XTSMGRAPHICS branch also had its status inverted: per xterm ctlseqs a
reply of `CSI ? 2 ; Ps ; Pv S` carries Ps = 0 on success, and a terminal
without SIXEL reports a zero maximum geometry, so a successful reply was
read as unsupported.
Drop the dead DA1 half of the probe with it: ProcessTerminal swallows
every `CSI ? … c` reply for the whole session so a late one cannot leak
into the composer (#8542), which means the attribute list never reached
the probe's input listener on any platform. The bare `CSI c` it wrote was
also unaccounted for in the DA1 sentinel FIFO, so its reply consumed
another probe's sentinel.
PI_FORCE_IMAGE_PROTOCOL, including its off/none kill switch, still wins
over the probe.
When a collapsed Gemini 3.6/3.7 Flash family routes user minimal onto the
same Cloud Code Assist wire id as low, emit thinkingLevel LOW. Those -low
SKUs reject MINIMAL with HTTP 400.
The exact-echo check used accent-insensitive compare, but the collision
suffix path was a case-sensitive startsWith. AuthLoader-3 vs authloader
therefore leaked through as a real description.
The first HUD commit hid Name: Name. The cause was earlier: task
name was copied into identity.label, which became progress.description
and skipped generateTaskLabel. Keep the handle for id allocation, but
only treat eval label as a real UI description so the tiny-model
summary can run.
The anchored Subagents list printed only `Id: description` and treated a
label that repeated the spawn handle as a real description. Show the same
⟨role⟩ badge as inline task rows and omit descriptions that only echo the id.
Addresses review feedback on the initial commit:
- restore the trailing newline at EOF stripped by the first edit
- add a Fixed entry for this change under ## [Unreleased] in the coding-agent CHANGELOG
StopOnTextCriteria decoded the last STOP_DECODE_WINDOW_TOKENS of the whole
sequence, so prompt tokens were eligible for matching. A prompt that itself
contains the stop string stops generation at the first generated token and
yields an empty title.
Anchor the window to the generation boundary by recording the first
generated index per batch entry. Existing local title models are
unaffected: with the assistant-prefill prompt shape, the example `</title>`
tags sit outside the 32-token window for normal messages, so no shipping
model changes behavior. The bug becomes reachable with any chat-level
few-shot prompt that places the stop string near the generation boundary.
A Codex request to a model the signed-in ChatGPT account is not entitled
to fails with "The '<model>' model is not supported when using Codex with
a ChatGPT account." That was classified as a plain provider error, so the
request failed outright even when a sibling account was signed in and
entitled to the model.
Classify that exact denial as an account-policy error, the same category
`cyber_policy` already uses, so the existing credential-rotation path can
reach an entitled account.
The match is deliberately narrow: it fires only for provider
`openai-codex`, only when the denied model in the message is the model that
was requested, and only for a bounded, non-null model identity. A denial
naming some other model does not trigger rotation, so an unrelated mention
cannot burn sibling credentials.
The memory-extraction prompt concatenated its instructions, few-shot
examples, and the user message into a single user turn, so a small local
model could not distinguish instructions from input and frequently echoed
the Globex/weather examples instead of extracting facts.
Send the instructions as a real system turn and the raw text as the user
turn. The tiny worker protocol gains a systemPrompt field, and Mnemopi
completion input carries task metadata so the backend selects the right
prompt per call.
Drop the code-built MEMORY_EXTRACTION_TEMPLATE rather than porting it:
prompt text belongs in .md files, and resolveMemoryCompletionInput already
overrides that template for every extraction call, so Mnemopi rendered it
only for the result to be discarded.
Measured on ONNX q4 CPU, LFM2.5-1.2B memory extraction improved from 1/8
to 5/8 once the roles were separated.
The claude-plugins provider read installed_plugins.json but never the
`enabledPlugins` map Claude Code keeps in ~/.claude/settings.json and
<project>/.claude/settings(.local).json. Two consequences: a plugin the
user switched off for a project still loaded there, and a local-scope
install enabled for a project never loaded unless the project directory
matched the install's recorded projectPath exactly.
Merge enabledPlugins across the same layers Claude Code consults (user
settings, then the active project root's and cwd's .claude/settings.json
and settings.local.json; later wins) and apply it in
listClaudePluginRoots: `false` hides the plugin, `true` opts a
local-scope install in regardless of projectPath. Untouched ids keep the
existing behavior. Contributing settings files join the cache key.
Solves: Claude marketplace plugins loading in the wrong projects
Tests: claude-plugins.test.ts — per-project off switch (local wins over
settings.json, other projects unaffected) and enabledPlugins:true
opt-in for a local install recorded under a parent directory
Independent review noted that a file at or below the snapshot cap with a
text-like extension but binary content was fully decoded into three string
views and a line array before the sniff rejected it — roughly three times
the file size in transient allocations for output that is thrown away.
Split the loader: read the bytes, sniff those bytes, and derive the views
only for what survives. A refused 4MiB binary now costs one read and no
decode.
No observable change: the 966-case differential against the base commit is
byte-identical to the run before this split, still differing only in the four
intended BOM snapshot tags.
Also name in BlockContextSource.text the one shape that would violate its
same-content contract — a source object reused across two different line
arrays — since the contract is documented rather than enforced.
Alibaba Token Plan advertises both dated DeepSeek V4 snapshots, but only
deepseek-v4-flash-0731 had an entry in ALIBABA_TOKEN_PLAN_DISCOVERED_MODEL_LIMITS.
deepseek-v4-pro-0813 is not in ALIBABA_TOKEN_PLAN_STATIC_MODELS either (only the
undated deepseek-v4-pro is), so it fell through to `contextWindow: null` /
`maxTokens: null` — the #7486 symptom, still live for this one id.
Reasoning already worked: the `normalizedId.startsWith("deepseek-v4")` branch
gives it reasoning: true and the high/max effort ladder. Only the limits were
missing, so this is a one-entry fix at 1M context / 384K output, matching both
deepseek-v4-pro and deepseek-v4-flash-0731.
Extends the existing discovery test to advertise the id and assert its limits
and thinking config. Verified the test fails without the source change
(contextWindow/maxTokens come back null) and passes with it.
Refs #8847