v1.0.47
@veyyon/agent-core
Added
AgentOptions.cacheEnforcement(and the matchingagent.cacheEnforcementaccessor) 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_ERRORreset) or a steering interrupt lands mid-batch, one bounded summary now names every call in the batch asran, ok,ran, failed,started, no result recorded, ornever 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 asbatchLedgeron 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
toolCallblock deleted (partial arguments are unsafe to run and an unpairedtool_usebreaks 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 newAssistantMessage.incompleteToolCallsand listed asnever 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_CHARStruncates every tool result to 2,000 characters.buildCacheAlignedCompactionContextsends 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.canUseCacheAlignedCompactionrefuses 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.serviceTierandGenerateBranchSummaryOptions.serviceTier, forwarded onto the request bygenerateSummary,generateTurnPrefixSummaryandgenerateBranchSummary. 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 itsSummaryOptionsfield 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 (__syntheticwithexecuted: false, a call the loop never dispatched, and__skippedwithentered: false, a call an interrupt cut the batch short of) andentered: truedeliberately 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 sentencerenderToolBatchLedgerwrites. 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
usercontent instead of adevelopermessage, so model-generated history cannot outrank live developer policy.
Fixed
- A message the compaction passes just rewrote now measures what it currently holds.
estimateTokenscached 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 overmessage.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
reserveTokensalone: on a 16k-window model the default reserve asked for 13107 output tokens on top of the history being summarized, soestimateCompactionRequestTokenspriced 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_useblocks sharing one id and twotool_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.thresholdlarger 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
__skippedwith the interrupt source and whether execution had been entered, matchingcreateSkippedToolResult. - A successful server-side compaction can now actually commit.
compactWithProviderreturns an emptysummaryby 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.assertValidCompactionResultrejected 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 whenpreserveDatacarries 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.
hasReusableSummarytreated only the two dead provider-native keys as unusable, so a current remote entry looked like an ordinary prior compaction andprepareCompactionadopted it with apreviousSummaryof"": 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.prepareCompactionnow 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 recoveryartifact://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, socutIndexbecameundefinedand 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.nonMessageTokensand 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
findIndexand 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_contentblob 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 ofpreserveDatawithout comparing the storedprovider/apiagainst 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.remoteEndpointpoints 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. requestRemoteCompactionrefuses a blank summary instead of returning it. The summary REPLACES the history it summarizes, so a200carrying{"summary": ""}or whitespace deleted the conversation and reported success. The local summarizer ingenerateSummaryalready refused this, and the remote branch returnsremote.summarywithout 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 intotool.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
toolCallblocks plusincompleteToolCalls, 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 lonebashinterrupted 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 intotool.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, withisErrorset. It was inventoried asran, failedunder 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 asstarted, 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,prepareCompactionreturned 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
readcounted 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 enteredtool.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_endof a call whose last argument byte had arrived. Judging completeness by that event alone told the model, of two fully writtenbashcalls, 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. TheshortSummaryfield stays onCompactionResult, 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.credentialIdsprobes only the credential rows it names, instead of every active row.checkCredentialsis 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.activeCredentialIdis now filled before a session has spent anything, andactiveIsPredictionmarks 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#providerRoundRobinIndexwithout 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 minimaxand/login aimlapinow 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 acreateApiKeyLoginflow 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-vertexandamazon-bedrockstill refuse: none of them is authenticated by a single pasted key.AuthStorage.onUsageLimitWithheld(listener)reports the move thatloadBalancingrefused, asUsageLimitWithheldEvent { provider, account, idleSiblings, retryAtMs }. With the gate off,markUsageLimitReachedrecords 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 ofonCredentialFailoverand 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.AuthStoragecarries a durable per-provider account selection:selectProviderCredential(provider, credentialId, { sessionId? }),selectedProviderCredentialId(provider)andclearProviderSelection(provider), backed by anauth_provider_selectiontable 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, defaulttrue) gates quota-driven movement between accounts of one provider. When it resolves false,markUsageLimitReachedstill records the block and returns that credential's ownretryAtMsinstead 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 orinvalid_grantcredential cannot serve the request whatever the operator asked for.discoverAuthStorageforwards 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 asCredentialFailoverEvent { 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
cacheReadsolely to price the turn, so four cache defects shipped and were each found by reading a bill: a Codex breakpoint the backend answersinvalid_parameterto, Claude-via-OpenRouter alias rows that never got a marker at all, a retention control sent to generations that reject it, and/branchre-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 throughVEYYON_CACHE_ENFORCEMENT=errororStreamOptions.cacheEnforcement, because the verdict is proven against provider usage reporting and a provider that changed what it reports would otherwise stop every session. Wired onanthropic-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_GRACEof 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#buildSideRequestContextdeliberately mirrors the main loop's system and tools prefix so a compaction, title, advisor or/btwrequest 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 atCACHE_TRACKER_MAX_KEYSand 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, anirc waitwith no timeout and a lunch break all produce that same line. It now readsafter 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.incompleteToolCallscarries the id and name of every tool call whose arguments were still streaming when the turn was cut off. Those calls have theirtoolCallblock removed, because partial arguments are unsafe to run and an unpairedtool_usebreaks 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.ConversationTokenDetailscarries 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 ofused_tokenswhose 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.loginnow 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_startwas 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 inUsage.discardedwith its price folded intocost.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 stalestopDetailsthree of them left behind), Google's two reset copies into one, and the wholesaleusage = { … }replacements that erased carried facts now go throughinheritUsageCarryovers. - 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 throughblock.arguments, which is re-parsed at most once perSTREAMING_JSON_PARSE_MIN_GROWTHbytes 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 untiltoolcall_end. The accumulated text now goes on the block throughsetStreamingPartialJsonat every delta and is cleared attoolcall_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 streamsfunction.argumentsas 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.boundProviderErrorDetailnow 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 ownstatus 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
ProviderResponseErrorwith anenvelopekind, which classifies as no kind at all, so anunavailable, aninternalor adeadline_exceededfrom 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 andunauthenticatedas 401, and which accepts either wire spelling of a code, since an HTTP/2grpc-statustrailer 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, soinvalid_argumentis 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.
ProviderResponseErrorattachesFlag.Transientfor the kinds that produced no content, andisProviderRetryableError(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 aRecordkeyed 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-lessEventStreamended the turn on the first attempt even though neither had produced any content. They are nowempty-bodyandincomplete-streamrespectively; 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 ECONNRESETandconnect ETIMEDOUT 10.0.0.1:443carry no word the transient-transport classifier matched, so a dead socket ended the turn on its first attempt whilefetch failed,terminatedandconnection 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,EPIPEandEAI_AGAINnow 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 whyhighmust 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.
createApiKeyLoginwas the only one settingOAuthPrompt.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 codeAuthStorageasks 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) declaresecret: falseand 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 readingwithScopedTimeoutSignalmatched 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-afternow 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.
classifyTextmatched its keyword patterns against the whole string, so a frame naming a file likesdk-stream-timeout.tsputtimeoutandtransientinto 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-afternow gets its wait from the provider's own reset clocks.getRetryAfterMsFromHeadersconsulted the generic headers and returned nothing when they were absent, discarding theanthropic-ratelimit-*-resetvalues 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#selectApiKeyCredentialand the unranked branch of#resolveOAuthSelectioneach 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
resolveOpenAICompatPolicythrew 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.Timeoutauthorizes 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_tokensgauges the WHOLE conversation againstmax_tokensand the server samples it with this turn's completion already appended, so writing it tousage.inputand 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 bothcalculatePromptTokensandcalculateContextTokensread the inflated number, so auto-compaction and the context footer both fired early. The prompt side is now the gauge minus the completion, which makestotalTokensequal exactly what Cursor says the conversation weighs. - A Cursor turn the server never finished is no longer persisted as a finished one.
turn_endedis the only completion signal on the wire, butstopReasonwas 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 onlyabortedanderrorturns, so it then trusted the partial token counts of a truncated one. A stream that ends withoutturn_endednow 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.mdfiles, 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) andConversationTokenDetails(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.cacheReadandcacheWritestay 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.
ConversationTokenDetailsis a gauge for the conversation (used_tokensout ofmax_tokens), andused_tokenswas being written tousage.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_tokenswas 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 onAssistantMessage.providerContextWindow. - Cursor turns now report the prompt tokens they actually used. The conversation checkpoint that carries
used_tokenswas 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 reportedinput: 0andcacheRead: 0while 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 theUsageStateplumbing that existed only to feed it are gone rather than corrected. - A
VEYYON_REQ_DEBUGdump no longer writes your credentials to disk in the clear. Every request and response header was recorded verbatim, soauthorization: Bearer sk-…,cookieand the provider api-key headers landed in a world-readablerr-session-*.jsonnext 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 created0600; 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
stateis 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.
hasVisibleAssistantContenthad two owners — the documented one inutils/empty-completion-retry, which deliberately does not count thinking, and a private copy in theollama-chatprovider that did — andemptyLengthFinishIsContextErroris set forprovider === "ollama"alone, so the same backend reached the same check down two streams and answered differently:openai-completionssurfaced the error whileollama-chatreturned a silent, contentlesslengthturn the agent loop could not act on. Both providers now share the one predicate and the one message. Tool "x" not foundnow 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, sovalidateToolCallpasses 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./logincarries notextModein the coding agent's slash-command declarations, so it is reachable only from the TUI, and these errors surface in--printruns, in ACP clients and in tool results the model reads, with no alternative offered. Each now namesveyyon auth-broker login google-gemini-clifor 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 itsprojectIdrather 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-agentanddevin-agentwere 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, becausedevin.tsis a bare Connect frame reader with no timeout of its own and a genuinely dead socket still has to end.VEYYON_STREAM_IDLE_TIMEOUT_MSandVEYYON_STREAM_FIRST_EVENT_TIMEOUT_MSstill take precedence, including0to disable a watchdog. - A failed credential disable is reported instead of discarded.
deleteAuthCredentialanddeleteAuthCredentialsForProviderreturnvoid, 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.
recordUsageCostsswallowed 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_breakpointon the developer instruction block for any 5.6+ Codex id. The ChatGPT Codex backend answersprompt_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 anapi.openai.comcapability and the policy resolver now accepts onlyModel<"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 fromapi.openai.comunder any provider id other thanopenaishipped 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_reasonas 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']matchedmust 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_ERRORreached 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_ERRORandSETTINGS_TIMEOUTare now transient;CANCEL,FLOW_CONTROL_ERROR,FRAME_SIZE_ERROR,COMPRESSION_ERROR,INADEQUATE_SECURITYandHTTP_1_1_REQUIREDstay 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 throughhttp2RetryVerdictin@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 adone/errorpush is legal (the lazy-stream forwarder produces one for a non-EventStream source), butwrapInbandToolStream,streamGitLabDuoandstreamOpenAIAnthropicShimstopped 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'screateScanner, and nothing in the repo — source, tests, or docs — referenced any of them. Thedialectbarrel already excluded the per-dialect modules, so the supported entry point is unchanged:createInbandScanner(dialect, options)from@veyyon/ai/dialect.AnthropicInbandScanner,DeepSeekInbandScannerandThinkingInbandScannerstay exported — they have real cross-module consumers. AUTH_BROKER_API_PREFIXis gone. Nothing read it, and what it documented was not what the broker does: it called/v1the "default bearer-protected route prefix", while the server answers/v1/healthzunauthenticated 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-compatcontext-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.tssay the same thing without an em dash, which is the punctuation this repository's prose uses. - No user-facing change:
src/models.tsdropped 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_tokenstoken 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 forlowwas servedhighon 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:budgetcarries a token count Veyyon computes from its own schedule,google-levelcarries thethinkingLevelenum Google publishes per family and no catalogue covers Cloud Code Assist, and MiniMax on the Anthropic endpoint collapses every tier to the single literaladaptive. Anthropic rows whose declaration is missing keep the budget dial and drop only the unverifiedoutput_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 offeringminimalon 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.
resolveModelThinkingreturns 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 declaredreasoning_optionsmapped verbatim. First-party twins inherit the declared surface of their catalog sibling:openai-codexfromopenai,xai-oauthfromxai,opencodefromopencode-zen, and thekimi-for-codingaliases 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 ofused_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 asT | undefined. Usagecarries what a retried attempt spent, and the three operations oncost.totaleach have one owner.Usage.discardedaccumulates the tokens and the price of attempts whose text a provider retry threw away,recomputeCostTotalis 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),discardAttemptUsageis the one way to carry an attempt forward,scaleUsageCostis the one way to apply a billing multiplier, andinheritUsageCarryoversis how a provider that rebuildsusagewholesale 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.5ascursor-grok-4.5-mediumand OpenAI's 1,050,000-tokengpt-5.4ascursor-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.4must not stripvercel). The original id is still tried first, so a model whose real name begins with a provider word is unaffected: a gateway-hostedclaude-4.6-sonnetstays 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_tokensdeclaration is a token range and names no level, and mapping it to a fixedhigh/maxpair (copied from another tool's picker) reachedclaude-sonnet-4-5andclaude-haiku-4-5as 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;maxstays out because the Anthropic and Bedrock schedules give it the same 32768 tokens asxhigh, 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 asgrok-4.5-mediumresolves 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.tsis 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-highanswered the 200k row Cursor discovery had written earlier instead of the 400kopenai/gpt-5.1row it proxies, andcursor/gpt-5.4answered 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-fastsuffixes 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-slowunreachable by name. Three causes, found against the live RPC and the nativedevinCLI's own traffic. The one that did the damage:normalizeDevinModelsskipped any entry whosedisabledbool 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-mediumamong them, every one of which the CLI lists as available, and the CLI's compiledClientModelConfighas nodisabledfield at all.disabled_reason(field 33) is the signal the server populates, and the filter reads that now. Second,GetCliModelConfigsgates entitlement onide_name: identifying aswindsurfcame 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 ownchiseland is shared with the chat provider in@veyyon/aias that contract already required. Third, the request now sendssupported_model_displays, without which the server withholds 6 entries includingadaptive; asking only for the user-facing styles[3, 8]also leaves the CLI's five internal harness roles (subagent-default,swe-checkand friends) server-side rather than offering them as selectable models. - Fixed Claude models served through OpenRouter losing Anthropic prompt caching entirely.
buildOpenAICompatdecidedcacheControlFormatwith a rawspec.id.startsWith("anthropic/")test while the same function had already computed the correct predicate asisAnthropicModel. 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 nocacheControlFormat, 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
supportsObfuscationOptOutsendingstream_options.include_obfuscationto any host anopenaimodel was re-pointed at. The gate readisOpenAIUrl || 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 sameisOfficialOpenAIEndpointhelper the file already uses, which also keeps an unsetbaseUrlclassified as official. - Removed the unused
isAzureDeploymentsUrlexport. It was a barebaseUrl.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 theazureOpenAIhost markers. - Ollama discovery reports why a model's
/api/showlookup failed instead of silently substituting invented metadata./api/tagsnames the models, but/api/showis what says whether one thinks, sees images, and how much context it really has, and every failure of it collapsed into the sameundefined. 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/showgets no retry while/api/tagsgets three, so one rate-limited request was enough. The reason now travels back through the sameonFailureevery 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 longollama listcan act on. - The local Ollama
/api/tagsfallback reports its own failures instead of returning a barenull. 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 unguardedresponse.json()threw out of the fetcher, so a captive portal became anunhandledstage blamed on this reader rather than abodyfailure naming the endpoint. - A models.dev
reasoning_optionseffort declaration that names no level is now read as "reasons, no effort control" rather than falling through to the identity ladder.none/nullis the off sentinel anddefault/autonames 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.7declares["none"]and was offered five levels,groq/qwen/qwen3.6-27bdeclares["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. Aneffortoption carrying an unrecognized tier name, or novalueskey 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,parseAnthropicModelandparseGeminiModelmatched lowercase-only patterns, so every provider that serves models under their HuggingFace repo names — Baseten'szai-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 acceptshighandmaxand returns a 400 for anything else, while Veyyon offeredminimal,low,medium,high,xhigh— four guaranteed 400s, withmaxunreachable — and the rule that sayshigh/maxfor 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.
fetchModelsDevwrapped the whole fetch-and-map in a barecatch {}and itsfetchtook 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.fetchnow takes the sameDiscoveryHooksas a dynamic fetcher and the manager hands it the caller'sonDiscoveryFailure, so a reader that can tell those three apart is finally heard. A throw is reported asunhandled, 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 apayloadfailure, 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, notorchestrate.orchestrateis 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:ultrathinkis not a word andworkflowzis deliberately misspelled for exactly this reason, and this one now carries the samez. 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. /thinkingis now/effort;/thinkingremains 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 isdefaultEffort— kept two words in circulation for one knob. Scripts, muscle memory, and ACP clients typing/thinkingare unaffected; the alias is permanent. What ACP clients see changes in one place: the advertised command list nameseffort, with the same[level]hint.argot-load-nudgeships OFF. It sits in the newexperimental/rule section, and everything in that section is off until named inttsr.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 addingargot-load-nudgetottsr.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./providersopens 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 barelogged intag. 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./setupis unchanged and keeps itsproviderssubcommand, 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 budgetand the model-facingtoken_budgetargument 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(-Sourceon Windows) is no longer an option. It cloned this repository into~/.veyyon/srcand 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.--refnames 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 runbun run setupin 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/srcan older installer left behind, and still moves it aside rather than deleting it when it holds uncommitted edits or unpushed branches. veyyon updateguidance for a source install no longer tells you to re-run the installer with--source. A checkout you own updates withgit 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.
refreshBaseSystemPrompttook an optional reason that defaulted tounspecified, 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 consecutiveunspecifiedentries, 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.refreshBaseSystemPromptstill declared no reason, soargot_loadandargot_unloadrebuilt the prompt from inside a tool call, with the whole conversation already behind the prefix, and recordedreason: undefined. That signature requires a reason too, and both tools name themselves. A model switch also stopped reporting itself asedit-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.approvalModedefaulted toyolo, which opts out of ALL permission, so the ladder, the interactive prompt, the per-tooltools.approvalpolicies and the working-directory and credential boundaries all existed and none of them ever fired. The default isauto: 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 explicitdeny).askno longer auto-approves reads, and the legacy names keep working:always-askisask, andwriteandauto-editare bothask-command. /mcphas no project scope.--scopeis gone from/mcp add,/mcp removeand/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.projectused to be the DEFAULT for/mcp add, so a quick add wrote<cwd>/.veyyon/mcp.json, and server lookup also resolved<cwd>/mcp.jsonand<cwd>/.mcp.json. Discovery had already stopped loading all three, which left the worst possible split:/mcp addwrote a file no session would ever read, while/mcp testand/mcp reauthwould still CONNECT to a server a repository declared and/mcp enablewould writeenabled: trueback into that repository file. Typing/mcp testis 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'smcp.json,.mcp.jsonand.veyyon/mcp.jsonare never loaded. Servers discovered from other tools' configs are unaffected, and/mcp liststill names the file each one came from.- The
mcp.enableProjectConfigsetting ("MCP Project Config", Load .mcp.json/mcp.json from project root) is removed, along with theenableProjectConfigoption onloadAllMCPConfigs,discoverAndConnectanddiscoverAndLoadMCPTools. It filtered MCP servers whose source level wasproject, and no provider emits one any more, so the toggle was a switch an operator could flip that did nothing.commands.enableOpencodeProjectis 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 destructuresenableUseralone. 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 existingconfig.ymlis ignored rather than an error. - A bare slash command that has subcommands opens a picker instead of silently running one of them.
/accountranstatus,/permissionsranstatus,/usageranshow,/sessionraninfo,/mcpand/sshranhelp,/shakeranelide,/memoryranview, and/collabstarted 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--printmode, the same list is printed. A subcommand that takes an argument prefills the composer with/cmd subrather than running with an empty one. The commands whose bare form is not a subcommand keep it:/yolo,/fastand/browserflip a switch,/setupopens the wizard,/goalenters goal mode,/todoshows the list,/secretopens the masked field,/compactcompacts, and/pluginslists. Those nine declarebareAction: "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. /logoutand/loginopen 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/logoutlands there withxarmed twice, and/logout <provider>lands on that provider./loginwith 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/logoutwith 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
logoutmode 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.modelFallbackStrategygainsany-model, andauto(still the default) no longer reaches a provider you did not name. The oldautoended 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 reportedAuto-compaction failed: Summarization failed: 402 You have depleted your monthly included creditsfrom 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.autonow 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-modelis the old behavior, opt-in, for anyone who would rather compaction never fail than never touch an unnamed account.configured-onlyis 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 statusblock print the same sentence about it from one owner, each ending in its own remedy (press aon the card,/providersin text), and a row whose access token has run out with no refresh token stored wears the failure mark andneeds attentioninstead 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. rin 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
secretssegment 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 andesc to go back, because Esc changes meaning in that state and the badge is the only persistent thing that says so, andstatusLine.sessionAccentkeeps 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.showAccountships 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, theaccountsegment readsas work(oras you@example.com, whichever the account is called), sits beside the model in every preset includingminimal, 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/accountanswers 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/settingsand frombon the account card. - The account you choose for a provider is remembered for good, and by every profile. Pressing
enteron the card used to write a session pin, which meant the choice was gone on the nextveyyonand 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 anauth_provider_selectiontable, 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
ircis now told to say something the peer can act on, or nothing. Waking an idle peer costs it a whole turn, soAck,Understood,On itandQuick update: still working on itspend a turn to convey nothing, and two agents trading them is how an IRC loop sustains itself. The newirc-signalrule is scoped to theirctool, 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
/compactand 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 askswillCompactRemotely(session)(modes/components/compaction-summary-message.ts), which reads the same two primitives the engine's#tryServerSideCompactiongate reads — thecompaction.remotesetting 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. backgroundAfteron 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: 0backgrounds immediately.ttsr.experimentalRules, the list of experimental rules you have turned on. It is deliberately a second list rather than a value inttsr.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+rin 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 wasveyyon models refreshfrom 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-scoperule, 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 whenbun,npm,pnpm,yarn,deno,cargo,goorpytestis 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/extensionexeccalls,launchbackground processes, eval kernels, and the shared service workers) joins a per-session budget group: a cgroup v2cpu.maxquota on Linux (direct delegated write, or asystemd-run --usertransient 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; withsession.cpuLimitKillon, 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.cpuLimitCoresis 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(alsooff,none,0) lifts the cap entirely,/cpu-limit kill on|offswitches the over-budget action, and/cpu-limit resetdrops the session's override so the saved value is inherited again. Every branch writes a runtime override and never the config, andresetdrops 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 statusreports 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
/providersaccount 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, becauseinvalid_grant: refresh token revokedis the only string that tells you the grant died on the provider's side rather than locally.enteruses the selected account for ITS provider,nnames it,rre-probes,uopens usage,xlogs out behind a second-press confirm,aadds another account for the selected provider, andescunwinds 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 movexonto an account you did not choose. - Switching accounts is per provider, and
/accountreports 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 manageropens the card,/account switch <provider>opens it focused on one provider, and/account name <text>,refresh,usage,logoutandaddreach the same actions without it./accountis 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.