Merged PR #4330 and fixed the remaining offered-boundary regression by stopping offer promotion at intervening live blocks.
Verified with targeted transcript/native scrollback regressions: 40 pass.
- Added `normalizeSingleStringField` to dynamically map misplaced string inputs to required schema fields for single-argument tools.
- Integrated argument normalization into `validateToolArguments` to handle model-specific variations in JSON payloads during validation passes.
- Updated `coding-agent` streaming and rendering components to recognize `_input` as a legacy alias for `input` across various UI paths and logic flows.
- Refactored `hashlineEditParamsSchema` to strictly enforce the `input` field while maintaining support for legacy aliases via runtime coercion rather than schema definition.
- Corrected unit tests to reflect that `_input` is rejected by the strict schema but handled gracefully by the validation layer.
SessionManager.#runFencedAtomicRewrite's finally now only clears #atomicRewriteFenceEpoch when it still matches the unwinding task's epoch. When flushSync supersedes an in-flight rewrite (bumping #diskEpoch and resetting #diskTail), a fresh atomic task scheduled at the new epoch can take ownership of the fence before the stale rewrite finally settles; the previous unconditional clear stranded the newer rewrite's bookkeeping so subsequent sync appends took the hot writer path and were then detached by the newer publish.
Regression: SequencedRewriteStorage pauses the first N writeTextAtomic calls on per-call gates. Test schedules a stale rewrite, forces flushSync to bump the epoch via a fenced append, schedules a newer rewrite that parks at pauses[1], releases the stale gate (stale unwinds and guard-rejects), then appends a custom entry — asserts writerOpens does not grow (fence preserved) and the fenced entry lands in the newer publish's body. Without the fix, writerOpens grows from 1 to 2.
Fixes#4338
- Added `#hasPendingAsyncWake` to detect running or pending background jobs owned by the agent.
- Deferred todo reminders and `session_stop` hook passes until all agent-owned background async jobs complete.
- Ensured scheduling pauses caused by async jobs do not trigger terminal session stops or premature todo nags.
Replaced the boolean #atomicRewriteActive flag with #atomicRewriteFenceEpoch: number | null. The fence branch in #appendToSessionFile now applies only while the pending atomic rewrite's epoch still matches #diskEpoch. Once flushSync -> #rewriteSynchronously bumps the epoch, the in-flight writeTextAtomic is guaranteed to abandon via its commitGuard, so subsequent sync appends can (and must) take the hot path against the freshly-published body instead of being stranded in memory when close() returns without another rewrite.
New regression: pauses writeTextAtomic mid-flight, appends a fenced custom entry, calls flushSync (which captures it into the durable body), then appends a message + custom entry after the epoch bump. Reads the current JSONL BEFORE releasing the paused atomic and asserts both post-flushSync entries are already on disk; then releases the atomic (commitGuard rejects) and closes the session and asserts nothing is lost.
Fixes#4338
- Simplified match logic to rely exclusively on content hash equality.
- Removed strict validation that rejected colliding snapshot tags.
- Updated recovery behavior to resolve collisions to the most-recently recorded snapshot.
- Refactored tests to expect successful preview and patching despite tag ambiguity.
- Added `getModel` to `AgentLoopConfig` to allow runtime model resolution.
- Updated `streamAssistantResponse` to resolve the model dynamically per provider call instead of using the stale configuration snapshot.
- Enabled mid-run model switches to take effect immediately for context promotion and retry fallbacks.
Normalized extension custom-message payloads before session state or persistence, including bare string sendMessage shorthands. Skipped legacy bare custom_message entries during context rebuilds and dropped malformed custom/hook messages before LLM conversion. Added regression coverage for the poisoned-session resume crash.\n\nFixes #4345
SessionManager.#persistTitleChangeEntry's catch fallback previously did a single-shot atomic rewrite: any prompt/tool appended while it awaited was fenced with #atomicRewriteDirty=true but never re-serialized. Extracted the fenced-rewrite do-while loop into #runFencedAtomicRewrite and used it from both #rewriteAtomically and #persistTitleChangeEntry, so fenced entries during either path are captured before the task resolves.
Added SessionStorage.drain(): for FileSessionStorage and MemorySessionStorage it is a no-op; IndexedSessionStorage already had one and now conforms to the interface. SessionManager.flush() and close() await it so a graceful shutdown does not exit while a fire-and-forget writeTextSync publish (queued by flushSync on an indexed backend) is still on the wire — reducing the residual publish-window race for Redis/SQL where the backend cannot be aborted mid-flight.
Regression covers the title fallback loop: TitleFallbackPausingStorage forces updateSessionTitle to throw, pauses the fallback's writeTextAtomic, appends a message and a custom entry during the pause, and asserts (a) both fenced entries land on the current JSONL, (b) the final title is applied, and (c) writeTextAtomicCalls >= 2 proving the loop iterated.
Fixes#4338
IndexedSessionStorage.writeTextAtomic no longer delegates directly to writeText, which yielded on #awaitPath between the guard check and the backend publish. The new impl consults the guard three times — up front, again after #awaitPath resolves, and finally inside the enqueued task immediately before #backend.writeFull — so a flushSync that bumps #diskEpoch while the atomic rewrite is suspended cannot land stale content on Redis/SQL backends. When the enqueue-time guard rejects, the optimistic index update is restored only when nothing has advanced it past our mtime, so a concurrent writer's state is preserved.
Added a PausableWriteFullBackend regression: the first writeTextAtomic parks inside backend.writeFull holding the per-path tail; the second queues with a guard that flips after the first is released. The backend records only the first content, confirming the guard is honored at publish time.
Fixes#4338
FileSessionStorage.#replaceSessionFileAfterEpermSync now unlinks the staged temp file when commitGuard returns false in both fallback branches: the ENOENT-vanished-target path and the post-move-aside path (where the moved-aside backup is also restored). Honors the writeTextAtomic contract that a guard-rejected stage is discarded.
Regressions cover all three guard-reject exits: the direct rename pre-check, the ENOENT branch inside the EPERM fallback, and the move-aside branch that also restores the backup. Each asserts no orphan .tmp remains in the session dir.
Fixes#4338
SessionManager.#rewriteAtomically now enables #atomicRewriteActive before #closeWriterHandle() and keeps it set until the rewrite task exits, so a sync append landing in the close-yield window is fenced and cannot open a fresh writer that the pending writeTextAtomic would then detach from the current JSONL path. Same pattern applied to the #persistTitleChangeEntry atomic fallback.
Added a regression that pauses the fake storage's writer.close() gate, appends a message and a custom entry during the pause, and asserts (1) no new writer opens (writerOpens counter unchanged) and (2) the fenced entries land on the current JSONL path after the rewrite completes.
Fixes#4338
SessionStorage.writeTextAtomic now accepts a commitGuard the backend calls synchronously immediately before publishing the staged body. FileSessionStorage performs the guard check and rename in the same tick via fs.renameSync (both on the direct path and the EPERM move-aside fallback), so a concurrent #rewriteSynchronously (flushSync -> Ctrl+C / session exit) that bumps the disk epoch cannot be overwritten by the stale body serialized before it ran. MemorySessionStorage and IndexedSessionStorage honor the same guard.
SessionManager.#rewriteAtomically threads a guard that returns false when the disk epoch changes, and re-checks the epoch after every writeTextAtomic before touching #fileIsCurrent / #rewriteRequired. #persistTitleChangeEntry's atomic fallback wires the same guard.
Added a regression that pauses the fake storage's writeTextAtomic mid-flight, appends a session_exit custom entry (which the fence records in memory), calls flushSync, releases the paused rewrite, and asserts the exit record is still on the JSONL path and the atomic publish was rejected by the guard.
Fixes#4338
Fenced synchronous session appends while an atomic full-file replacement is active so Windows EPERM fallback cannot detach the append writer from the current JSONL path.
Added a deterministic storage fake regression covering superseded compaction rewrites, title changes, session-exit diagnostics, resume, and post-rewrite tool/assistant tail persistence.
Fixes#4338
Addresses codex review on #4335: the previous wrap dropped to cmd.exe on Windows, which broke bash tool semantics for $VAR, $(...), source, and POSIX quoting even when the local executor would have resolved Git Bash / bash.exe. The wrap now takes the resolved ShellConfig (shell binary + login/-c args + optional prefix) from settings.getShellConfig() and reuses it for the ACP terminal/create shape, so the ACP path matches the local path on both platforms. Tests updated to stub getShellConfig and assert the resolved-shell shape deterministically.
The bash tool routes commands through the ACP client's terminal/create when the client advertises the terminal capability. It was passing the full shell line as the ACP command field with no args, which relies on the client interpreting command through a shell. Per the ACP protocol docs, command is the executable and args is its argv tail; a spec-conformant client spawns them directly (no implicit shell), so any bash line with a space, pipe, &&, redirect, or $(...) failed with ENOENT and the agent silently degraded to read-only tools.
The bash tool now wraps the shell line before the createTerminal call: { command: /bin/sh, args: [-c, line] } on POSIX and { command: cmd.exe, args: [/d, /s, /c, line] } on Windows. /d/s/c matches Node's spawn({ shell: true }) convention (/s preserves the whole shell line as one argv element on the receiving end). process.platform on the agent side proxies the client's platform, which matches the near-universal ACP shape of an editor spawning omp as a co-hosted subprocess.
Fixes#4333
Anthropic's OAuth usage endpoint now ships a generic limits[] array;
model-scoped weekly caps (Fable) exist only there while the legacy
seven_day_opus/seven_day_sonnet buckets are permanently null. Parse
weekly_scoped entries into anthropic:7d:<slug> tier rows (Claude 7 Day
(Fable)), backfill shared 5h/7d from session/weekly_all when legacy
buckets are absent, and accept limits[]-only payloads in hasUsageData.
Add scopeLimits/blockScope to claudeRankingStrategy so an exhausted
Fable/Mythos cap gates only matching-model requests instead of cooling
down the whole OAuth credential (mirrors the Antigravity per-counter
precedent). Dedupe the ACP /usage tier suffix when the label already
names the tier.
A bounded default-mode regex (e.g. [A-Z]{9}) whose greedy reach spans
a placeholdered secret plus a short trailing raw chunk resolved
differently depending on whether the leading portion of that span was
still raw text (first obfuscate() call) or already a placeholder
(later calls): a cut-resolution resume point that landed exactly on
the start of another already-generated placeholder was handed
straight to a fresh regex.exec attempt instead of skipping past it,
exposing a shorter/different match to a LATER call that the first
call never attempted (e.g. leaving a trailing byte raw on pass one,
then sweeping it into a brand-new placeholder on pass two), churning
provider-visible history and prompt-cache prefixes across that
transition.
Added extendPastAdjacentPlaceholders, which chains a cut-resolution
resume point through every immediately-adjacent generated-placeholder
segment (no raw gap) before allowing a fresh regex attempt, applied at
all three resume points in the partialPlaceholderCut handling block.
Both calls now land on the same next scan position and agree on the
same (conservative) resolution from the first pass onward.
Bun keeps the parent event loop alive when an unref'd child has a piped
stderr stream. The first #4324 fix started with stderr: "pipe", so a
long-lived idle TTS/STT/tiny/mnemopi worker could keep short CLI commands
alive even after proc.unref().
Switch worker stderr capture to a temp-file fd target instead of a Bun
ReadableStream pipe. The parent does not start any JS read while the
worker is alive; after onExit it reads the bounded tail from the file,
logs captured lines, appends the tail to the surfaced worker Error, then
closes and removes the temp capture.
Add a regression that spawns a non-test wrapper process with an idle
unref'd worker and asserts the wrapper exits immediately. Existing stderr
capture, truncation, and intentional SIGKILL behavior remain covered.
Fixes#4324
Separated audited offerable transcript rows from durable snapshot rows so lower finalized content below a live block can be repaired instead of duplicated when the live block grows.
Added transcript and virtual-terminal regressions for the lower finalized tail case.
Fixes#4326
The earlier fix made #generateRegexReplacement's no-stable-candidate
fallback (a pathological match-everything default replace-mode regex,
e.g. [\s\S]{8}) key-derived via #generateReplacement, so it stays a
fixed point across restarts \u2014 but only if the key itself is stable.
secretEntriesNeedPlaceholderKey previously treated every replace-mode
entry as never needing the persisted key, so a fresh install with ONLY
such a regex (no other entry needing a persisted key) never got one:
SecretObfuscator fell back to defaultPlaceholderKey(), a process-random
key regenerated on every restart, so the fallback marker churned across
restarts anyway.
secretEntryNeedsPlaceholderKey now also returns true for a regex entry
in a replace-mode shape with no custom `replacement` \u2014 exactly the
shape that can reach the key-derived fallback. A regex WITH a custom
replacement (always emits the literal configured string) and a plain
replace secret (pure content-hash) are unaffected.
Inference worker subprocesses (TTS, STT, tiny-model, mnemopi embeddings)
were spawned with stderr: "ignore", so a native crash inside the child
was completely discarded. The parent only ever logged the bare exit code
(e.g. Kokoro TTS's recurring "tts subprocess exited with code 7"),
leaving the recurring crash loop undiagnosable.
createWorkerSubprocess now pipes stderr and drains it in the parent:
- Each decoded stderr line is forwarded to logger.debug under
"<exitLabel> stderr" so operators get live visibility on chatty native
runtimes without touching the chat scrollback.
- A bounded 16 KiB ring keeps the tail of stderr so the eventual exit
Error carries the actual crash reason (ONNX Runtime traceback, glibc
assertion, etc.) instead of "code 7" alone. The prefix is preserved so
existing log grepping keeps working.
- The exit event and the stderr pipe are independent, so a synchronous
read in onExit would race the drain. SpawnedSubprocess grew a
stderrDrained: Promise<void>, and onExit chains the error surface off
it so callers see the whole tail. Tests can await stderrDrained
deterministically instead of racing wall-clock timers.
- Intentional terminate() SIGKILLs still stay silent — signal-exit
gating on intentionalExit is unchanged.
Fixes#4324
- Changed the `path` property from an array of strings to a single semicolon-delimited string across tool definitions and tests.
- Updated validation error messages to reflect the new `path` input format.
- Adjusted all relevant test cases to provide path targets as semicolon-separated strings.
findNonMatchingReplacement special-cased value.length <= 3 with a fully
exhaustive search over every base**length candidate (base = 90, so up to
729000 for a 3-character match). A default replace-mode regex of that
length matching every candidate (e.g. `[\s\S]{3}`) burned the entire
sweep \u2014 measured ~70ms \u2014 on every single match before reaching the
whitespace/keyed fallback, stalling provider requests on modest tool
output.
All lengths now use the same bounded single-position-substitution
search already used for values longer than 3 chars: O(length * 90)
instead of O(90^length). Measured ~1ms for the same case post-fix.
Correctness is unaffected \u2014 any regex whose rejection depends on a
single position (the realistic case: character classes, literal
matches, bounded repeats) is still found; this is the same bounded-
search tradeoff already accepted for longer values.
When a default replace-mode regex has no same-length candidate that
escapes it (a pathological match-everything config such as `[\s\S]{8}`),
`findNonMatchingReplacement` returns undefined. The fallback kept the
content-hash-derived replacement, but that is not a fixed point: the
regex re-matches it on the next obfuscate() pass, and since the value
is hashed from its own bytes (not the original secret), each pass
rehashes it into a different marker \u2014 churning the redaction and
drifting the provider prompt-cache prefix it anchors, both across
re-obfuscation passes and across obfuscator restarts.
Fall back to the same key+length-only marker already used for
per-chunk remainder redactions, which depends on nothing but the
per-install key and the value's length, so re-matching and
re-redacting it always reproduces the identical marker.
Added a macOS stat-device fallback for SSHFS mount detection so already-mounted remotes do not trigger a second sshfs invocation when mountpoint is unavailable.
Added a focused regression test and changelog entry.
Fixes#4319
- Tracked placeholder/partial-result paints via render() override so an update landing before the shape reaches the terminal skips resetDisplay().\n- Added negative-case unit tests proving no reset fires when the intermediate shape was never painted.\n\nFixes #4314
- Added renderer hooks for first-result placeholder replacement and partial-result settle repaints.\n- Enabled the hooks for SSH and covered the streamed-placeholder and settle seams.\n\nFixes #4314
Added providers.webSearchGeminiModel and GEMINI_SEARCH_MODEL so Gemini web_search requests use a selected grounding model while keeping gemini-2.5-flash as the fallback.
Covered OAuth, Developer API, and missing modelVersion fallback paths in Gemini web search tests.
Fixes#4312
Allowed npm:<package> install specs to validate against the resolved package name while still forwarding the original spec to Bun.
Added regression coverage for installing npm:pi-figma-remote-auth through PluginManager.
Fixes#4310
Converted ArkType validation failures into ConfigError results and surfaced models.yml validation failures in CLI model listing and noninteractive startup.
Fixes#4305
MCPOAuthFlow silently swallowed a rejected dynamic client-registration
response and then threw the opaque "OAuth provider requires client_id"
error from the fallback authorize probe. Users adding Figma's MCP server
hit this because Figma's DCR endpoint 403s every unlisted client (per
the Figma MCP catalog gate) and the surfaced string gave them nothing
actionable to do.
Capture the DCR endpoint + HTTP status (or the thrown transport error)
on the flow instance and, when the authorize probe confirms client_id
is required, throw an error that names the endpoint, the outcome, and
the manual oauth.clientId/oauth.clientSecret workaround. The no-DCR-
endpoint branch gets its own message so users know the server never
advertised registration in the first place.
Fixes#4307