Skip to content

v1.0.47

Choose a tag to compare

@github-actions github-actions released this 13 Aug 05:16
· 4209 commits to main since this release

@veyyon/agent-core

Added

  • AgentOptions.cacheEnforcement (and the matching agent.cacheEnforcement accessor) carries the prompt-cache enforcement level onto every provider request, so a host can decide whether a turn whose cache markers were demonstrably ignored is reported or fails the run. Defaults to the provider's own default, which reports rather than fails.
  • A partial-completion ledger on any tool batch that is cut short. When a provider stream dies mid-turn (for example an NGHTTP2_INTERNAL_ERROR reset) or a steering interrupt lands mid-batch, one bounded summary now names every call in the batch as ran, ok, ran, failed, started, no result recorded, or never ran, so the model retries only the dropped calls instead of re-running discovery. It carries ids and outcomes only, never tool output, and is also exposed structurally as batchLedger on the placeholder result's details.
  • The ledger now also names a tool call whose arguments were still streaming when the turn was cut off. Those calls have their toolCall block deleted (partial arguments are unsafe to run and an unpaired tool_use breaks replay), so before this they had no result, no block, and no mention anywhere: the model read a turn in which it had never asked for that tool. Their identity is carried on the new AssistantMessage.incompleteToolCalls and listed as never ran, arguments never finished, with a line telling the model to reconstruct the arguments rather than copy them back. When EVERY call in the turn was still streaming there is no placeholder result to attach the ledger to, so it is delivered as a turn-level synthetic notice instead.
  • @veyyon/agent-core/compaction/cache-aligned-context: a cache-aligned summarization request that replays the live session's own provider prefix instead of building a fresh one. Local compaction sent a standalone request (a different system prompt, no tools, and the whole conversation re-serialized into one synthesized user message), so it shared zero cached prefix with the session it was compacting, and it fired exactly when that session was largest. It was affordable only because it was lossy: TOOL_RESULT_MAX_CHARS truncates every tool result to 2,000 characters. buildCacheAlignedCompactionContext sends the session's tools, its system prompt, its whole message array byte-for-byte, and one appended instruction turn, which reads as cached prefix and carries the untruncated tool results. Measured on a synthetic 210-message session with a ~192k-token live window: 38,590 fresh input tokens today against 183,262 cache-read plus 457 fresh, which is $0.116 against $0.056 at Anthropic Sonnet rates, so 2.05x cheaper and no longer truncated. It replays the WHOLE array, not the span being discarded, because the message-side cache breakpoints sit on the trailing messages and a request that stops at the cut point diverges before any breakpoint. canUseCacheAlignedCompaction refuses unless the model row carries the prompt-cache capability, a session system prompt was supplied, and the conversation does not end on an unanswered tool call (appending a user turn there is an invalid request); on every refusal the existing truncated path runs unchanged, because a cache-aligned request that misses costs about three times more than the one it replaced.
  • SummaryOptions.serviceTier and GenerateBranchSummaryOptions.serviceTier, forwarded onto the request by generateSummary, generateTurnPrefixSummary and generateBranchSummary. A host resolves a service tier per provider family (serving priority and cost, not reasoning depth) and could put it on a live turn but not on the summary of that turn, which is the largest request a session makes. compact() rebuilds its SummaryOptions field by field, so the new field is restated there too.
  • toolResultNeverRan(details): whether a tool result is a placeholder for a call that never reached the tool. Both placeholder shapes count (__synthetic with executed: false, a call the loop never dispatched, and __skipped with entered: false, a call an interrupt cut the batch short of) and entered: true deliberately does not, because that tool was running when the interrupt arrived. It owns its own module (@veyyon/agent-core/tool-result-never-ran, re-exported from the package root as before, so no import changes) rather than living in the agent loop, because compaction reads it too and a pruning pass must not import the loop. Three decisions read it and must not disagree about whether work happened: whether a card may drop the model-facing placeholder text, whether a failed turn is safe to discard and replay, and whether a result counts as a read of the file it names.
  • TOOL_BATCH_LEDGER_HEADLINE_PREFIX, the opening of the sentence renderToolBatchLedger writes. The ledger is a standing instruction with an expiry its own text cannot express, so a host has to recognize a rendered ledger to stop sending one the model has already answered, and the turn-level form of it is a plain user message carrying no structured ledger to match on. The renderer now builds its headline from this constant, so the thing a reader matches and the thing the writer emits cannot drift apart.

Changed

  • The three compaction prompts (compaction-summary, compaction-update-summary, compaction-summary-context) are now oh-my-pi's text verbatim, replacing veyyon's. A test pins each one by SHA-256 so an unapproved edit fails the build rather than degrading summaries silently. Known behavior differences from the veyyon text: the summary prompt no longer states that compaction is in-place with the recent turns retained beside it, and it no longer separates the durable overarching goal from the mutable current task.
  • A compaction summary now enters the provider request as agent-attributed user content instead of a developer message, so model-generated history cannot outrank live developer policy.

Fixed

  • A message the compaction passes just rewrote now measures what it currently holds. estimateTokens cached its answer per message object on the stated assumption that a stored message is never edited in place, and three passes do exactly that: the shake/dedup elision assigns a placeholder over message.content, the overflow prune blanks a result, and the image drop splices blocks out. Object identity survives all three, so every later reader was told the size the message had BEFORE the bytes were removed, for the rest of the session. Since the compaction decision floors the provider's figure with that estimate, an estimate that could not fall meant maintenance could never bring a session back under the trigger: the "the dedup alone fixed it, skip the summarization" path could not fire, the dead-end rescue measured a residual that was already gone, the prune's own cache-warm suffix guard priced a tail it had already blanked, and the operator's context meter reported elided bytes as live. A cached entry is now trusted only while the content it was computed over still has the same shape (the fragment sequence and each fragment's length), which is one walk with no tokenizer in it, so a rewrite is visible on the next read while an unchanged message still never re-tokenizes.
  • A small-window model can compact again. The output budget a summarization request asks for is part of what the context window has to hold, and it was derived from the absolute reserveTokens alone: on a 16k-window model the default reserve asked for 13107 output tokens on top of the history being summarized, so estimateCompactionRequestTokens priced the request above the whole window, candidate admission skipped every candidate as unsendable, and the session never compacted at all. What the operator saw was a context gauge growing past 100% with "Auto-compaction failed: ... holds 16000 tokens and the summary needed 23418" repeating once per turn, and only error-driven overflow recovery doing any work. A reserve that large for the window now falls back to the same proportional reserve the trigger policy already uses (resolveBudgetReserveTokens), so a small window buys a SHORTER summary instead of no summary; the turn-prefix request of a split turn is bounded the same way, and both the estimate used for admission and the request actually sent read one owner so they cannot disagree. A window the reserve genuinely fits in asks for exactly what it asked for before, so nothing changed for a 200k-class model.
  • A provider that repeats a tool-call id no longer poisons the rest of the session, whether the repeat arrives inside one assistant message or on a later turn. Both calls ran, so the stored turn carried two tool_use blocks sharing one id and two tool_results pointing at it, which no layer can pair: the outbound canonicalizer maps by original id and collapsed both onto one handle, and the wire form is rejected by every provider that validates the pairing. Since the malformed turn is stored, it replayed on every later request, so one glitched stream ended the conversation rather than one turn. A repeated id is now renamed (<id>_2, <id>_3, ...) where the finished message is assembled, before dispatch and before storage, so each call keeps its own result. Providers that hand out ids from a per-message counter (call_0, chatcmpl-tool-0) hit the cross-turn case on their second tool turn, and the canonicalizer's handle map is keyed by the original id and lives for the whole session, so two such calls collapsed onto one handle however many turns apart they were. An id already taken anywhere on the branch is now renamed too.
  • An absolute compaction.threshold larger than the running model can reach is now capped at the auto point (the window minus the reserve) instead of one token below the window. A trigger inside the reserve can never fire, because the request that would push the context that high is refused or overflows first: a 256000 threshold on a 200000-token model resolved to 199999, which silently turned proactive compaction off and left error-driven recovery to do the work, while the operator was told compaction was running "at 200k". The cap is now the largest trigger a request can actually reach, and the flag that drives the operator notice is set when the configured amount is past that point rather than past the window. An amount equal to the window is therefore reported too: it is as unreachable as one twice that size.
  • A tool call whose signal had already aborted when dispatch reached it now carries the same skip details as every other interrupted call. The placeholder for it was built with an empty details bag, so the discriminator consumers key on to tell "never ran" from "the tool refused this" was missing on exactly the calls that produce it most: the siblings queued behind the one call that cancelled the run. Its text is fixed per abort reason, so a whole batch of them reaches the model as one line repeated, and a consumer comparing that text counted one failure happening over and over. It now stamps __skipped with the interrupt source and whether execution had been entered, matching createSkippedToolResult.
  • A successful server-side compaction can now actually commit. compactWithProvider returns an empty summary by design, because the compacted window the provider returns is the artifact for that span and veyyon deliberately does not also pay a model to re-summarize it. assertValidCompactionResult rejected every empty summary, so a billed, successful round trip was thrown away with "the generated summary is empty", the window was discarded, and history was never trimmed. The local fallback could not rescue it either, since the driver falls back only when the remote call itself failed. The emptiness rule is now conditional on the replacement artifact: an empty summary is valid only when preserveData carries a remote window that passes validation, presence of the key alone is not enough, and an empty summary with no window still throws. That message now says no window was stored, so the next reader is not sent down the remote path by mistake. The cut-point and token-metadata assertions are unchanged and apply to both shapes.
  • A session that compacted server-side and then compacted locally no longer loses the span in between. hasReusableSummary treated only the two dead provider-native keys as unusable, so a current remote entry looked like an ordinary prior compaction and prepareCompaction adopted it with a previousSummary of "": the messages the window covered were never re-expanded. The local pass that followed then stripped the window, as it must, leaving that span neither summarized nor replayable. The live key now sits alongside the two legacy ones in one non-reusable list, so a remote entry is looked past and its real messages are re-expanded and summarized locally, exactly as a legacy entry already was. Ordinary local entries with real summary text are still reused.
  • Compaction no longer reports "Nothing to compact (session too small)" against a full context gauge, nor succeeds while freeing nothing. A tool result is never a valid cut point, because cutting there would separate it from the call it answers, so a turn whose result is larger than the whole keep-recent budget has no usable boundary behind it. Every cut the search could reach then either kept the entire range, leaving nothing to summarize and refusing, or kept the oversized result itself, summarizing a little and freeing nothing so the next turn asked again. The second is the worse of the two, because it looks like it worked: a warning arrives every turn against a gauge that never moves. The cut now lands on the turn's own start and the oversized result inside the kept tail is elided (see the tail-budget entry below), which frees the same bulk without feeding the user's latest message to the summarizer; keeping nothing remains the answer only when no turn start exists inside the range. A range that genuinely fits the budget is still refused, and a boundary that does leave a usable tail is still preferred over discarding one.
  • The retained tail after compaction is hard-bounded by compaction.keepRecentTokens, and the default drops from 20000 to 10000 — the tail rides the prefix cache anyway. The cut-point search can only land on a turn boundary, so a single oversized recent turn (one huge file read or command output) used to land ENTIRELY on the kept side: tails of ~100k tokens survived every pass, and the bulk was the lowest-information content in the session. prepareCompaction now elides inside the kept tail when it exceeds the budget: heavy non-error tool results are replaced with an explicit marker, largest result first regardless of recency, and the original text rides the preparation so the session layer can offload it to a recovery artifact:// blob and persist the rewrite. Never elided at any size: the newest user message, assistant text and tool calls, error results, and skill reads. The bound is enforced against the same estimator the cut search uses, including its provider-vs-local ratio scaling, so a kept tail over budget now means nothing elidable remained, not that the budget was advisory.
  • An empty cut-point list no longer indexes past the start of the array. A range containing nothing but tool results has no valid cut point at all, and the over-budget fallback read cutPoints[cutPoints.length - 1] on an empty list, so cutIndex became undefined and the split-turn check dereferenced it.
  • The recent-token budget is no longer scaled by the size of the system prompt and the tool schemas. Compaction scales the budget by how far its local estimate undershoots what the provider charged for the same messages, but the comparison used the provider's whole prompt count, which includes content that is in no session entry. Growing the tool set therefore shrank how much conversation each compaction kept, for no reason a user could see or change: with one fixed conversation, a 20k harness cut the retained tail by a third and a 60k harness by more than half. Callers pass the non-message figure through CompactionPreparationOptions.nonMessageTokens and it is subtracted before the ratio; when nobody knows it, the budget is left alone rather than scaled by a guess.
  • Prune and shake no longer aim at exactly the entries they must not touch when the latest compaction kept nothing. Both skip entries behind the compaction boundary, because those were summarized away and are never sent again, so rewriting them churns persisted history without shrinking a prompt. Both resolved that boundary with a plain findIndex and clamped a miss to 0, and the keep-nothing marker matches no entry by design, so it read as "nothing is behind the boundary" — the inverse of what it means. Boundary resolution now has one owner, resolveCompactionBoundaryIndex, which places a keep-nothing boundary just past the compaction entry: everything before it is summarized away, everything after it is ordinary live context. An id that is merely absent, which a forked or migrated branch can produce, still reads as no boundary rather than as keep-nothing.
  • Server-side compaction no longer chains a compacted window onto a host that cannot read it. The window is an opaque encrypted_content blob and the compact endpoint is stateless, so the blob is the whole conversation state and only the provider that minted it can decrypt it — but the chain site read the previous window out of preserveData without comparing the stored provider/api against the model now compacting, while the replay side had always keyed on them. A session that switched hosts mid-run (openai to azure, or the reverse) therefore posted the other host's window on its next compaction, drew a rejection, and fell back to local compaction with a user-visible warning, wasting a full compaction round trip at exactly the moment the context was overflowing. A window from a different provider or api is now dropped and the compaction starts fresh; the readable summary of that span is carried forward either way.
  • A remote summarizer that answers with something other than a summary no longer writes its whole reply into the log. compaction.remoteEndpoint points at whatever the operator configured, so a corporate proxy, a captive portal, or a plain web server in front of the intended endpoint is the common failure, and each answers with an HTML page. The non-2xx body was logged uncapped on every compaction attempt of every turn; it is now capped at 4096 characters and reports the original length, matching the Google provider path. The cap is applied before the secret redactor rather than after, so the redactor's scan is bounded too.
  • requestRemoteCompaction refuses a blank summary instead of returning it. The summary REPLACES the history it summarizes, so a 200 carrying {"summary": ""} or whitespace deleted the conversation and reported success. The local summarizer in generateSummary already refused this, and the remote branch returns remote.summary without re-checking it, so the transport was the only place the check could live. Both wire shapes now refuse, and both say the history was NOT compacted.
  • A tool call cut off while it was waiting for approval is no longer reported to the model as possibly having applied side effects. The batch ledger keyed "started, no result recorded" off the flag that also covers time spent in beforeToolCall, so an interrupt during a permission prompt sent the model to check state for a tool whose body never ran. It now keys off entry into tool.execute() itself.
  • A pause/resume listener that throws is now reported. It was caught and discarded, so a host indicator could sit on "running" through a pause with nothing anywhere explaining it, and the listener kept its subscription and kept missing transitions.
  • A cut-short turn that left no placeholder result no longer silently discards its ledger. The ledger was built from the surviving toolCall blocks plus incompleteToolCalls, but delivered only while iterating the surviving blocks, so a turn whose single tool call was still streaming its arguments (its block deleted, so zero surviving blocks) produced a ledger naming that call and then threw it away. That is the one case the ledger was written for: with no block, no result and no placeholder, the ledger was the only place the call was named at all. It now travels as a turn-level synthetic notice when there is no placeholder to carry it. A lone call that is fully described by material already in the transcript still emits nothing.
  • A tool cut off inside tool.execute() is no longer told to retry itself verbatim. The "may have applied partial side effects, check state before retrying" wording lived only in the batch ledger, and the ledger is suppressed for a one-call batch, so a lone bash interrupted by steering after it started running received the plain skip text ending "retry the skipped tool if it is still needed", an instruction to re-run a command that may have half applied. The placeholder text now carries the distinction itself, keyed on entry into tool.execute(), so it survives the one-call case. The one-call noise guard on the ledger is unchanged: a batch with no siblings still gets no inventory.
  • The batch ledger no longer reports a call cut off mid-execution as one that ran. Outcomes were keyed off the presence of a result message, but a call whose tool.execute() was aborted mid-flight is answered eagerly with a skipped placeholder and so has one, with isError set. It was inventoried as ran, failed under the advisory saying its result is already in the transcript and it must not be re-run: false twice over, since nothing usable ran and its side effects may be half applied. It is now inventoried as started, no result recorded.
  • Compaction no longer refuses a session whose context window is full. The budget of recent history a pass must preserve was a flat compaction.keepRecentTokens (20000) and was never measured against the model, so once the system prompt, the tool schemas and the skills took enough of a modest window, the conversation's own share was smaller than that budget while the window was full. The cut-point search then found nothing to cut, prepareCompaction returned nothing, and the operator was told the session was too small to compact against a gauge reading no room left. The budget is now capped at the space the conversation is allowed to occupy, which is the compaction trigger for the model's window minus everything in the prompt that belongs to no entry. The cap applies only when the trigger is derived from the window: an operator who sets an absolute threshold has stated the trigger directly and it may sit far under the window, which would shrink the budget until the cut landed inside the exchange that just finished. An unknown window, an unknown prefix, or a prefix that already exceeds the trigger also means no cap and the previous behaviour. A prefix estimate larger than the provider's whole prompt count is also reported now instead of passing silently: it is proof the two disagree about what is in the prompt, it already skipped the scaling rather than corrupting it, and it was measured on 11.6% of one day's Gemini turns with nothing anywhere saying so.
  • Supersede pruning no longer treats a call that never ran as a read of the file it named. A turn whose stream dies after emitting its calls pairs each of them with a never-ran placeholder, and the pass keyed only on the call's arguments, so a dropped read counted as the newest read of that path. Two things went wrong, and the second loses content the model had: the placeholder was blanked to [Superseded by a newer read of this file], replacing the one fact it carries (nothing ran) with a claim about a read that did not happen; and because the group is walked newest-first, it also marked the last REAL read of that path superseded, so the model was left with a pointer to a read that never happened instead of the file. A placeholder is now skipped both as a candidate and as a key contributor, while an interrupted call that had entered tool.execute() stays a real result and supersedes as before.
  • A tool call the abort never closed is kept when its arguments are provably complete, instead of being deleted and misreported. An abort is decided inside the loop: the signal is tested before the event already pulled is processed, so a steering interrupt discards delivered events, including the toolcall_end of a call whose last argument byte had arrived. Judging completeness by that event alone told the model, of two fully written bash calls, that their "arguments never finished" and that "no record of them is left in this transcript. Reconstruct their arguments rather than copying them back" - false in every clause, and the arguments it described were deleted in the same pass. Completeness is now read from the block's own accumulated argument JSON: a payload that parses to an object means the provider finished, and the call is retained with that parse rather than with the tolerant partial one a streaming block carries. Truncated JSON, a payload that is not an object, and a provider that writes no marker at all stay incomplete, the last deliberately, because silence is not evidence.

Removed

  • compact() no longer generates a short PR-style summary, saving one model request per compaction. The shortSummary field stays on CompactionResult, the compaction entry, and the collab and share projections: compaction hooks still set it, and sessions written before this change still carry it. Its only display consumer is the session-listing title fallback, which veyyon reaches only when its own tiny-model titler is disabled or declined the first message.
  • A tool call that an interrupt skipped now says so in its details, not only in its text. A queued message or a peer interrupt can cut a batch short before a call is dispatched, and the placeholder result that stands in for it is marked as an error, because from the provider's side the call produced no answer. The headline is fixed per source, so two unrelated interrupts produce byte-identical text, and anything reading that text to decide whether a tool failed sees one failure repeating. The same discriminator already existed for calls the assistant never dispatched at all; this is the interrupt case it did not cover.

@veyyon/ai

Added

  • CheckCredentialsOptions.credentialIds probes only the credential rows it names, instead of every active row. checkCredentials is one sequential network round-trip per credential, so a surface asking about a single account paid for every other account of every provider and waited behind them; an id that is no longer stored contributes no result rather than an error, because a row a peer logged out between the render and the request is already answered. Omitting the field keeps the whole-store behaviour every existing caller wants.
  • Routing answers which account will serve the NEXT request, not only which one served the last. SessionCredentialRouting.activeCredentialId is now filled before a session has spent anything, and activeIsPrediction marks that answer as a replay of the routing decision rather than an observation of one. A fresh session, a session whose last-used account is rate limited, and a session with every account blocked all reported no active account at all, so every surface above them said "no account" while the next request was already determined. The prediction runs the same ladder resolution runs (an explicit pin, then a sticky account that can serve now, then round robin ordered by block availability), and it is pure: it reads #providerRoundRobinIndex without advancing it, because a status line and an open card rebuild the answer on every frame and painting a screen must not move traffic.
  • /login openai, /login google, /login groq, /login xai, /login mistral, /login minimax and /login aimlapi now prompt for an API key, validate it against the vendor's own models endpoint, and store it, instead of refusing with "has no browser login". Each of the seven registry definitions gained a createApiKeyLogin flow pointing at that vendor's key page. AI/ML API is stored without a check because its model list answers unauthenticated requests and cannot tell a good key from a bad one. azure, google-vertex and amazon-bedrock still refuse: none of them is authenticated by a single pasted key.
  • AuthStorage.onUsageLimitWithheld(listener) reports the move that loadBalancing refused, as UsageLimitWithheldEvent { provider, account, idleSiblings, retryAtMs }. With the gate off, markUsageLimitReached records the block and returns that account's own reset, which is correct and silent: the caller cannot tell "your window returns in four hours" from "your window returns in four hours and two idle accounts could have served this". The event carries that difference, deduped inside the storage by the exhausted window it describes, because the block scope and provider type key that decide whether a sibling is idle are private to the class and a return value has no dedupe key. It is the counterpart of onCredentialFailover and is subscribed the same way, through the constructor option or the method. Nothing fires when no idle sibling exists, or when load balancing is on and the move happens.
  • AuthStorage carries a durable per-provider account selection: selectProviderCredential(provider, credentialId, { sessionId? }), selectedProviderCredentialId(provider) and clearProviderSelection(provider), backed by an auth_provider_selection table in the same SQLite store as the credentials and keyed by the account's resolved identity rather than its row id, so the choice outlives a token refresh, a re-login, a restart, and a different profile. Selection resolves at the same chokepoint as the old session pin, so sticky routing, OAuth ranking and divergence reporting see it without a second code path; an explicit session pin still outranks it for that one session. A store that does not implement the three optional hooks keeps its previous behaviour exactly.
  • AuthStorageOptions.loadBalancing (boolean | () => boolean, default true) gates quota-driven movement between accounts of one provider. When it resolves false, markUsageLimitReached still records the block and returns that credential's own retryAtMs instead of switching to a sibling, so the caller waits out its own window rather than spending another subscription. Auth-death rotation is deliberately outside the gate: a revoked or invalid_grant credential cannot serve the request whatever the operator asked for. discoverAuthStorage forwards the option to both the broker-backed and local stores, and passing a resolver lets a live setting change take effect without rebuilding the store.
  • AuthStorage.onCredentialFailover(listener) reports an auth-death handover as CredentialFailoverEvent { provider, from, to, cause } with a human label for each account (name, else email, else account id). The event fires from the resolution that actually served rather than from the rotation that predicted it, because rotation cannot know which sibling the ranking will pick; a handover that lands back on the same account, or one that no request claims within 60s, is dropped rather than reported.
  • The prompt cache is now checked instead of only billed, and a request whose cache markers were demonstrably ignored can fail the run. Every provider placed its markers and then read cacheRead solely to price the turn, so four cache defects shipped and were each found by reading a bill: a Codex breakpoint the backend answers invalid_parameter to, Claude-via-OpenRouter alias rows that never got a marker at all, a retention control sent to generations that reject it, and /branch re-prefilling the whole transcript. All four are silent to a cost meter and unmistakable to a check — the request carried anchors, the prompt was over the cacheable floor, it was not the first turn on the key, the window was open, and the provider reported neither a read nor a write, which cannot happen when caching works. That one verdict is the only one that can fail; a moving window, an edited prefix, and a provider that does not report cache writes are reported and never thrown on, so a working session is never halted by a guess. The default is to REPORT: blocking is opt-in through VEYYON_CACHE_ENFORCEMENT=error or StreamOptions.cacheEnforcement, because the verdict is proven against provider usage reporting and a provider that changed what it reports would otherwise stop every session. Wired on anthropic-messages, the surface that reports cache writes and can therefore prove the verdict.
  • The failure lands on the NEXT request rather than the one that was rejected. A rejection is only knowable once usage arrives, by which point the money is spent, so failing there would cost the completed assistant turn as well; deferring keeps the work and still stops the session before it pays the same full price twice. It is raised once and then cleared, so one historical rejection cannot brick a session.
  • The cache check gives up its claim a fifth of the way before the nominal cache lifetime, rather than accusing right up to the boundary. The window belongs to the provider: it is measured on the provider's clock, Anthropic refreshes it on every cache HIT rather than on every request so its start moves where no client can observe it, and an entry can be evicted early under load. Comparing against the nominal TTL would therefore report a genuine expiry as a rejection near the edge, and with blocking enabled that halts a working session for something unpredictable. Past CACHE_WINDOW_GRACE of the lifetime a miss is reported as cold rather than rejected: a missed finding still shows up in the record, whereas a wrong one stops the run. cacheWindowGraceMs() exposes the same threshold so a caller reasoning about the cost of a long wait cannot disagree with the verdict at the edge.
  • A side-channel request is no longer reported as a degraded cache. Agent#buildSideRequestContext deliberately mirrors the main loop's system and tools prefix so a compaction, title, advisor or /btw request SHARES the prompt cache, but those requests carry different and usually much shorter messages, so they read back only the shared prefix — a fraction of what the previous conversational turn read. That is the product working as designed, and every one of those turns was warning about it. A collapse is now only claimed when the current prompt is still at least as large as the previously cached prefix, because a smaller prompt cannot contain that prefix and reading less of it is arithmetic rather than a defect. The comparison baseline still follows every turn down, including a side request's: remembering the maximum instead would make compaction over-report forever, and refusing to update on a non-comparable turn would freeze the baseline so a collapse could never be reported again, so one self-correcting turn of blindness is the smallest of the three failures and the sequence is pinned.
  • Cache observations are kept per cache identity instead of in a single slot, so the check works on gateway traffic. A provider-session record is scoped to an endpoint and model, and several logical conversations share that scope: the auth gateway serves many clients through one streamAnthropic. With one slot those interleaved and each reset the other's history, so every request reported itself as the first request on its key and the check silently did nothing — a false negative, but on exactly the traffic that most needed watching. A latched rejection is also scoped now, so a rejection on one conversation can no longer fail the next request of an unrelated one that happens to share an endpoint and model. The map is bounded at CACHE_TRACKER_MAX_KEYS and evicts least-recently-used, because a long-lived gateway's key space is its clients' and an unbounded map is a slow leak; touching an identity refreshes its recency, so a busy conversation is not evicted by a burst of one-off keys around it.
  • An expired cache window now names the gap that expired it. The verdict said prompt cache was cold (window-expired); wrote 51000 tokens, which tells an operator they just paid to re-read the whole prompt and nothing about what to change: a long tool call, a daemon, an irc wait with no timeout and a lunch break all produce that same line. It now reads after a 7m12s gap, in minutes and seconds because the number is compared against a wall-clock wait. That attribution is also the evidence needed to decide whether keeping the window warm through long waits is worth paying for, which cannot be judged from a cost total alone. The other cold reasons report no duration rather than inventing one, so a first turn never claims to have expired.
  • AssistantMessage.incompleteToolCalls carries the id and name of every tool call whose arguments were still streaming when the turn was cut off. Those calls have their toolCall block removed, because partial arguments are unsafe to run and an unpaired tool_use breaks the provider's tool_use/tool_result pairing on replay, and until now the removal took the call's whole identity with it. Both fields arrive with the provider's block header, before any argument delta, so they are complete even when the arguments are not.
  • Cursor now reports how the provider says its own context is composed, on AssistantMessage.providerContextComposition. ConversationTokenDetails carries an undeclared field 3 that protobuf dropped silently, and decoding it out of Cursor's own client store shows it is a per-bucket breakdown of used_tokens whose eight buckets sum to the total exactly. Only a gateway that assembles the prompt can measure this: it knows what the tool schemas cost after its own serialization, where we can only estimate what we sent. The first real sample puts tool definitions at 8,326 of 14,483 tokens, which is 57% of the window spent before the conversation starts, and is not a number any local estimate would have found.

Changed

  • AuthStorage.login now reports which credential row it wrote (OAuthLoginIdentity.credentialId), and an API-key flow declares that the answer it wants is a secret (OAuthPrompt.secret). An upsert answers with every row a provider has rather than the one it added, so a caller that wanted to act on the account just stored had no way to identify it; and a UI had no way to know a pasted API key should be masked without reading the prompt text.
  • A retry no longer throws away the money the abandoned attempt already cost. Every in-provider retry discards the attempt's text, which is right (it is not replayable), and used to discard its billed tokens with it, which is not: a stream that dies after message_start was billed for the whole prompt including the cache write, an empty Gemini answer was billed for every thinking token it sampled, and an aborted thinking loop is the most expensive discard in the system. Under prompt caching the discarded attempt is usually the expensive cache WRITE and the survivor a cheap read, so the error is not a rounding difference, and a provider whose limit window is measured locally from observed cost also under-reported how much of the operator's quota was gone. The spend now rides forward in Usage.discarded with its price folded into cost.total, while the delivered token fields stay untouched so the context meter still describes the message that survived. Anthropic's four retry triggers collapsed into one discard owner (which also fixed a stale stopDetails three of them left behind), Google's two reset copies into one, and the wholesale usage = { … } replacements that erased carried facts now go through inheritUsageCarryovers.
  • Anthropic requests now cache the stable Veyyon system-prompt prefix separately from changing project, assignment, and Argot blocks, preserving prefix reuse across parent and subagent turns.
  • Official GPT-5.6 Responses requests (api.openai.com) now mark Veyyon's stable harness block with an explicit OpenAI cache breakpoint, preserving prefix hits when project, task, or Argot suffixes change. Codex Responses Lite is excluded: the ChatGPT Codex backend does not accept the field.
  • Model reasoning intent now resolves once before provider mapping, so effort, mandatory-thinking floors, routed model IDs, and transport-specific token budgets use one request plan.

Fixed

  • Devin and the OpenAI-completions wire now publish the accumulated tool-argument text on the content block as it streams, so a tool-call preview draws the command as it is typed instead of appearing whole when the call ends. Both providers accumulated the argument fragments privately (a local map, and block.partialArgs) and only exposed them through block.arguments, which is re-parsed at most once per STREAMING_JSON_PARSE_MIN_GROWTH bytes of growth; a call shorter than that threshold parsed once and then froze, so a renderer reading the block saw the first fragment and nothing more until toolcall_end. The accumulated text now goes on the block through setStreamingPartialJson at every delta and is cleared at toolcall_end, which is the same marker the incomplete-tool-call ledger reads to decide a call's arguments never finished — leaving it set would report a completed call as truncated. A host that streams function.arguments as an object rather than a JSON fragment (MiniMax) publishes nothing: its value is not concat-safe text and each chunk is already complete.
  • A provider failure that carried no body now names that instead of trailing off after a colon. Devin API error 500 Internal Server Error: was the whole message an operator got, and the same shape reached them from GitLab Duo, the Codex response handler and a Cursor trailer that carries a code and no sentence; a whitespace-only body from a proxy rendered as a colon and a few spaces. boundProviderErrorDetail now answers (no detail) for an empty or whitespace-only body, so every site that interpolates a provider body says what happened without a per-site fallback. Anthropic keeps its own status code (no body) wording deliberately: the context-overflow classifier matches those exact bytes on a 400 or 413 to recognise an overlong prompt that Anthropic rejects with no envelope, so that site now branches on the empty body explicitly rather than relying on the empty string being falsy.
  • The Devin auth error is the last site that interpolated a whole response body into Error.message, and it is now capped like every other one. An auth endpoint behind a corporate proxy or a captive portal answers with an HTML page, and that page reached the TUI, the session file, and every later read of the turn.
  • A Cursor stream that fails on the wire is now retried, like the identical failure from Devin already was. Cursor and Devin both speak Connect over HTTP/2 and both report a dead stream the same way, a trailer carrying a code and a sentence, but only Devin mapped that code onto a status the shared classifier reads. Cursor threw a bare ProviderResponseError with an envelope kind, which classifies as no kind at all, so an unavailable, an internal or a deadline_exceeded from Cursor failed the turn outright while the same code from Devin was retried and recovered. Both providers now read one table (connectFailureStatus), which places a transient code as 503, a rate limit as 429 and unauthenticated as 401, and which accepts either wire spelling of a code, since an HTTP/2 grpc-status trailer carries the number where a Connect end-stream trailer carries the name. A code the table cannot place is a fault of the request itself and stays terminal, so invalid_argument is reported exactly as before. An unreadable Cursor end-stream frame is now an incomplete stream rather than a protocol violation, because the terminal event never arrived in a form anything could act on.
  • A provider error that declares itself transient is now retried by the provider loop, not only by the turn loop. ProviderResponseError attaches Flag.Transient for the kinds that produced no content, and isProviderRetryableError (which Anthropic's and Devin's in-provider retries call) ignored that flag and re-derived transience from the message text. So a Devin empty body was retried by one loop and refused by the other, and a truncated Cursor stream was retried only because its sentence happened to contain the word "truncated": rewording the message would have silently retired the retry. The classifier is now the single owner of transience, and the per-kind verdict is a Record keyed on the kind union, so a new kind cannot be added without recording a verdict for it.
  • A 2xx with no body and a stream that ends with no final message are now retryable where they are reported. Both were labelled envelope, which classifies as nothing, so a missing OpenAI-wire body and a result-less EventStream ended the turn on the first attempt even though neither had produced any content. They are now empty-body and incomplete-stream respectively; the replay-unsafe guard still blocks a retry when partial tool output already escaped.
  • A transport failure spelled as an errno is now retried like the same failure spelled in prose. read ECONNRESET and connect ETIMEDOUT 10.0.0.1:443 carry no word the transient-transport classifier matched, so a dead socket ended the turn on its first attempt while fetch failed, terminated and connection refused (the wrappers undici usually produces for the same fault) were retried. The OAuth-transient pattern in the same file has always treated these codes as transient, so a token refresh retried what a model request gave up on. ECONNRESET, ECONNREFUSED, ECONNABORTED, ETIMEDOUT, EPIPE and EAI_AGAIN now classify as transient, word-bounded like the status numbers so an identifier containing one does not match. A turn carrying a tool call is still not resampled, because replaying it could double-apply the tool.
  • No behavior change: the Gemini 2.5 budget schedule's comment named a [high, max] effort pair the catalog no longer computes. It now states the ladder a budget-range row actually gets (minimal..xhigh) and why high must sit below the tier above it.
  • Every login flow that asks you to paste a credential now says it is a credential, so no surface echoes it. createApiKeyLogin was the only one setting OAuthPrompt.secret, and the eighteen prompts that ask directly (Alibaba Coding Plan, Cloudflare AI Gateway, Kagi, LiteLLM, llama.cpp, LM Studio, NVIDIA, Ollama, Ollama Cloud, Parallel, Qwen Portal, Tavily, Vercel AI Gateway, vLLM, OpenCode Zen, both Xiaomi forms) left it absent, as did the pasted authorization code AuthStorage asks paste-code providers for, which is exchangeable for tokens. The field's contract is inverted to match: ABSENT NOW MEANS MASKED, and the prompts that ask for configuration rather than a credential (Alibaba's endpoint choice and custom base URL, the GitHub Enterprise domain, a Perplexity email address and its emailed one-time code) declare secret: false and stay readable. A flow that forgets the flag is now safe rather than leaking.
  • An error is classified by its message rather than by the stack trace embedded in it. This codebase's errors carry their cause chain and their frames, so String(error) contains our own file names: a frame reading withScopedTimeoutSignal matched the timeout test, set the transient flag, and a dead credential was retried to exhaustion instead of being surfaced once. Status parsing still reads the raw string, since stripping frames cannot invent a status code.
  • A 429 that carries no retry-after now falls back to Anthropic's per-bucket rate-limit reset headers, consulted only after the generic headers because those are the provider's direct answer. Without a stated window the caller backed off exponentially under ten seconds against a limit measured in minutes, which is the immediate-repeat signature in the error telemetry.
  • An error message that carries a stack trace is now classified by the message alone. classifyText matched its keyword patterns against the whole string, so a frame naming a file like sdk-stream-timeout.ts put timeout and transient into an error that had neither, and a hard failure was retried as a transient one until the budget ran out. The status parse still reads the raw string, because a frame name cannot introduce an HTTP status token but can easily introduce a keyword. Classification therefore no longer depends on which files happen to be on the stack, which is a property no caller could have reasoned about.
  • An Anthropic 429 that omits retry-after now gets its wait from the provider's own reset clocks. getRetryAfterMsFromHeaders consulted the generic headers and returned nothing when they were absent, discarding the anthropic-ratelimit-*-reset values sitting in the same response, so the session fell back to exponential backoff and retried inside a window the provider had already told us about. The generic headers still win when present; the reset clocks are the fallback, capped and validated in @veyyon/utils.
  • Credential selection under a provider-wide quota wall now picks the account that frees up soonest instead of the round-robin head. When every account for a provider was blocked, #selectCredentialByType, the no-strategy branch of #selectApiKeyCredential and the unranked branch of #resolveOAuthSelection each returned whichever candidate rotation happened to land on, which is routinely the account just marked with the longest block, so the session waited hours behind a wall that another account would have cleared in minutes. All three now order by block availability: unblocked first, then soonest-unblocking, stable within equal rank, so rotation order is preserved wherever it still carries information.
  • Turning thinking off on a host that cannot be told to stop reasoning no longer fails the whole turn. Fireworks and its kind are served a disable request by pinning the floor tier instead, and a model that publishes no tiers has no floor, so resolveOpenAICompatPolicy threw a configuration error and the request never went out. The knob is simply omitted now and the model manages its own reasoning, which is what an operator asking to turn thinking off wants.
  • Stop a wedged local tool bridge from holding the stream idle watchdog off forever. The #4593 stand-down slid the deadline forward for as long as local work was pending, with no upper bound, so a local tool that never returned a result left the stream silent until the user cancelled the turn. The stand-down is now bounded to one continuous stretch and reports a local-tool hold by name.
  • Keep the timeout reading on an HTTP/2 error whose surrounding prose names a timeout. A named RFC 7540 code decides whether the transport fault is transient, but it says nothing about whether the fault was a timeout, and Flag.Timeout authorizes no retry on its own: it is the signal the auto-compaction candidate loop breaks on. Suppressing it alongside transience made that loop re-send a full context to the model that had just timed out instead of moving to the next candidate.
  • Cursor turns no longer count the reply twice. ConversationTokenDetails.used_tokens gauges the WHOLE conversation against max_tokens and the server samples it with this turn's completion already appended, so writing it to usage.input and then adding the streamed completion on top reported a conversation plus a copy of its last reply. On a 98k-token turn that is 38% of a 256k window spent on tokens that were never there, and both calculatePromptTokens and calculateContextTokens read the inflated number, so auto-compaction and the context footer both fired early. The prompt side is now the gauge minus the completion, which makes totalTokens equal exactly what Cursor says the conversation weighs.
  • A Cursor turn the server never finished is no longer persisted as a finished one. turn_ended is the only completion signal on the wire, but stopReason was initialised to "stop" and nothing on the success path revisited it, so a connection that dropped mid-reply after a clean HTTP/2 close was indistinguishable from a complete turn. The compaction anchor skips only aborted and error turns, so it then trusted the partial token counts of a truncated one. A stream that ends without turn_ended now fails as an incomplete stream, the same way the Google and OpenAI providers treat a missing finish reason.
  • A Cursor turn that never delivered the caller's instructions now fails instead of reporting success. The request context is the only channel a cursor-agent model honors for the system prompt and the operator's AGENTS.md files, and nothing pushes it: the server has to ask, and when the ask never arrived the composed rules were dropped in silence and the model answered from Cursor's own prompt with none of the operator's instructions. A turn that was never asked now reports what was lost and how much of it, and delivery is tracked per conversation by content, so a server that legitimately asks once still passes while an instruction file edited mid-conversation is treated as undelivered until it actually reaches the wire.
  • An abandoned Cursor turn now reports what it spent. Cost was folded once, at the end of a clean turn, so every aborted or failed turn reported zero regardless of how much it generated. 175 of 326 recorded Cursor turns were aborted, and a Cursor row backed by a bundled model reference inherits that model's real rates, so those turns were silently free in every cost total.
  • Cursor's token readings are folded in one place instead of wherever each one arrived. TokenDeltaUpdate.tokens (this turn's completion) and ConversationTokenDetails (the conversation's occupancy) are two unrelated quantities, and three defects in a row came from conflating them in thirty lines of scattered conditionals. Both are now accumulated raw on a per-turn account and turned into a usage object by one function. cacheRead and cacheWrite stay zero because Cursor reports no prompt-cache breakdown at all, not because the provider fails to read one.
  • Cursor conversation checkpoints no longer bill a whole conversation as one turn's output, and no longer throw away the context window the server reports. ConversationTokenDetails is a gauge for the conversation (used_tokens out of max_tokens), and used_tokens was being written to usage.output, which charged the turn at output rates for the entire history and overwrote the real completion count when a checkpoint arrived after the token deltas. It is the prompt side of the turn, and it is recorded there. max_tokens was discarded entirely, but for a model the bundled catalog predates it is the only true window anywhere in the system: discovery has no window field to read and substitutes a default, so a 1M-window model reporting 210k used was measured against a guessed 200k, pinning the context gauge at "0% left" and asking to compact on every turn while the provider considered the conversation a fifth full. It now travels on AssistantMessage.providerContextWindow.
  • Cursor turns now report the prompt tokens they actually used. The conversation checkpoint that carries used_tokens was folded in only when no token delta had been seen yet, but a token delta carries the COMPLETION and never a prompt count, so the two counters were unrelated and the guard protected nothing it was reached for. What it did instead was skip the fold on any turn that streamed even one output token, which is every real turn: across the recorded sessions 311 of 311 Cursor turns reported input: 0 and cacheRead: 0 while billing 2.05M output tokens, including 144 that ended cleanly. Two things broke behind that zero. Cost tracking scored every Cursor turn at $0, so a provider serving the default model contributed nothing to any spend total. More seriously the context gauge reads the prompt side, saw an empty conversation, and could not tell that a session had filled up, so auto-compaction had no signal to fire on. The repeat-assignment check that already made a second checkpoint idempotent is what makes the fold safe to run every time, so the guard and the UsageState plumbing that existed only to feed it are gone rather than corrected.
  • A VEYYON_REQ_DEBUG dump no longer writes your credentials to disk in the clear. Every request and response header was recorded verbatim, so authorization: Bearer sk-…, cookie and the provider api-key headers landed in a world-readable rr-session-*.json next to your source and stayed there. Credential-bearing header names are redacted to <redacted N chars>, which still answers the two questions a debugging session asks (was the header sent, does the value look truncated) without carrying the value; the dump files are created 0600; and the filenames are gitignored. Bodies are still recorded verbatim, so an OAuth token exchange still puts a refresh token in the file. The handbook says so.
  • A pasted OAuth callback with no state is refused. The served callback handler always required the value it issued, but the manual-paste arm accepted a code that carried no state at all, which is the branch an attacker can actually reach: a code they induce you to paste is exactly the input that arrives without one. Both arms now compare against the issued value.
  • An Ollama turn that spent its whole context window on thinking now reports the same actionable "raise Ollama num_ctx" error whichever endpoint served it. hasVisibleAssistantContent had two owners — the documented one in utils/empty-completion-retry, which deliberately does not count thinking, and a private copy in the ollama-chat provider that did — and emptyLengthFinishIsContextError is set for provider === "ollama" alone, so the same backend reached the same check down two streams and answered differently: openai-completions surfaced the error while ollama-chat returned a silent, contentless length turn the agent loop could not act on. Both providers now share the one predicate and the one message.
  • Tool "x" not found now lists the tools that do exist. The reader of that message is the model, and the failure left it two moves: guess another name, which is usually wrong twice, or abandon the task. The active tool set is the remedy and the caller already held it, so validateToolCall passes it in and the message names it, sorted and bounded because a session can expose a hundred tools and the text is re-read on every turn that holds it. The one-argument construction still works and falls back to saying that the tool is not in the active set and that a different argument will not help.
  • The Google Cloud Code Assist credential errors no longer say only Use /login to re-authenticate., at any of their three sites. /login carries no textMode in the coding agent's slash-command declarations, so it is reachable only from the TUI, and these errors surface in --print runs, in ACP clients and in tool results the model reads, with no alternative offered. Each now names veyyon auth-broker login google-gemini-cli for a terminal and qualifies the slash command with the surface it lives on, and the two parse failures read differently so a corrupt credential file is distinguishable from one missing its projectId rather than both prompting the same re-login. Missing GitLab access token. Run /login gitlab-duo or set GITLAB_TOKEN. is corrected the same way.
  • StreamTimeoutError's default message says what a timeout means and what to do. Request timed out. is the whole sentence an operator saw for a stream, idle or first-event deadline, and it did not say whether the failure was worth retrying, which is the only decision available at that point.
  • Cursor and Devin streams are no longer aborted mid-turn with "Provider stream stalled while waiting for the next event". cursor-agent and devin-agent were the only lazy providers registered with no stream limits, so they inherited the generic defaults meant for a token stream: 100s to the first event and 120s of silence thereafter. Both backends run their own agent loop remotely and emit nothing to us while that loop plans, edits and runs commands, which routinely outlasts two minutes, so the watchdog was killing healthy sessions. A stall also auto-retries, so each aborted turn burned the full budget and then started the whole turn again, which is why this presented as the provider being both very slow and constantly failing. They now run a budget sized for an agent rather than a token stream, 300s to the first event and 600s of silence. Neither is exempted outright, because devin.ts is a bare Connect frame reader with no timeout of its own and a genuinely dead socket still has to end. VEYYON_STREAM_IDLE_TIMEOUT_MS and VEYYON_STREAM_FIRST_EVENT_TIMEOUT_MS still take precedence, including 0 to disable a watchdog.
  • A failed credential disable is reported instead of discarded. deleteAuthCredential and deleteAuthCredentialsForProvider return void, so a swallowed statement failure told the caller the credential was disabled while it stayed enabled and in rotation: a key the provider had already rejected kept being retried on every request with nothing anywhere saying why.
  • A dropped usage-cost batch is reported. recordUsageCosts swallowed its failure, and an under-reported spend total is indistinguishable from cheap usage.
  • A provider in-flight wakeup that cannot be written is reported. Queued requests for that provider then wait out the full fallback interval instead of starting when the slot frees, and the usual cause (a provider directory that cannot be written) does not heal, so the stall repeated for the whole run and presented only as "this provider is mysteriously slow".
  • A refused or failed Google account email lookup is reported. The login still succeeds without an email, but the email is how the account picker names the credential, so the discarded error left an operator with unlabelled Google accounts and no way to learn why.
  • Fixed cumulative function-call argument snapshots being appended as deltas, which repeated tool arguments in live previews even when the finalized call was correct.
  • Fixed Gemini dynamic thinking remaining active when reasoning was explicitly disabled.
  • Fixed Bedrock using different extra-high reasoning budget defaults depending on the call path.
  • Fixed Codex Responses Lite stamping prompt_cache_breakpoint on the developer instruction block for any 5.6+ Codex id. The ChatGPT Codex backend answers prompt_cache_breakpoint is not supported on this model (invalid_parameter), which failed every turn on the Codex path and left no prefix cached. The field is an api.openai.com capability and the policy resolver now accepts only Model<"openai-responses">, so the Codex request path cannot ask for it.
  • Fixed prompt_cache_retention: "24h" reaching GPT-5.6+ requests. The suppression required the official endpoint as well as the modern generation, while the compat flag that enables the field is URL-keyed and provider-blind, so a 5.6+ id served from api.openai.com under any provider id other than openai shipped the deprecated retention control with no cache breakpoint beside it. Deprecation is a model-generation property, so the suppression is now keyed on the generation alone.
  • Fixed the OpenAI Responses cache-breakpoint serializer marking text with no cacheable content. A breakpoint marks the prefix ending at its own block and the platform floor for a cacheable prefix is 1024 tokens strictly, so a blank block could only spend a marker slot or draw the documented 400 for a breakpoint on a non-cacheable block.
  • OpenAI-compatible Chat Completions streams now reject EOF without an authoritative finish_reason as an incomplete provider stream before repairing or exposing a partial tool call.
  • Empty-completion retries now propagate cancellation during backoff instead of returning the discarded empty result. Azure Responses and Codex Responses use the same bounded empty-completion policy as the other provider adapters.
  • Fixed a tool-validation rejection withholding the accepted-value set whenever every enum value happened to be a substring of the error boilerplate. The suppression check that stops a set being printed twice tested raw containment of each value anywhere in the message, so the enum ['a','b'] matched must be one of the allowed enum values ('a' inside "allowed", 'b' inside "be") and the hint silently never fired. It only suppresses now when the message quotes each literal, the shape a validator that genuinely lists the set uses; an unquoted list gets a redundant hint, because a duplicate is cosmetic while a missing set leaves the model with no legal value to retry with. Plain JSON-Schema tools were hit hardest, meaning every MCP server and custom tool, since their messages never list the enum at all.
  • Fixed the issue-block length cap dropping the accepted-value line first. Issues are emitted in schema-property order and the cap keeps the head of the text, so a tool with thirty required fields plus one enum spent the entire budget on generic "is required" lines and truncated away the only line naming a legal value. Closed-set lines are now ordered ahead of the rest before the cap applies, stable within each group, so the cut falls on what the model can least act on. The cap itself is unchanged.
  • HTTP/2 stream and session resets are classified by the RFC 7540 error code they name rather than by loose wording. Stream closed with error code NGHTTP2_INTERNAL_ERROR reached the session as an unclassified hard failure for every code except the one whose name happens to contain the phrase "internal error", so a refused, reset or gracefully-drained stream lost the turn instead of engaging the retry and model-fallback chain. INTERNAL_ERROR, PROTOCOL_ERROR, REFUSED_STREAM, ENHANCE_YOUR_CALM, CONNECT_ERROR, STREAM_CLOSED, NO_ERROR and SETTINGS_TIMEOUT are now transient; CANCEL, FLOW_CONTROL_ERROR, FRAME_SIZE_ERROR, COMPRESSION_ERROR, INADEQUATE_SECURITY and HTTP_1_1_REQUIRED stay hard, and a named code beats the generic text heuristics so a cancel our own side asked for is never retried. The split is shared with the transport layer through http2RetryVerdict in @veyyon/utils/fetch-retry, so both layers agree. A replay-unsafe turn stays hard: transport transience says the next attempt could differ, not that repeating a turn whose tool call may already have run is safe.
  • A prompt cache that stops growing is now reported, and the check reaches the provider where that happens. The only regression signal compared this turn's cache read against the previous turn's, so an entry the provider serves unchanged while the prompt keeps growing could not differ from itself and every one of those turns was judged healthy: one recorded Codex session read exactly 38,656 tokens for thirty-five consecutive turns while its prompt went from 46,980 to 106,343, re-billing the whole remainder each time. The verdict fires only when the read is byte-identical to the previous turn's, the prompt grew, and the uncached remainder has overtaken the cached prefix, which is 0.36% of recorded turns rather than the 29% that merely repeat a read. It reports and never fails a request, because a provider routing to a shard that lacks the newer entry looks the same from here and is not something a session should die over. The Codex Responses provider is now tracked at all: the cache module had one production importer, so on the provider carrying the loss the enforcement setting resolved and then governed nothing.
  • Stream forwarders no longer hang when an upstream stream ends without a terminal event. EventStream.end() without a done/error push is legal (the lazy-stream forwarder produces one for a non-EventStream source), but wrapInbandToolStream, streamGitLabDuo and streamOpenAIAnthropicShim stopped at the end of their forwarding loop without ending their own output, so every consumer parked on a stream that would never close and only a caller-side abort released the turn. Each now settles its output from the upstream result, or fails loudly when the upstream ended with no result at all.

Removed

  • The eight dialect-private in-band scanner classes (GeminiInbandScanner, GemmaInbandScanner, GLMInbandScanner, HarmonyInbandScanner, HermesInbandScanner, PiNativeInbandScanner, Qwen3InbandScanner, XmlInbandScanner) are no longer exported. Each was constructed only by its own dialect definition's createScanner, and nothing in the repo — source, tests, or docs — referenced any of them. The dialect barrel already excluded the per-dialect modules, so the supported entry point is unchanged: createInbandScanner(dialect, options) from @veyyon/ai/dialect. AnthropicInbandScanner, DeepSeekInbandScanner and ThinkingInbandScanner stay exported — they have real cross-module consumers.
  • AUTH_BROKER_API_PREFIX is gone. Nothing read it, and what it documented was not what the broker does: it called /v1 the "default bearer-protected route prefix", while the server answers /v1/healthz unauthenticated and then requires a bearer for every other path, whatever its prefix. Restoring prefix-scoped gating from that constant would have opened any route outside /v1, so a dead constant describing a security model the code does not implement is worth less than nothing.

@veyyon/catalog

Changed

  • Comment prose that credited or dated a chat message is gone from the openai-compat context-window table; the credit named who reported a defect and never what the code must do. Comments only, so nothing behaves differently.
  • No user-facing change: two comments in model-thinking.ts say the same thing without an em dash, which is the punctuation this repository's prose uses.
  • No user-facing change: src/models.ts dropped an import it no longer uses, which was failing the repository's lint gate for every package beside it.
  • An effort ladder that no endpoint validates is no longer withheld along with the ones it does. Reading models.dev's budget_tokens token RANGE as a declared [high, max] level pair created a fabricated surface that then outranked every real declaration, so a row in budget mode collapsed to two rungs and an operator asking for low was served high on Anthropic, Bedrock, and Gemini alike. That mapping is gone. Three transports send no effort NAME to an endpoint and therefore keep a ladder when nothing is declared: budget carries a token count Veyyon computes from its own schedule, google-level carries the thinkingLevel enum Google publishes per family and no catalogue covers Cloud Code Assist, and MiniMax on the Anthropic endpoint collapses every tier to the single literal adaptive. Anthropic rows whose declaration is missing keep the budget dial and drop only the unverified output_config.effort, since sending an effort a model rejects is #3497's HTTP 400. The model cache schema is bumped so a row written before ladders came from the endpoint cannot be served: the ladder travels with the spec, so a cached row still offering minimal on Fireworks MiniMax is issue #2315 verbatim and cannot be repaired in place. The thinking transports and the OpenAI-compatible disable dialects are now values rather than type-only unions, so the sets can be enumerated at run time and a new member fails the suite until somebody records what an undeclared model on it should get.
  • Effort ladders, context windows, and provider listings now come from models.dev declarations only; identity no longer fabricates an effort ladder. resolveModelThinking returns no surface when neither the spec nor a models.dev declaration provides one, the picker stays closed for those models instead of offering tiers the endpoint never accepted, and every provider with a models.dev descriptor — now including Fireworks, Baseten, Novita, Vercel AI Gateway, Wafer Serverless, Sakana, and Kimi Code — has its declared reasoning_options mapped verbatim. First-party twins inherit the declared surface of their catalog sibling: openai-codex from openai, xai-oauth from xai, opencode from opencode-zen, and the kimi-for-coding aliases from the K3 row they route to. Budget-only declarations open the fixed high/max pair, matching opencode's budgetVariants contract. Ollama keeps its host-declared low..max wire vocabulary (models.dev cannot catalog a local daemon), and stale ollama cache rows normalize back to it. models.dev is also a runtime overlay now: one process-memoized, disk-cached, ETag-conditional api.json fetch enriches every descriptor-covered provider field-wise instead of wholesale-replacing static rows, with silent stale-on-failure (the bundled catalog remains the baseline, same contract as opencode's ignored refresh).
  • Added shared GPT-5.6 prompt-cache-breakpoint capability classification for OpenAI Responses transports.
  • Added a canonical reasoning selection contract that resolves supported effort, wire effort, mandatory-thinking floors, and effort-tier model routing from one model capability.
  • The bundled Cursor protobuf binding declares ConversationTokenDetails.detailed, the field 3 the schema Cursor's client ships leaves out and protobuf therefore dropped without a trace. It is the provider's own per-bucket breakdown of used_tokens, recovered from recorded client bytes and pinned by a sum identity, and @veyyon/ai's Cursor provider now reads it. The binding is regenerated with the toolchain the rest of the generated files already use, which is why every optional message field in it now spells its type as T | undefined.
  • Usage carries what a retried attempt spent, and the three operations on cost.total each have one owner. Usage.discarded accumulates the tokens and the price of attempts whose text a provider retry threw away, recomputeCostTotal is the only place the total is summed (so a service-tier rescale can no longer erase that spend with a hand-written four-field sum), discardAttemptUsage is the one way to carry an attempt forward, scaleUsageCost is the one way to apply a billing multiplier, and inheritUsageCarryovers is how a provider that rebuilds usage wholesale from one wire field keeps the facts that rebuild is not allowed to destroy. An attempt its provider already priced keeps that number; only an unpriced one is priced at the model that served it.

Fixed

  • A gateway model whose id carries the gateway's own name resolves to the vendor row it proxies. Cursor serves xAI's 500k grok-4.5 as cursor-grok-4.5-medium and OpenAI's 1,050,000-token gpt-5.4 as cursor-gpt-5.4, and the candidate walk stripped effort tiers, speed suffixes and dash-spelled versions but never a leading provider name, so both fell to the shared 200k assumption. The window is what the compaction trigger is derived from, so a session on a prefixed model compacted at two fifths of the context it had and then reported that a 256k threshold was larger than the model's whole window. The walk now also tries the id with a leading provider id removed, with the vocabulary read out of the bundled provider list at run time (so a new gateway needs no edit) and the longest name winning (vercel-ai-gateway-gpt-5.4 must not strip vercel). The original id is still tried first, so a model whose real name begins with a provider word is unaffected: a gateway-hosted claude-4.6-sonnet stays at the 200k Anthropic really publishes. Cursor's model cache namespace is bumped with it, because a cache written under the old rule keeps serving the 200k assumption for its full two-hour life.
  • Anthropic budget models offer their five thinking tiers again instead of two. A models.dev budget_tokens declaration is a token range and names no level, and mapping it to a fixed high/max pair (copied from another tool's picker) reached claude-sonnet-4-5 and claude-haiku-4-5 as if the endpoint had declared it: minimal, low, medium and xhigh disappeared from the picker, and an operator asking for medium silently got high, because a level a ladder does not carry clamps to one it does. Compaction summaries and handoffs ran at high for the same reason. A budget transport takes any legal integer and Veyyon owns the effort-to-budget schedule, so a row with no declared ladder now carries minimal through xhigh; max stays out because the Anthropic and Bedrock schedules give it the same 32768 tokens as xhigh, making it a selection that cannot change the request. A declared effort ladder still wins wherever one exists, so Opus 4.5 keeps the low/medium/high surface its endpoint documents.
  • An agent gateway no longer describes every model it proxies as Claude-class. Cursor, Devin and Antigravity report their limits badly or not at all, and all three fell straight to the shared 200k/64k assumption, so a gateway-hosted grok-4.5 — a 500k model — was published at 200k, and a Gemini row at a fifth of its window. The number is not cosmetic: auto-compaction, the context panel, context promotion and the context-overflow check all read it, so the agent compacted at two fifths of the window it had, and an operator with a 256k threshold was told the threshold was larger than the model's context window. Discovery now resolves a gateway model's limits in order — what the endpoint reported, then the catalog's own entry for that model (an effort-tiered id such as grok-4.5-medium resolves through its base, because a tier changes how a model thinks and not how much context it has), then the assumption, which is now reached only for a model id the catalog cannot identify at all. The output cap is deliberately not symmetric: a catalog-derived cap is still clamped to 64k because a vendor's own cap is not a promise about the proxy, while a cap the gateway itself reported is taken as given. src/discovery/gateway-limits.ts is the one owner, and nothing but it reads the raw assumption.
  • A gateway's limits no longer resolve from the gateway's own catalog row, which is where the assumption they were meant to replace is recorded. The first resolver read the whole bundled reference index, so cursor/gpt-5.1-high answered the 200k row Cursor discovery had written earlier instead of the 400k openai/gpt-5.1 row it proxies, and cursor/gpt-5.4 answered 200k for a 1,050,000 model. Rows belonging to the five gateway providers are now excluded from the evidence, along with any row carrying the assumed 200k/64k pair at zero cost, which is what a row written under the old rule looks like. Cursor also kept taking its limits from its own bundled row on both reference paths, so the fix reached only ids Cursor does not serve, and Devin's dash-spelled versions (gpt-5-4, gemini-3-1-pro) and stacked -high-fast suffixes resolved to nothing and fell to the assumption; both spellings now reach the vendor row. Cached discovery rows written under the old rule are retired by a model-cache version bump, because the startup model list reads the cache with a 24 hour TTL and the refresh path serves a stale row while backoff applies, so without the bump an upgraded user kept being told a 1M-token model holds 200k.
  • Fixed Devin discovery publishing 1 model instead of 169, which made every Devin model except the free swe-1-6-slow unreachable by name. Three causes, found against the live RPC and the native devin CLI's own traffic. The one that did the damage: normalizeDevinModels skipped any entry whose disabled bool was set, and field 4 is not a disablement flag on the current wire — in the CLI's own fully-entitled response it is true on 171 of 174 entries, grok-4-5-medium among them, every one of which the CLI lists as available, and the CLI's compiled ClientModelConfig has no disabled field at all. disabled_reason (field 33) is the signal the server populates, and the filter reads that now. Second, GetCliModelConfigs gates entitlement on ide_name: identifying as windsurf came back with "Upgrade to Pro to access this model" attached to 167 of 168 entries for an account that sees all of them through the CLI, so the identity is the CLI's own chisel and is shared with the chat provider in @veyyon/ai as that contract already required. Third, the request now sends supported_model_displays, without which the server withholds 6 entries including adaptive; asking only for the user-facing styles [3, 8] also leaves the CLI's five internal harness roles (subagent-default, swe-check and friends) server-side rather than offering them as selectable models.
  • Fixed Claude models served through OpenRouter losing Anthropic prompt caching entirely. buildOpenAICompat decided cacheControlFormat with a raw spec.id.startsWith("anthropic/") test while the same function had already computed the correct predicate as isAnthropicModel. The bundled catalog carries four alias rows spelled ~anthropic/claude-*-latest, where the leading tilde sorts them to the top of the model picker and makes them the likeliest Claude-on-OpenRouter selection, and the prefix test is false for every one of them. With no cacheControlFormat, the completions path returned before writing a breakpoint and the provider re-read the whole conversation at full input rate on every turn. Nothing failed loudly; the only symptom was the bill.
  • Fixed supportsObfuscationOptOut sending stream_options.include_obfuscation to any host an openai model was re-pointed at. The gate read isOpenAIUrl || provider === "openai", and the provider clause defeats the endpoint test it is ORed with, so Azure and arbitrary compatible proxies received a field a strict validator rejects with a 400. It is keyed on the endpoint now, through the same isOfficialOpenAIEndpoint helper the file already uses, which also keeps an unset baseUrl classified as official.
  • Removed the unused isAzureDeploymentsUrl export. It was a bare baseUrl.includes("/deployments/") one-liner with no callers, while the only site needing the check builds the Azure URL inline, so it read as the supported way to classify an Azure deployment and was not one: any host with a /deployments/ segment would have matched. Azure classification goes through the azureOpenAI host markers.
  • Ollama discovery reports why a model's /api/show lookup failed instead of silently substituting invented metadata. /api/tags names the models, but /api/show is what says whether one thinks, sees images, and how much context it really has, and every failure of it collapsed into the same undefined. Locally the substituted context window then OVERWROTE the real one, so a 32k model was advertised at 128k, prompts were packed to the larger size, and Ollama dropped the front of the context to make them fit: the agent lost its system prompt mid-session and looked like it forgot rather than like it failed. On Ollama Cloud the model kept its place in the picker with thinking and image input quietly stripped, which reads as veyyon not supporting them. /api/show gets no retry while /api/tags gets three, so one rate-limited request was enough. The reason now travels back through the same onFailure every other reader in this package already used, with the model id in the detail because the call runs once per model and "one of them lost its metadata" is not something an operator with a long ollama list can act on.
  • The local Ollama /api/tags fallback reports its own failures instead of returning a bare null. It is the last step before an empty picker, and a refused connection to a daemon that is not running, a 403 from a proxy in front of it, and an HTML error page were one silence. The HTML case was worse than silent: the unguarded response.json() threw out of the fetcher, so a captive portal became an unhandled stage blamed on this reader rather than a body failure naming the endpoint.
  • A models.dev reasoning_options effort declaration that names no level is now read as "reasons, no effort control" rather than falling through to the identity ladder. none/null is the off sentinel and default/auto names the endpoint's own choice, so a declaration made only of those states there is nothing to address — the same surface as the empty option list that already mapped this way. Two live rows were affected and both got a fabricated ladder: cerebras/zai-glm-4.7 declares ["none"] and was offered five levels, groq/qwen/qwen3.6-27b declares ["none","default"] and was offered four. Every one of them is a value the endpoint says it does not accept, so the picker, /thinking, and the saved-effort rows all advertised efforts that could only be rejected on the wire. An effort option carrying an unrecognized tier name, or no values key at all, still falls back to identity: that is a control Veyyon cannot name yet, not a control that is absent.
  • Model identity no longer depends on how a host spells the id. parseGlmModel, parseOpenAIModel, parseAnthropicModel and parseGeminiModel matched lowercase-only patterns, so every provider that serves models under their HuggingFace repo names — Baseten's zai-org/GLM-5.2, moonshotai/Kimi-K2.6, nvidia/NVIDIA-Nemotron-3-Ultra-550B-A55B — parsed as an unknown family. Nothing failed loudly: each identity-derived policy simply never applied, and the row kept whatever the inference fallback had guessed. The visible cost was a picker full of efforts the endpoint rejects. Baseten's GLM-5.2 route accepts high and max and returns a 400 for anything else, while Veyyon offered minimal, low, medium, high, xhigh — four guaranteed 400s, with max unreachable — and the rule that says high/max for GLM-5.2 was already in the tree and had only ever failed to match the id. Two shipped rows change, both GLM-5.2 (baseten, wafer-serverless), both to the accepted pair.
  • The models.dev fallback reports why it produced no models instead of failing silently. fetchModelsDev wrapped the whole fetch-and-map in a bare catch {} and its fetch took no hooks at all, so models.dev being unreachable, answering a status, or serving something that is not JSON were one silence — the same defect the dynamic discovery path was fixed for, left behind on the source that enriches every Anthropic catalog. ModelsDevFallback.fetch now takes the same DiscoveryHooks as a dynamic fetcher and the manager hands it the caller's onDiscoveryFailure, so a reader that can tell those three apart is finally heard. A throw is reported as unhandled, because a fetch that throws never reached its own hooks and the fault is on this side rather than the provider's. Specs the manager's rejection gate dropped are reported once per fetch as a payload failure, naming them: that is how this source disappears quietly, since drifted fields reject every spec, the enrichment vanishes, and the catalog just looks thin.
  • The models.dev refresh drops its timeout timer as soon as the request settles. It armed a bare abort-signal timeout for the full fifteen seconds no matter how fast the answer came back, and a timer that outlives its request is the documented trigger for a crash inside Bun's concurrent collector: the signal fires long after anyone cares, sets an abort reason, and the collector walks it during an unrelated allocation. Discovery against a fast or mocked endpoint is the case that piles them up. The timeout still covers reading the body, not just the response headers, because a stalled body is the failure this deadline exists to bound.

@veyyon/coding-agent

Breaking Changes

  • The orchestration magic keyword is orchestratez, not orchestrate. orchestrate is an ordinary English verb, and the notice it attached tells the model to drive the work as a multi-phase parallel subagent run and to override any tendency to do it inline, so "orchestrate the release", "please orchestrate this migration yourself", and even "do not orchestrate anything, just fix the one file" each changed how the turn ran. The notice does not display, so nothing on screen said why a one-file fix turned into a fan-out. A magic keyword has to be a token nobody types by accident: ultrathink is not a word and workflowz is deliberately misspelled for exactly this reason, and this one now carries the same z. The editor glow follows the trigger, so the ordinary verb no longer glows either, which is what makes the affordance visible before you send. Settings → Interaction → Magic Keywords → Orchestrate Keyword (magicKeywords.orchestrate) is unchanged and still governs it.
  • /thinking is now /effort; /thinking remains as an alias. One axis, one name: the two commands always set the same session reasoning effort, but having the concept's primary spelling differ from every surrounding surface — the settings row is "Default Effort", the status line says "effort", the schema key is defaultEffort — kept two words in circulation for one knob. Scripts, muscle memory, and ACP clients typing /thinking are unaffected; the alias is permanent. What ACP clients see changes in one place: the advertised command list names effort, with the same [level] hint.
  • argot-load-nudge ships OFF. It sits in the new experimental/ rule section, and everything in that section is off until named in ttsr.experimentalRules, so a session that used to receive the nudge no longer does until you opt in. Turn it back on in Settings → Rules → Rules → Built-in · Experimental, or by adding argot-load-nudge to ttsr.experimentalRules. The rule was already gated by the Argot master toggle, so nothing about it was reaching a project with Argot off; what changes is that a project with Argot ON now has to ask for it. A rule injects text into a live session on the model's behalf, which is the operator's context being spent, so an unproven one shipping on was the wrong default to have picked.
  • /providers opens the account manager instead of the onboarding wizard. It was an ALIAS of /setup, so the name a user reaches for to inspect their accounts opened first-run provider setup: one row per provider carrying a bare logged in tag. The credential store has always held several credentials per provider, so that screen could not answer any of the questions actually being asked of it: which of three Anthropic accounts is spending this session's tokens, whether one of them is failing, or how much of each account's quota is left. /setup is unchanged and keeps its providers subcommand, so onboarding is exactly where it was; only the name that suggested account management now leads there. There is deliberately no singular /provider.
  • Goal model budgets are now default-off and controlled only in Settings → Tasks → Modes → Model Goal Budgets. /goal budget and the model-facing token_budget argument were removed, so a model cannot raise its own allowance or turn the policy on.
  • The install script no longer builds from source, and --source (-Source on Windows) is no longer an option. It cloned this repository into ~/.veyyon/src and built there, which left a second divergent copy of the product on the machine, so a curl install now downloads a verified release binary or fails. --ref names a published release tag only; it used to imply a source install for anything else. To run an unreleased ref, or to work on Veyyon, clone the repository yourself into a directory you choose and run bun run setup in that checkout. An unsupported platform, a musl system, or a tag with no published release now says exactly that and hands over those commands instead of offering a flag that clones. Uninstall still finds a ~/.veyyon/src an older installer left behind, and still moves it aside rather than deleting it when it holds uncommitted edits or unpushed branches.
  • veyyon update guidance for a source install no longer tells you to re-run the installer with --source. A checkout you own updates with git pull && bun install, and the rollback refusal explains the fast-forward constraint without naming the removed flag.
  • Every mid-session prompt-cache invalidation is now attributed. refreshBaseSystemPrompt took an optional reason that defaulted to unspecified, and the callers that omitted it were the frequent ones: a cwd re-root, a secrets refresh, a memory clear, and memory startup. A measured session recorded four consecutive unspecified entries, each a roughly 32k-character prompt rebuild, so the record proved the prefix cache had been discarded and could not say by what. The reason is a required parameter now. The last hole was one layer down: ToolSession.refreshBaseSystemPrompt still declared no reason, so argot_load and argot_unload rebuilt the prompt from inside a tool call, with the whole conversation already behind the prefix, and recorded reason: undefined. That signature requires a reason too, and both tools name themselves. A model switch also stopped reporting itself as edit-mode-change: the edit variant is not a prompt gate and never re-resolves outside a model switch, so the label sent readers chasing a settings flip nobody performed; it now names the inputs that actually moved.
  • Tool approval is a real gate. tools.approvalMode defaulted to yolo, which opts out of ALL permission, so the ladder, the interactive prompt, the per-tool tools.approval policies and the working-directory and credential boundaries all existed and none of them ever fired. The default is auto: every tier still runs unasked, so the common loop is unchanged, but the guards are back on, and a per-tool policy, a path outside the working directory, a call carrying a real credential, or a tool's own critical call now stops and asks. An operator who wants a stricter rung says so once, in onboarding, in /settings, or for one session with /permissions. The ladder itself was reshaped to four rungs an operator can hold in their head: Ask everything (ask, every call asks, reads included), Ask commands only (ask-command, reads and edits run, anything that executes asks), Auto (auto, every tier runs with the guards still on, and the rung a fresh install starts on), and Yolo (yolo, nothing asks except blatantly destructive commands and an explicit deny). ask no longer auto-approves reads, and the legacy names keep working: always-ask is ask, and write and auto-edit are both ask-command.
  • /mcp has no project scope. --scope is gone from /mcp add, /mcp remove and /mcp smithery-search, the wizard's "Configuration Scope" step is gone, and every read and write the command makes lands in the active profile's <agentDir>/mcp.json. project used to be the DEFAULT for /mcp add, so a quick add wrote <cwd>/.veyyon/mcp.json, and server lookup also resolved <cwd>/mcp.json and <cwd>/.mcp.json. Discovery had already stopped loading all three, which left the worst possible split: /mcp add wrote a file no session would ever read, while /mcp test and /mcp reauth would still CONNECT to a server a repository declared and /mcp enable would write enabled: true back into that repository file. Typing /mcp test is not consent to reach a server you never configured. /mcp enable <name> on a server that only exists in a repository file now says it is not configured, names the file that IS read, and states that a repository's mcp.json, .mcp.json and .veyyon/mcp.json are never loaded. Servers discovered from other tools' configs are unaffected, and /mcp list still names the file each one came from.
  • The mcp.enableProjectConfig setting ("MCP Project Config", Load .mcp.json/mcp.json from project root) is removed, along with the enableProjectConfig option on loadAllMCPConfigs, discoverAndConnect and discoverAndLoadMCPTools. It filtered MCP servers whose source level was project, and no provider emits one any more, so the toggle was a switch an operator could flip that did nothing. commands.enableOpencodeProject is removed for the same reason: .opencode/commands/ is no longer scanned, so the row governed nothing. commands.enableClaudeProject ("Claude Project Commands", Load commands from .claude/commands/) is removed on the same grounds, and it was the worst of the three: the value was read on every command load, returned from the toggle reader, and then dropped, because the only caller destructures enableUser alone. A repository's .claude/commands/ is repo-authored content and is not loaded at all, so the row promised a gate over a branch that does not exist. A stale key in an existing config.yml is ignored rather than an error.
  • A bare slash command that has subcommands opens a picker instead of silently running one of them. /account ran status, /permissions ran status, /usage ran show, /session ran info, /mcp and /ssh ran help, /shake ran elide, /memory ran view, and /collab started hosting a session, in every case with the chosen verb declared alongside the others as though it were an equal. Nine commands each taught you a different rule about what its bare form meant, and the one that started hosting was the least recoverable of them. Bare invocation now lists the subcommands in a card you navigate with the arrows, click, or dismiss with escape, and choosing one runs exactly what typing it would have run. Outside a terminal, in ACP and --print mode, the same list is printed. A subcommand that takes an argument prefills the composer with /cmd sub rather than running with an empty one. The commands whose bare form is not a subcommand keep it: /yolo, /fast and /browser flip a switch, /setup opens the wizard, /goal enters goal mode, /todo shows the list, /secret opens the masked field, /compact compacts, and /plugins lists. Those nine declare bareAction: "distinct", and each has to give a written reason in the enforcement test, because the dispatcher cannot tell an honest switch from a hidden default dressed as one. The guard sits in the dispatcher rather than in each handler, so a new command cannot reintroduce this: it either opts out on purpose or it gets the picker.
  • /logout and /login open the account card, and the dedicated provider-then-account logout picker is gone. Logging out is choosing an ACCOUNT, and that picker could show a label and a bullet: an operator holding a personal Max plan and a Team seat on one email address picked from two identical-looking names, with the destructive key as the first thing offered. The card is the only surface that says what each account is (its plan, its quota, whether it is serving this session, whether it is the one you chose), so /logout lands there with x armed twice, and /logout <provider> lands on that provider. /login with no provider lands there too, because the card's sidebar already lists every provider a login is possible for and shows the accounts you have while you choose. The two refusals the card cannot state are unchanged and still name where the auth actually comes from: a provider with nothing stored says so and names the env variable or config key that is authenticating it, and a bare /logout with nothing stored anywhere says to remove it at that source. /login <provider> is untouched: it runs the sign-in immediately and now ends on the card, focused on the provider it just added.
  • The provider picker signs in and nothing else. It carried a second logout mode that filtered the list to providers holding a credential, and a second hosting mode that drew itself as a floating card; with the account card owning logout, both were unreachable, and an unreachable branch in a component this small is a mode someone will wire up again by accident. What remains is the embedded sign-in list the setup wizard shows, which is the only thing that ever mounts it. A disabled provider's stored credential is still removable, which was the one guarantee the logout mode carried on its own: the card lists every provider the credential store reports rather than every provider still enabled, and that is now pinned as a test on the card.
  • compaction.modelFallbackStrategy gains any-model, and auto (still the default) no longer reaches a provider you did not name. The old auto ended with "the largest context window you have credentials for", so a session running one provider could summarize on another provider's key, unattended, on every threshold crossing: a Cursor session reported Auto-compaction failed: Summarization failed: 402 You have depleted your monthly included credits from a Hugging Face account it had never been asked to spend, and the message named no model, so nothing on screen said which account had been billed. auto now stops at the chain you configured, the same-provider compaction sibling the model's catalog row recommends, the interactive model, and the model roles you assigned. any-model is the old behavior, opt-in, for anyone who would rather compaction never fail than never touch an unnamed account. configured-only is unchanged.

Added

  • Every account row says what state its credential is in, on the one axis you can act on: valid, expiring, expired, rate limited, or refused by the provider on its last refresh. The card and the inline /account status block print the same sentence about it from one owner, each ending in its own remedy (press a on the card, /providers in text), and a row whose access token has run out with no refresh token stored wears the failure mark and needs attention instead of reading as a working account. The renewable form of the same expiry says nothing at all, because it renews itself on the next request and a warning under a working account is how a reader learns to ignore warnings.
  • r in the account card refreshes THE SELECTED ACCOUNT. The health probe is a sequential network round-trip per credential, so asking about the row under the cursor on a nine-account provider meant waiting behind eight accounts you had not asked about. From the add entry, which holds no credential, it still refreshes the provider's accounts, and the footer chip says which of the two it is about to do. Answers from earlier probes are kept for the life of the card, so refreshing one row no longer blanks the health mark and the usage bars on every row that had already reported, and neither does renaming an account.
  • The footline can report how many secrets are live in this session, as a secrets segment carried by every preset. It counts distinct VALUES the obfuscator will expand, read from that expansion authority after its own expiry sweep rather than from the vault file, so the count and the substitution can never disagree, and two placeholders sharing one value count once. It names the nearest deadline (2 secrets · 12m) only inside the final hour of a lease, because a deadline eight hours out is not a thing anyone can act on.
  • A second account for the same provider can be named as it is stored, right after the login that created it, rather than only from the account card later. The first account for a provider is not asked about, because there is nothing yet to tell it apart from. Esc leaves it unnamed and does not undo the login.
  • The one moment Account Load Balancing costs you something is the moment it says so. With the setting off, an account that hits its quota waits for its own window while its idle siblings sit there unused, and that trade is the point of the default, but it was made silently: the session simply stalled, and a setting nobody is told about is one they cannot revisit. Quota exhaustion that could have moved to an idle sibling and did not now warns once with the account that is out, when its window returns, how many other accounts are idle, and where the toggle is. Once per exhausted window, so a turn that retries into the same wall states it once, and not at all when nothing could have served (one credential, or every sibling already blocked) or when the setting is already on.
  • Settings → Appearance → Status Line → Composer Footline (statusLine.enabled), ON by default: the switch for the quiet metadata row under the composer (profile, model, account, secrets, mode, path, git, the context gauge, MCP boot health, the live draft token count). It ships on because that row is the only standing answer to which directory this window is pointed at, which branch, which model and mode are live, and how much context is left; an operator who wants a composer that carries nothing turns it off in one keystroke. It is read on every render rather than captured at startup, so toggling it lands on the next frame with the settings screen still open, and while it is off the work behind the row (git status, usage windows, the account inventory) is not done at all. Two things stay outside the switch: while the view is proxied onto an agent the row still renders the agent's name and esc to go back, because Esc changes meaning in that state and the badge is the only persistent thing that says so, and statusLine.sessionAccent keeps its settings row because it colors the editor border and the working-message accent rather than this line. The knobs that only describe the row (the preset, the thinking-level spelling) hide while it is off.
  • The footline can name the account that is spending, once a provider holds more than one. statusLine.showAccount ships OFF and Settings → Appearance → Status Line → Show Serving Account turns it on, behind that tab's Advanced fold and hidden entirely while the footline itself is off, because a knob for a row that is not on screen governs nothing. Load balancing is off by default, so exactly one of several stored accounts drains while the others sit idle, and the account identity reached the status line only as a cache key for the usage segment: ⏱ 5h 71% was rendered with no owner, three Anthropic logins, one percentage, and no way to tell whose quota was at 71 without opening /account. Turned on, the account segment reads as work (or as you@example.com, whichever the account is called), sits beside the model in every preset including minimal, and is silent for a provider that stores one credential, so a single-account setup pays no width for it. It names what is SERVING rather than what was picked, because those differ exactly when a chosen account was blocked or revoked and traffic moved, which is the moment the line has to be right; it follows the operator's own name for an account, and a rename lands on the next render rather than whenever a cache happens to expire. Counted per provider, not per store, so one login each at four providers still says nothing. Off is the default because the chip holds a permanent slot on the one line that is always on screen to answer a question only an operator who moves between accounts asks, and /account answers that on demand for everybody else. The footline sheds segments from the right as the terminal narrows, so the width it holds is width the model, the mode and the context percentage are competing for, and turning it off skips the work behind it as well: the resolver stops walking the credential inventory rather than computing a label nothing draws.
  • Settings → Providers → Accounts → Account Load Balancing (accounts.loadBalancing), OFF by default. When one account hits its quota or rate limit, load balancing lets the session continue on another account of the same provider; with it off the session waits for that account's own window instead. Off is the honest default: every account here belongs to a real subscription with a real bill and a real terms-of-service, and spreading one session's work across them is a decision only the operator can make, which is how a work account otherwise ends up serving personal traffic with no way to un-spend it. The gate covers exhaustion only. A revoked or disabled credential still fails over, because a dead credential cannot serve the request whatever the setting says, and that move now announces itself: a notice names the account that could not authenticate, the reason, and the account that took over. The notice fires from the request that actually served rather than from the rotation that predicted it, so with several healthy siblings it cannot name the wrong one. The toggle is reachable from /settings and from b on the account card.
  • The account you choose for a provider is remembered for good, and by every profile. Pressing enter on the card used to write a session pin, which meant the choice was gone on the next veyyon and invisible to a second terminal — while the credentials it was made from live in one machine-wide store that every profile reads. The choice is now stored beside them in an auth_provider_selection table, keyed by the account's IDENTITY rather than its row id, so it survives a token refresh and a logout followed by a fresh login to the same account. It is per provider on purpose: several providers serve one session at once, so choosing an Anthropic account leaves Codex and Gemini exactly where they were. Switching also drops the session's sticky routing record in the same call, so the card can never show one account while another is still serving, and a selection naming an account that is no longer stored is deliberately kept rather than forgotten, because a re-login rewrites the row under the same identity and dropping the choice in between would silently move the operator somewhere else.
  • A subagent that sends a bare acknowledgement over irc is now told to say something the peer can act on, or nothing. Waking an idle peer costs it a whole turn, so Ack, Understood, On it and Quick update: still working on it spend a turn to convey nothing, and two agents trading them is how an IRC loop sustains itself. The new irc-signal rule is scoped to the irc tool, so it never fires on an edit or a file read, and it stays silent on a message that names a file, a location, or a decision.
  • The compaction loader now names a server-side pass while it runs: "Compacting context... (openai remote compaction)" for /compact and the same suffix on the auto-compaction loader. A remote pass is one provider round trip with no local summarizer behind it, and the only on-screen difference from a local pass used to be nothing at all, so a silent minute read as a summarizer grinding through the history on the operator's dime. The label asks willCompactRemotely(session) (modes/components/compaction-summary-message.ts), which reads the same two primitives the engine's #tryServerSideCompaction gate reads — the compaction.remote setting plus a session model whose capability data resolves a server-compaction transport — so the indicator cannot disagree with the engine about which path a pass will take.
  • backgroundAfter on the bash tool, the number of seconds one call may hold the foreground before it converts to a background job. It overrides Settings → Shell → Bash → Auto-Background After in both directions, so a model that already knows a command is slow can hand the turn back early instead of waiting out the shared default, and one that needs output inline (a pipe into the next command) can buy more time for that call alone. Asking for it is the opt-in: the wall-clock timer arms even with Bash Auto-Background switched off. backgroundAfter: 0 backgrounds immediately.
  • ttsr.experimentalRules, the list of experimental rules you have turned on. It is deliberately a second list rather than a value in ttsr.disabledRules: that setting stores exceptions-to-on, so a rule that ships OFF cannot be expressed in it at all, and keeping them apart means an opt-in left behind after a rule graduates to stable grants nothing rather than silently suppressing a rule that came back. A name in both lists stays off, because the safe reading of a contradiction about injecting text into a live session is "do not". It has no settings row of its own on purpose — the rule list is where a rule is turned on or off, and a second control holding the same names would be a way for the two to disagree.
  • ctrl+r in the model picker (/model, /models, /switch, alt+p) reloads the catalog from your providers and models.dev, and the card now says so along its bottom edge. The list is whatever the model cache holds, and that cache stays fresh for two hours, so a model a provider published this morning was simply missing with nothing on screen explaining the absence; the only cure that shipped was veyyon models refresh from a shell you had to leave the session to reach. The key refetches with the one strategy that ignores a fresh cache, since the cache-respecting default would redraw the same list and read as a broken button. A press while a reload is in flight is ignored rather than queueing a second fetch, and a reload that fails puts the provider's reason on the status line instead of leaving the card saying it is still working.
  • A bundled test-scope rule, which asks for a narrower test selection when a command runs a whole suite. Most of a session's wall clock goes to testing, and the expensive variable is breadth rather than frequency: measured in this repository, one test file returns in 0.31s, two package buckets in 4.8s, and 180 files in 15.0s, before the sandbox's own per-invocation overhead. A narrow run after every small edit is cheap and is good discipline; running everything each time is what burns the session. The rule fires when bun, npm, pnpm, yarn, deno, cargo, go or pytest is invoked with flags or nothing, and goes quiet the moment a path, package or filter narrows the run, so it costs you nothing once you are already scoping. It sees breadth and not frequency, because a rule matches the argument buffer of the current tool call and carries nothing across turns, which makes "you have run this four times in a row" inexpressible and "this command names no target" exact. It is advisory and does not interrupt, and it repeats after a ten-message gap rather than on every call. A bundled rule is injected only when it matches, so a rule you never trip costs no tokens.
  • Per-session CPU limits (session.cpuLimitCores, default off; session.cpuLimitKill, default off). Every process a session spawns (bash commands plain and PTY, MCP stdio servers, custom tool/command/extension exec calls, launch background processes, eval kernels, and the shared service workers) joins a per-session budget group: a cgroup v2 cpu.max quota on Linux (direct delegated write, or a systemd-run --user transient service when only the user manager is reachable), a Job Object hard CPU rate cap on Windows. A once-per-second watcher reads group usage and refuses new commands while the group stays saturated, with an error naming the budget, the measured usage, and the fix; with session.cpuLimitKill on, a sustained breach also SIGTERMs the group, and the kill is reported as a budget action rather than a crash. On macOS, where no per-group quota exists, enforcement is policy-only (refuse, renice, optional kill) and the settings row and a startup warning say so; on a host with no working backend the limit warns at startup instead of silently doing nothing. The harness's own compute is never capped.
  • /cpu-limit, which changes one session's CPU budget without touching the saved one. session.cpuLimitCores is a per-profile setting: it is chosen once and every session that profile starts inherits it, which is right for a default and wrong for the moment the default is in the way. A build that needs the whole machine, run from a profile capped at two cores, had one answer, which was to open /settings, raise the cap for every future session too, and remember to put it back. /cpu-limit <cores> sets a different budget for this session, /cpu-limit remove (also off, none, 0) lifts the cap entirely, /cpu-limit kill on|off switches the over-budget action, and /cpu-limit reset drops the session's override so the saved value is inherited again. Every branch writes a runtime override and never the config, and reset drops the override rather than writing the saved number back, so a later change to the profile wins instead of being shadowed by a copy of what it used to say. /cpu-limit status reports the budget, whether it came from this session or the profile, and what the limiter is actually enforcing on this host, since a report built from the setting alone says "2 cores" on a machine where nothing can enforce it; the bare form lists the subcommands like every other command that has them. It is text-mode drivable, because a headless client whose profile caps CPU otherwise has commands refused with an error naming a budget it cannot reach.
  • A /providers account manager card: one row per stored CREDENTIAL rather than one per provider, grouped by provider in a sidebar. Each row carries the account's label, the identity that tells it apart from its siblings, the plan the provider reports, the origin badge that distinguishes a real login from an env var aliasing the provider, one bar per usage window, and the upstream reason verbatim under a failed credential, because invalid_grant: refresh token revoked is the only string that tells you the grant died on the provider's side rather than locally. enter uses the selected account for ITS provider, n names it, r re-probes, u opens usage, x logs out behind a second-press confirm, a adds another account for the selected provider, and esc unwinds a rename, then an armed logout, then the card. Health and usage arrive as their probes land rather than blocking the first frame, and the selection is keyed by credential id rather than row index, so a probe landing under the cursor cannot move x onto an account you did not choose.
  • Switching accounts is per provider, and /account reports what that means. Several providers serve one session at once (the main model, subagent roles, web search), so there is no single "current account" to switch, and moving between PROVIDERS stays a model choice in /models. /account (or /account status) prints one block per provider the session has actually routed to, with the account serving it and that account's remaining quota; a provider you hold credentials for but have not used is absent rather than padding the list. /account manager opens the card, /account switch <provider> opens it focused on one provider, and /account name <text>, refresh, usage, logout and add reach the same actions without it. /account is text-mode drivable, so ACP and RPC clients get the status block too.

Release notes were shortened from 480,126 characters to fit GitHub's 125,000-character body limit. Read the complete package changelogs and full commit range.