v1.2.0
@veyyon/agent-core
Breaking Changes
- The minimum supported Bun runtime is now 1.4.0.
Fixed
- The remote-summarizer cap comment names the profile-scoped log directory it writes to (
~/.veyyon/profiles/<name>/logs) instead of the pre-migration~/.veyyon/logs. Comment only; the cap and the write path are unchanged. - A cancelled turn carries
Flag.Abortwhatever reason it was aborted with, instead of only when its text matched the genericRequest was abortedsentinel.
@veyyon/ai
Breaking Changes
- The minimum supported Bun runtime is now 1.4.0.
- One OAuth classifier, one name.
isDefinitiveOAuthFailurewas a wrapper that returnedisOAuthExpiry(msg)and nothing else, kept because callers had learned the second name, and@veyyon/ai/auth-storagere-exported it a third way. Two names for one predicate is how two behaviours appear later, so the wrapper is gone: the classifier lives in@veyyon/ai/error/flagsasisDefinitiveOAuthFailure, beside the patterns it tests. An embedder importingisOAuthExpiry, or importingisDefinitiveOAuthFailurefrom@veyyon/ai/auth-storageor@veyyon/ai/error/auth-classify, imports it from@veyyon/ai/error/flagsinstead;@veyyon/aiitself is unchanged.error/auth-classifykeepsisAuthRetryableError, which is a rule of its own. @veyyon/ai/utils/retryno longer re-exportsisCopilotTransientModelError. It was a compatibility line for importers that predated the classifier moving into the error module; that classifier is at@veyyon/ai/error/flagsand is imported from there.callWithCopilotModelRetrystill lives inutils/retry.Flag.OAuthExpiryis removed. Nothing ever set it, sois(id, Flag.OAuthExpiry)answered false for every dead grant there has ever been, and a bit that always reads false is worse than no bit. Whether a refresh failure is definitive isisDefinitiveOAuthFailure, which is a boolean because it decides whether to disable a credential and its two answers are not symmetric.- One home per error class.
AuthBrokerErrorandAuthBrokerStreamUnsupportedErrorwere declared in botherror/classes.tsandauth-broker/client.ts, andCodexWebSocketTransportError,CodexWhitespaceToolCallLoopErrorandCodexProviderStreamErrorin botherror/classes.tsandproviders/openai-codex-responses.ts. Two classes wearing one name makeinstanceofanswer false for an error that carries the name:RemoteAuthCredentialStoretested the stream-unsupported sentinel against a class its own thrower never constructed, andclassify()comparederror.nameto a string literal, which any object passes by assigning a field. The classes live inerror/classes.tsand the modules that throw them import from there, so classification keys off identity. An embedder importingAuthBrokerStreamUnsupportedErrororAuthBrokerErrorfrom@veyyon/ai/auth-broker, orCodexWebSocketTransportErrorfrom@veyyon/ai, imports it from@veyyon/ai/errorinstead.CodexProviderStreamErrortakes its retryability as a required named option (new CodexProviderStreamError(message, { retryable, code })) rather than a positional boolean, so a construction site cannot leave the answer to a default. - The Anthropic retry wrapper is
isAnthropicStreamRetryable. It was a second exportedisProviderRetryableError, wrapping the one inerror/retryable.tsand adding Copilot'smodel_not_supportedcheck, so a caller's retry decision depended on whether it imported@veyyon/aior@veyyon/ai/errorand neither call site said which rule it got. One production call site used the wrapper; every other caller already used the classification directly. An embedder importingisProviderRetryableErrorfrom@veyyon/ai/providers/anthropicimportsisAnthropicStreamRetryablefrom there, or the unwrapped predicate from@veyyon/ai/error. isUsageLimitOutcomeandisUsageLimitStatusare removed from@veyyon/ai/error/rate-limitand from the@veyyon/airoot. They were the quota decision tree written a second time, outside the classifier, and six call sites wroteisUsageLimit(error) || isUsageLimitOutcome(status, message)because each half missed a case the other caught. The question has one accessor,isUsageLimit(error)from@veyyon/ai/error/flags, which now also answers a failure that arrived as a bare status with no body. An embedder callingisUsageLimitOutcome(status, message)callsisUsageLimit({ status, message })instead; the parts the quota family reads —matchesUsageLimitText,isOpaqueStatusBody,parseRateLimitReason— are unchanged.isRetryableStreamEnvelopeErroris removed from@veyyon/ai/error; the out-of-order envelope wording is now a transport rule in the registry, andisStreamEnvelopeErrorstill answers the identity question a provider asks about a stream it was reading.
Changed
- The provider retry ladder asks the registry what the transport stage does about a failure instead of reading
Flag.Transientand five message patterns, so a fast-mode entitlement wall, a malformed tool call and a stream that ended without saying why are no longer re-sent against the same credential. - The service-tier decision reads the catalog's per-provider wire capability instead of naming providers in four functions, and the vocabulary and its readers are re-exported from
@veyyon/aiand@veyyon/ai/typesunchanged. - The Codex usage reader takes
toBooleanfrom@veyyon/catalog/utils, where its sibling Copilot reader already took it, instead of declaring a third copy of the same one-line typeof check. - The stream-corruption vocabulary — a corrupted TLS record, a peer-reported HTTP/2 stream error, the upstream code
1302, a body that stopped mid-JSON, an envelope whose events arrived out of order — is a transport rule in the registry rather than a prose block only the provider ladder read, so the turn sees the same fault the ladder does. - A framing violation carries
Flag.TransportRefused, so every reader of the retry decision refuses it and a wrapper sentence that classifies as a deadline no longer gets it re-sent. - The OAuth callback-path and dialect wire-tag ownership gates read declarations instead of source text. Both proved "declared here and nowhere else" by matching the owner's formatted line, which stayed green for a duplicate spelled any other way; they now ask the declaration census in
@veyyon/utils/source-declarationsand the parsed import lists, so a re-copied constant is red however it is written. - The leaked-markup healer reads one vocabulary.
getStreamMarkupHealingPatternno longer restates the Kimi and DeepSeek provider lists and asks the catalog'sleakedToolCallGrammar, keeping"thinking"as the floor because every pattern runs the reasoning scanner.StreamMarkupHealingPatternis the catalog's union rather than a second spelling of it, and the streaming engine's official-OpenAI check is the catalog'sisOfficialOpenAIEndpointrather than a third copy of the hostname test. - A strict-tools rejection has one vocabulary, and so does a transient sentence. The grammar family read
invalid_request_errorplus a grammar or schema complaint; the OpenAI paths read a wire-format code, astrictvalue the endpoint would not mix and a tool schema it would not take, at 400 or 422; and each recognised rejections the other let through. The words are the registry's now,matchesStrictToolsRejectionTextcovers both shapes,matchesCompiledGrammarTooLargeTextnames the narrow case a caller answers by dropping the capability for the session, and the grammar rule accepts 422 because the endpoints that answer with it reject the same request for the same reason. Codex's retryable-event predicate had its own copy of the transient wording, drifted from the shared pattern by two phrasings (temporarily,processing your request); those moved intoTRANSIENT_TRANSPORT_PATTERN, where every provider reads them, and the event's code set stays Codex's own. - Every provider in this package fetches through
fetchProviderWithRetry, so the retry verdict is the registry's for the whole package.@veyyon/utilsowns the loop — the attempt bound, the backoff, theRetry-Afterhint, the HTTP/2 verdict — and cannot own the decision, because a status means something only next to a body. Its gate admitted the transient set and each caller was free to narrow it, so Bedrock, Codex, Gemini CLI and the shared OpenAI stream path passed no gate at all and silently took the loop's reading: a bare 429 was re-sent against the same spent credential while the credential layer, looking at the identical failure, rotated away from it. A provider that needs the loop without the verdict still passes its ownshouldRetryResponse. - One verdict decides whether a failed response is sent again. Four ladders had written that decision out by hand with four transient vocabularies: the Anthropic client read the status set plus 409 and never looked at the body,
ollama.tsread 5xx and one body pattern, the Claude usage poll read the set minus 429, andfetchWithRetry's gate stood in front of all three with a fourth copy.error/response.tsis the one home: it classifies what the response says through the same registry the thrown-error path uses, then asksrecover(id, "transport"). What stays per provider is aResponseRetryPolicy— a status its own API documents as retryable (Anthropic's 409), a status it refuses on principle (the usage poll's 429), a body it knows a replay reproduces (llama.cpp's deterministic tool-call parse failure) — and nothing about what a status means. The body is part of the decision and is read through the same bounded, redacted read as the error message, so a 429 that saysoverloadedis still a throttle and one with nothing to read is a wall the credential layer answers.isTransientStatusno longer restates the 408/429/5xx set and delegates to@veyyon/utils'isRetryableStatus, its owner. - The quota question has one owner, and the answer is on the id. A failure that carried a status and no wording at all reached no classification rule — the rule set runs per cause-chain link only when a link has something to read — so
{ status: 429 }came back as the raw number,Flag.UsageLimitwas absent, and six call sites ORed in a second predicate over(status, message)to cover it.classifynow asks the same rules about a bare status, and only when no link carried wording: a 429 with no body is a wall, a 429 that saysToo many requestsis a throttle, and reading the status without checking for a body first collapses the two and burns a sibling credential on every throttle. Because the flag is on the id,recover,retriableandisagree with the accessor instead of each answering separately — a bare 429 was a wall to the rotation layer and a throttle to the provider ladder, and the two now say the same thing: hand it to the credential stage, which rotates. Six copies of the OR are gone:stream.ts'sisRetryableUpstreamError,auth-retry.ts'sisDirectCredentialRotationError, two inauth-storage.ts, one in the auth gateway, anderror/auth-classify.ts. Rotation on a hard401still belongs toisAuthRetryableError, now its only reader, and a bare403still does not rotate. A bare 500, 502, 503 or 504 still states nothing through its status and stays an unclassified number. - One predicate decides whether a provider failure is retried.
@veyyon/utils/fetch-retrykept a second classifier,isRetryableError, with its own transient vocabulary and its own validation veto, andisProviderRetryableErrorconsulted it as a last resort — so one provider sentence was matched by two rule sets that had drifted apart by a phrase.unable to connectwas in the utils list and not in the classifier's, so a host that could not be reached at all was retried while carrying no flag, and the session layer reads flags. The transient vocabulary is now stated once in the error registry's network family, the two answers the utils predicate produced on its own (a bare transient status with no message to read, and an abort) are rules inisProviderRetryableError, andcallWithCopilotModelRetryasks the same predicate as every other ladder instead of a second one. - The Copilot routing flap is one rule with two readers.
isCopilotTransientModelErrorreadcodeand the SDK's nestederror.code; the classifier read the same code in the body text. A 400 whose message wasxand whose code saidmodel_not_supportedwas retried by the Copilot ladder while carrying no flag, so every flag reader saw an unclassified 400.Signal.codecarries the provider's own error code into the rules, the flap is declared once in the network family, and the accessor the ladder calls reads it. - One registry decides what happens when a provider fails. Classification lived in one file and the decision lived at each of thirteen retry loops, every one of which re-derived from prose whether the thing it caught was worth another attempt; the halves disagreed, and each disagreement was fixed at one call site.
error/domains/now holds one file per recovery axis —network.ts(transport, refusal, timeout),account.ts(quota, auth),request.ts(overflow, grammar, fast mode, provider HTTP),turn.ts(tool call, stream, thinking loop, content, interrupt) — each declaring the rules that recognise its families and what the three stages do about them (a socket, the credential presented with it, the whole turn). The axis is the grouping rather than one file per family because every consumer of the error module pays for each module it reaches, and twelve leaves broke six reach ceilings while the same content in four did not.error/registry.tsassembles them in one array whose order IS the precedence, so a failure that is both a spent quota and a throttle has one answer and that answer is written down.error/flag.tsholds the vocabulary;error/flags.tsis the walk over the cause chain and the accessors. The turn-retriable set is derived from the families that say they retry, replacing a hand-kept bitmask that sat twenty lines below the flag table — the shape where a flag is added and the mask is not. Classification is unchanged failure for failure, andAIError.recover(id, stage)is new. - Provider failures are classified by one rule set instead of a chain of about thirty regexes. Each rule states the flags it sets, the structural condition it reads (HTTP status, api, HTTP/2 error code) and, separately, the text condition — and every rule's condition is complete on its own, so the precedence that used to be encoded as position in an if-chain is now written down. A rule that decides on the provider's wording alone is now visible as such and the set of them is pinned by a test, since that is the shape that reclassifies itself when a provider rewords a sentence. Classification is unchanged failure for failure, and Anthropic fast mode is now two rules — the 400 that rejects the
speedparameter and the 429 that names the missing entitlement — so a 400 body cannot be read with the 429's rule or the reverse. - A failure diagnostic names every kind. The label list was hand-kept beside the flag table and had stopped three flags short, so a strict-tool rejection, a fast-mode wall and a dead grant each rendered as
classified:0x10000000in logs and retry banners — the three failures whose recovery is least obvious were the three with no name. Labels are derived from the flag's own name, so a flag cannot exist without one. - The two leaked-markup modules state which layer they are.
stream-markup-healing.tsis the scanner and the sole owner of which pattern a model needs;leaked-thinking-stream.tsis the stream-wrapping layer above it for reasoning only. Both headers previously described the same job in different words, andharmony-leak.ts— a different question, gated toopenai-codex— read as a third copy of it. - One owner for the usage report wire shape, and a rejection recognised without loading the validator. The report schemas were declared at module scope in
usage.tsand a second time inauth-broker/wire-schemas.ts, so a launch paid to build validators for a request most sessions never make and the broker could drift from the reader it answers. They live in@veyyon/ai/usage/report-wirebehind one memoized accessor, and the broker composes that owner's schema rather than restating it.isArkErrorsin@veyyon/ai/utils/schemaanswers "did validation reject this" structurally, beside the existingisArkSchema, so a caller no longer imports a schema library to reachinstanceof. The Codex failure-event payloads are read by declared field readers, which removed three branches no response could take.
Fixed
- A Codex request no longer carries the Responses Lite marker without the
reasoning.context: "all_turns"the lite transport requires, which the backend refused withX-OpenAI-Internal-Codex-Responses-Lite requires reasoning.context to be all_turnsfor every turn of a session on a lite-marked model whose id states no wire generation. - One suite's provider stub can no longer answer another suite's request. The twelve test-only provider overrides were twelve module-level variables, twelve setters and twelve
if (override)branches, so nothing could answer "is any override installed" and nothing could clear them.bun testruns a bucket in one process, so a suite that installed one and never restored it replaced that provider for every file after it, and the failure landed on the innocent file: a Bedrock deadline test terminated in 3ms and reported that it never named a deadline, because it was talking to another suite's stub instead of a credential process. The overrides are one map keyed by api, readable throughproviderModuleOverrideSnapshot(), and a preloaded tripwire snapshots that map before each test and fails the test that ends with an override it did not inherit, putting the inherited value back first so one leak costs one failure instead of a cascade. It reports what a test added or replaced rather than whatever is installed, becausepackages/simulationsreplaces all twelve apis at module scope on purpose and holds them for the life of the process. Three suites were leaking: two Bedrock and one Cursor. - A cancellation or a content verdict is no longer retried by the provider ladder:
isProviderRetryableErrorreads the registry's retry veto ahead of every message-text rule, and a cancellation is recognised by its error name (AbortError,ToolAbortError,RequestAbortError) rather than by the wordabortedin a sentence. - A named HTTP/2 refusal is no longer outvoted by the sentence a provider wrapped around it.
NGHTTP2_CANCEL: operation timed outcame back retryable through the word "timed out", and a wrapper saying "connection error, please retry" over a refused code marked the whole failure transient, so a stream the peer had refused was re-sent until the attempt budget ran out. The code is now its own flag,Flag.TransportRefused, set from the HTTP/2 verdict rather than from wording and owned by arefusalfamily ordered ahead oftransport; the retry veto reads the flag before any prose rule. The flag sits beside the others rather than clearing them, so the wording still describes the failure — clearing the transient bit instead made a credential handshake that cancelled its own stream on a deadline stop saying it had timed out. - Ollama no longer drops the last bytes of an answer when a connection ends mid-marker. The chat path builds its healer behind a branch that could not be taken (
getStreamMarkupHealingPatternanswers"thinking"as its floor and never abstains), so four downstream guards were unreachable and the healing path had no test. A stream that ends without adonechunk while the scanner is holding a partial marker now keeps those bytes:answer<thireached the session asanswer. - A
VEYYON_REQ_DEBUGdump is never written into a file the recording did not create. Both dump files are opened through one helper that creates them owner-only (0600) withwx, so a name left world-readable by an earlier run or planted by another account is refused rather than reused with its permissions inherited. The unreachable overwrite path that made reuse possible is gone. - An AWS credential cache reset now drops the resolution still in flight, not only the cached value.
clearAwsCredentialCache()clearedcacheand leftinflightpopulated, so the next caller received the promise the reset was meant to discard, andinvalidateAwsCredentialCache()— called on a401/403so stale credentials are re-resolved — could not reach a resolution already running with the credentials the provider had just rejected. Both seams now drop the in-flight entry,invalidateAwsCredentialCache()only for the profile and region it names, so a concurrent resolution for another key keeps its single flight. - A cancelled provider stream is no longer recorded as a provider failure. Devin's terminal record was written before the outcome was known and always at
error, so four of twenty-two recordeddevin: stream failedlines in a local corpus were the operator pressing stop — noise sitting on top of the eighteen that were real.AIError.finalizenow returnslogLevelbesidestopReason, derived from the sameabortedfact, so a record can never say "aborted" aterroror the reverse, and the Devin catch block finalizes before it writes and files a cancellation atdebug. - A framing violation from a provider stream is now terminal: it is never classified transient and never retried, whatever sentence the provider wrapped it in. Retrying reaches the same peer that would not delimit its frame.
VEYYON_REQ_DEBUG=1can no longer fill the disk or double a request's memory. The response log wrote every byte a provider sent, so a server that keeps a response flowing turned a debug flag into a local outage, and the request snapshot read a wholeRequestorBlobbody into memory beside the copy the real request was already carrying. Each capture now stops at 32 MiB (VEYYON_REQ_DEBUG_MAX_BYTES), states in the file how much it recorded and how much it omitted, and warns once naming the file. The recorded request and the response the caller receives are unchanged.- A failed provider response is read under a byte ceiling instead of whole. Every non-2xx path called
response.text(), so an enormous error page — a captive portal, a misrouted gateway, a proxy echoing the request back — was allocated in full before any cap applied, and whatever survived reached the error message, the session file and the terminal. One shared reader now bounds the read, cancels the rest, strips control bytes and escape sequences, redacts credential-shaped runs such as an echoedAuthorizationheader, and says how many bytes it did not read. Provider status codes and structured error envelopes are unchanged. - A timed-out or aborted proxy tunnel now closes at the proxy instead of staying open.
connectProxiedSocketpreviously used standard socket destruction when abandoning an in-progress CONNECT handshake, which left unread request bytes in the proxy's TCP receive buffer and prevented the peer socket stream from closing; teardown now resets the TCP socket, closing the connection at the proxy immediately on timeout, caller abort, proxy error, or unexpected closure. - A declared first-event budget now bounds the whole phase before the first event, not each request inside it.
streamFirstEventTimeoutMswas applied to one attempt everywhere and to no sequence of attempts, so three providers spent it again per attempt: Anthropic re-spent it on every rung ofPROVIDER_MAX_RETRIESplus backoff (a declared 100s became roughly seven minutes against an endpoint that accepted the connection and said nothing), Codex re-spent it throughfetchWithRetryfor six attempts plus 7.5s of waiting, and GitLab Duo ran six 30s REST setup calls in series — twice, when a cached namespace turned out stale — with nothing bounding the chain. Measured against a transport that accepts and never answers, all three overran a 500ms declared budget past 5s; the phase now ends within twice the declared number plus one backoff, and every API in the union surfaces inside a 5s bound the sweep suite asserts per API. The rule is narrow on purpose: a server that answers (a 429 or 503 carryingretry-after) keeps its own retry budget, and only a stall — no response at all — is cut off once the budget is gone. - A GitLab Duo turn no longer waits on an unbounded namespace handshake, and no longer blames configuration for a stalled network. The runtime namespace lookup runs on the turn path through the discovery reader in
@veyyon/catalog, which took noAbortSignalat all, so a silent endpoint held the turn open with itsstartevent already in the transcript; and every step of the handshake reported a stall as "this candidate produced nothing", so the turn ended with "Set GITLAB_DUO_NAMESPACE_ID to a root namespace" — a configuration remedy for a timeout. The setup phase now carries one deadline (the caller's number, or 90s, whichever is smaller), and a request that dies on it ends the phase instead of walking the remaining candidates and setup calls against a signal that is already dead. utils/first-event-budgetowns the contract:openFirstEventBudget,openStallLadderBudget(a phase budget for a retry ladder: the per-attempt deadline timesPRE_RESPONSE_STALL_ATTEMPTS),openBoundedFirstEventBudget(which can only tighten a deadline, never loosen one),isPreResponseStall, and aFirstEventBudget.fence()that hands a retry ladder or a setup chain a signal covering what remains rather than a fresh full-length timer.- A first-event stall is still retried once.
streamFirstEventTimeoutMskeeps the meaning every provider already gave it — one attempt's deadline — and now also bounds how many stalled attempts a turn pays for: two. Bounding the phase at exactly the declared number would have deleted stall retries for every caller, including the 100s default, so a single flaky connect that produces no first event would have ended the turn instead of being reopened. The second consecutive stall is a dead endpoint and ends the phase, which is what keeps a declared number from multiplying into minutes. BUILTIN_API_IDSis exported fromapi-registry, type-checked againstKnownApi, so a suite can enumerate the API union at run time instead of restating it.- The budget sweep asserts it probes every api the shipped catalog can reach, read from
models.jsonat run time. The sweep enumerated the api registry, which is the dispatcher's list; a provider is what an operator selects, and 59 of them ship in the bundled catalog. An api that arrives with a provider and is not probed leaves the sweep green while a reachable path is unmeasured, which is the shape of the hole that letgitlab-duo-agentrun an unbounded setup phase. Today the two sets agree; the gate is what makes a divergence in either direction red. - A caller's declared retry cap now reaches every provider that retries.
maxRetryDelayMssays how long a single server-directed wait may be before the refusal is surfaced instead of slept on, and it was read by exactly two paths: Gemini CLI honored it, GitLab Duo forwarded it, and everybody else used a number of their own. Codex was the visible half — it hardcoded a five-minute ceiling, so a429carryingretry-after: 120cost two minutes of silence even when the caller had declared it would tolerate none — and Bedrock, Ollama and the whole OpenAI-compatible family (openai-completions,openai-responses,azure-openai-responses,openrouterthrough the sharedpostOpenAIStreamhelper) were the quiet half, each falling back to the retry helper's own 60s cap and ignoring a caller that asked for less. Every one of them now passes the declared cap through, and keeps its own number as the default when no cap was declared. - Every provider refusal is asserted to name its remedy, for every api in the union at once.
401,404,429and400have four different answers (fix the credential, fix the route or the model id, wait, fix the request), and nothing checked that the four arrive as four: the budget sweep proves a turn ENDS and says so, and the error-detail bound owns one interpolated body on two of the fourteen paths. A path that rendered all four as "request failed" satisfied both.test/every-provider-refusal-names-what-to-do-about-it.test.tsdrives all fourteen against a refusing transport, pins the class each one surfaces, and fails by default when a fifteenth api joins the union. It also asserts what only a suite holding a credential and a rendered message together can: no refusal echoes the api key back into the message. - A
200whose body closes before the dialect's terminal marker is no longer reported as a finished turn on Bedrock or Ollama, and the rule every dialect applies to that EOF now lives in one place (utils/terminalless-eof). Both providers seededstopReason: "stop"before the first byte arrived and only overwrote it from amessageStopor adone: truechunk, so an endpoint that accepted the request, answered200and closed wrote an EMPTY assistant message into the session as an answer — no error, no retry, and a blank turn the model then reads back as history. The judgement is the same one the completions dialect already made: visible text is a stop, reasoning with no answer is alengththe session can recover, a tool batch counts only when every call parsed, and anything else is anincomplete-streamfailure. Found bytest/a-stream-that-stops-mid-turn-is-never-reported-as-a-finished-one.test.ts, which asks all fourteen apis the same question and pins each answer. - An Anthropic stream that ends before
message_startis retried inside the declared first-event budget instead of ten times past it. The failure is transient by design (a real hiccup deserves another attempt) andisPreResponseStalldid not recognise it, so the phase fence added for stalls never applied: against an endpoint answering200with an empty body, a declared 5s budget spent 49s on exponential backoff.isEmptyStreamEnvelopeErrornames the class — nothing arrived at all — and the fence ends the phase after it. A429is deliberately not in the class: the server answered, and its wait is bounded bymaxRetryDelayMsinstead. - Gemini CLI's empty-stream retry uses the URL it POSTed to, not
response.url. A customoptions.fetch— a proxy wrapper, the auth gateway, an SDK embedder — answers with a constructedResponse, whoseurlis the empty string, so the retry threwConfigurationError: Missing request URL: a configuration remedy for a transport condition, naming a URL the provider had in hand, and it replaced the truthful "Cloud Code Assist API returned an empty response" that the same path reports without a custom fetch. - A Devin auth response that is neither protobuf nor gzip names itself.
decodeDevinUserJwtResponsetried protobuf, thengunzipSync, and let the second failure escape, so a proxy error page or a truncated body reached the operator asincorrect header check— zlib's own words, naming no provider, no step and no remedy. It is now anenvelopefailure carrying the byte count and a bounded prefix of what actually arrived;enveloperather thanincomplete-streambecause a structurally wrong body cannot be improved by the three-rung auth ladder, and retrying it spent the caller's whole first-event budget and turned the message into a deadline. - Accepted clean OpenAI-compatible stream EOF after complete text or tool output and recovered reasoning-only completions as incomplete turns, preventing false missing-finish failures from DeepSeek V4 Flash and Muse Spark providers.
- No user-facing change; normalized the semantic EOF implementation to the repository formatter.
argot
Breaking Changes
- The minimum supported Bun runtime is now 1.4.0.
@veyyon/catalog
Breaking Changes
- The minimum supported Bun runtime is now 1.4.0.
Changed
compat/markup-leaks.tsowns which endpoints leak model markup into visible content. The provider list for DeepSeek's DSML envelope and the Kimi-K2 rule existed twice, byte for byte — once here as aSetand once in@veyyon/aias an or-chain — so a newly-leaking host could be added to one and not the other.isOfficialOpenAIEndpointis exported for the same reason: the streaming engine carried a third copy of theapi.openai.comhostname check.- The five provider discovery readers share one set of payload readers.
codex,gemini,cursor,openai-compatibleandantigravityeach declared schemas at module scope to answer questions of the form "is this field a string", so importing the descriptor table to list models built a validator graph for every provider before any request was made, andcodex.tsalso carried its own copies of three field readers.utils.ts— already the shared reader for cross-package callers — gainedtoFields,toStringValue,toNonEmptyString,toFiniteNumber,toArrayandtoStringArray, and the readers use those.toFieldsaccepts an array, keeping the previous behavior where a bare array envelope reads as an empty model list rather than a failed response. identityexportsstatesOpenAIWireGeneration, so a caller can tell a model below a version floor from one whose id states no version at all.provider-models/wire-capabilities.tsdeclares what each provider realizes on the wire, so a service tier's effect is read from one per-provider entry instead of the provider-name comparisons that decided it in four functions.- Strict tool schemas, a local chat-template renderer and a loopback proxy that forwards upstream are declared per provider in
provider-models/wire-capabilities.ts, socompat/openai.tsreads one entry instead of a six-provider comparison chain and two provider sets.
Fixed
- The GitLab Duo Workflow discovery reader accepts a
signaland passes it to every request of the handshake, and stops the walk when it fires. The runtime entry point runs on a turn's critical path — a namespace lookup, a project lookup, a paginated group walk and two GraphQL queries in series — with no deadline of any kind, so an endpoint that accepted the connection and answered nothing held the turn open for as long as the platform's socket timeout allowed. Worse, each step reported a stall as "this candidate produced nothing usable" and the reader concluded with "Set GITLAB_DUO_NAMESPACE_ID to a root namespace": a configuration remedy for a network fault. An abort now ends the handshake and surfaces as the deadline it was. A catalog refresh that passes no signal is unchanged, and one that passes a signal still degrades tonullrather than throwing, becausefetchGitLabDuoWorkflowModelscatches. - Anthropic model discovery reads the versioned endpoint, so a provider configured with the SDK's own base URL discovers models instead of 404ing. Anthropic's REST API lives under
/v1and its SDK takes the host without it (it appends/v1/messagesitself), so both spellings are legitimate configuration — butanthropicModelManagerOptionshanded the configured base straight to the catalog read, which appends/models. A provider pointed athttps://api.anthropic.comtherefore streamed normally and askedhttps://api.anthropic.com/modelsfor its catalog, which answered 404 fifty-five times in the recorded logs. Nothing failed loudly: the manager fell back to the bundled catalog, so the only symptom was a model list that never learned anything new, and a warn line naming a URL that looked right. Discovery now uses the versioned base while the model specs keep the base the caller configured, which is what the SDK is handed — the same splitvercelAiGatewayModelManagerOptionsalready made. - A GitLab Duo handshake stops when the server refuses the caller, instead of blaming configuration for it. The signal fix above covered a stall; a refusal took the other road to the same wrong answer.
requestGitLabJsonreported a401and a404as distinct reasons and then returned the same "this candidate produced nothing usable" for both, so a wrong or expired token walked the namespace override, the project lookup, the paginated group walk and both GraphQL queries — every one of them rejected — and concluded with "Set GITLAB_DUO_NAMESPACE_ID to a root namespace", a configuration remedy for a credential the server would not accept. A401now ends the handshake naming the token, and a429ends it naming the wait. A403and a404still move to the next candidate, because those are about the namespace rather than the caller, and the next one may well be visible. - Cursor model discovery records one reason per request. Four events end an HTTP/2 attempt — the connect timeout, a session error, a stream error, and a non-2xx status — and every one of them reported, while the promise behind them absorbed the second answer silently, so the result was single-valued and the reasons were not. One unresolvable host reported both
HTTP/2 connection failed: getaddrinfo ENOTFOUNDandHTTP/2 stream failed: The pending stream has been canceled, which lists a cancellation nobody can act on beside the fault that caused it, and doubles a per-provider failure count. The first event is the cause and is now the only one recorded.
@veyyon/coding-agent
Breaking Changes
- Veyyon source checkouts, Docker images, and published packages now require Bun 1.4.0 or newer.
- Code a repository carries no longer runs because you opened the directory. A project plugin registry (
.veyyon/plugins/installed_plugins.json) and an extension or hook file inside the working tree are withheld until the operator records a decision, per file, by sha-256. Both loaded during startup, before the approval rung, the working-directory boundary or the secret-use boundary could apply: a plugin registry names install directories that supply extensions, hooks, custom tools, slash commands and MCP servers, and an MCP server names a command to spawn and can put a${ENV_VAR}credential in an HTTP header, socdinto a clone was the whole exploit. A project that was working through either route needsveyyon trustonce. Paths outside the project root — profile extensions, installed plugins, a path you configured — are unaffected, and nothing prompts: a session that cannot ask loads nothing and reports which file and which surface it refused. - A stdio MCP server no longer inherits the environment veyyon was started with. It used to be spawned with
{ ...Bun.env, ...config.env }, so an MCP package — third-party code installed once from a registry and updated without being read — could read every provider key, cloud credential and CI token exported in that shell straight out ofenviron, without making a single tool call and with nothing in the product bounding it. A server now receives a baseline of what a program needs in order to run (PATH,HOME, temp, locale, certificate and proxy settings, and the directories version managers use to resolve a command; on Windows alsoPATHEXT,SystemRoot,ComSpecand theProgramFilesvariants, matched without regard to case) plus whateverenvsets. An ambient variable a server legitimately needs is named in the newenvPassthrough, andinheritEnv: truerestores full inheritance for one server while logging a warning on every spawn. A server that was silently relying on an ambient variable needs that variable named. - The hook event
session.compactingis nowsession_compacting. Its old spelling collided with thesessionsettings root, so a hook subscribing to it was read as configuration undersessionand dropped. A hook file naming the old event is rejected with the new name in the error rather than silently never firing. prompts/all-registries.tsno longer exportsassertEvalPromptOverridesClaimed. The refusal it performed isassertEvalPromptOverrideIdsExistinprompts/eval-overrides.ts, which reads the generated id space instead of the loaded registries.- The welcome hero no longer animates in, so
InteractiveMode.playWelcomeIntro, thesuppressWelcomeIntroinit option, theInteractiveModeInitOptionstype and the setup wizard'splayWelcomeIntrooption are gone, andgradientLogoandgradientEscapetake no shine argument.
Added
- The HD recorder resolves every binary a take will need before it records one frame: docker, bun for the scene check, and ffmpeg and python3 for the publish chain, with ImageMagick accepted under either of its two names and bun looked for at
~/.bun/bin/bunwhen a non-login ssh shell does not carry it. A publish tool first called after the recording is a take lost to a PATH difference; a rehearsal, which publishes nothing, needs only docker. bun scripts/verify-scene.ts <scene>checks a capture scene without recording it: every string the scene waits for must be produced by the prompt it submits, the product's own source, the sandbox seed, or a line the scene types, and anything else is declared in the scene with a# needle-source:line. A guard nothing produces does not fail fast — it waits out its timeout and the publish step then leaves the previous take's frame under that name. The HD recorder runs it as a preflight, refuses an endpoint that is not on the recording host, refuses a model row the server does not serve, and writes<scene>-model.txtbeside the frames naming the row, endpoint and host the take was driven by. Two guards in the hero scene were already stale: the todo board carries no count in its header, and "Status: complete" is only drawn in a goal details panel the scene never opens.veyyon trustand/trustdecide what a project may run: bare reports,approverecords the files exactly as they are now,--deny/denyremembers a refusal so the next launch does not ask again,--forget/forgetdrops the decision, and a named path decides one file — which is how a refusal that names a file the scan does not list gets answered without leaving the session. Decisions live in<agent dir>/project-trust.json, keyed by the symlink-resolved project root, so a symlink to a project is not a second identity; a store from another version, or one whose records are malformed, is discarded rather than half-believed./secret clear everywhereempties all three vaults in one command. Clearing was per vault and the only way to be sure nothing was left was to runclear global,clear profileandclear projectand read three reports, so the question people actually ask — "is any of this still stored" — had no command. The new form names every scope in one report, including the ones that held nothing, and revokes every placeholder it removed in a single notice to the model.everywhere,all,everythingandeveryall parse, and only onclear: they are refused onadd,scope,rmanddiscard, where "all of them" is not a destination.proof/zoom.pyholds a recording on one measured region and eases back out, so a row whose subject is a small block of text survives the downsample from the 2560-wide capture to the published 1920.
Changed
- The Subagents block above the composer is one row per running agent again — a mark, the agent's id, its spawn description and the model it resolved to — with the house rail as its left edge and no tree connectors. It had been rebuilt as a table of lanes with an id column, a model column against the right margin and a live activity column resolving recovery over tool over description, and the table said less than the short list it replaced: three padded columns read as a grid to scan, the activity column drew whatever text a tool call happened to carry, and a
bun -ecommand with a real newline in it put the tail of that command outside the block. Every cell folds newlines to spaces before it is bounded, because a bounded width states nothing about how many lines a string occupies, and the row never draws a task's prompt. - Light travels down the rail of the Subagents block, and only the rows whose agent is inside a tool are lit, so the sweep is a count of what is working rather than a decoration on the block.
- Every tool result block hangs its output from the same rail and draws no tree connectors. Grep, ast-grep, glob, the file list, web search, the IRC renderer and the diagnostics list each drew
├─,│and└─to nest rows that are not a hierarchy, which put a second vertical edge inside a block that already had one; nesting is two spaces of indent instead, and an IRC message body no longer carries a quote glyph of its own either. - A block's header row sits on the rail rather than at column zero, so one left edge runs from a block's title to its last row of output. The title used to start two cells left of the rail beneath it, and a status icon at the top of the spinner ramp is a full cell where the rail is an eighth of one, so the two stacked read as a chip balanced on a wire.
- The rail's travelling light advances by elapsed time instead of by a count of the repaints a block managed to get. A tool printing output holds the loop, several
setIntervalcallbacks land late or coalesce, and the highlight stalled and then jumped — the hitch was worst exactly when the terminal was busiest. Every rail also reads one monotonic clock, so two blocks on screen carry the same head instead of the board's rail crawling at a divisor of its own. - The memory backend's start finishes behind the first frame instead of in front of it: a session hands it to
AgentSession.deferStartupWork, and the first turn awaits it, so every tool call and subagent spawn still observes an installed per-session state. - A session no longer builds every prompt registry in order to validate an environment variable: the eval-override refusal reads the generated id space at
prompts/ids.generated.ts, which takes prompt assembly from 718 reachable modules to 528 and accepts an id owned by a sibling package whatever the import order was. - The launch hero is a still card. The sun used to bloom open and the wordmark reveal behind a 33 ms timer for 2.2 seconds before the screen settled, and
display.transitionsno longer governs it; overlays and the tool rail still read that setting. - The launch card is painted before the session is built rather than after it: an interactive launch reaches the first frame in 3.7ms of paint, where the plugin-root preload, extension and skill discovery, the model registry, the MCP connections and the interactive mode's own mount all used to run against a blank terminal. The mode adopts that screen and that card, whose model line and recent session fill in when the session resolves them, and the theme ground goes on with the card.
- The memory LLM has one owner.
loadMnemopiConfigbuilt a second, unsanitised remote client from themnemopi.llm*settings that every session overwrote, and the paths that load a config without resolving it (dispose, diagnostics, the stats memories) kept it -- a client with no credential resolver and no provider-text obfuscation. A config carries the request (config.llm); the client is built only inresolveMnemopiProviderOptions. Thememory_editschema is derived from the store's own operation list instead of restating it. - The advisor's usage-limit remedy and the api-key resolver ask
AIError.isUsageLimit(error)rather than re-deriving a quota verdict from a status and a message. Both had their own copy of the expression, and the resolver's copy read one predicate whilerotateSessionCredentialread another, so a bare429with no body could rotate on one side of the same failure and not the other. - The container scan behind the re-root hint reads one round of directories per depth instead of one directory at a time, so it costs the height of a tree rather than the sum of its directories. It runs during startup as part of the system prompt, where it measured 169ms of a 215ms prompt build; on this workspace with a warm cache the scan is 46.3ms before and 16.1ms after, and the whole predicate answers in 23.2ms including the
git check-ignorecall it batches. The project-marker stats are one round as well, and the repository marker's answer is reused by the container question rather than asked for twice. - A discovered config naming an environment variable that is not set now says so, naming the file, the entry field and the variable.
${VAR}expansion re-emitted the literal${VAR}for an unset variable, so an unresolved reference was indistinguishable from config text: the eight providers that expand a config (native, Claude, Claude plugins, Cursor, Gemini, OpenCode, SSH, Windsurf) passed it on and reported nothing, and an MCP server with a mistyped or unexported variable failed to start with no line anywhere naming the cause. Expansion now reports what it could not resolve to a sink the caller supplies, and the parameter is required, so a consumer states what happens to an unresolved reference rather than inheriting silence: discovery turns it into a warning, and the MCP connect guard stays the enforcement point that refuses a structural field, reading the same grammar from the expansion instead of its own copy. No value is ever quoted in the warning. - Config values (
models.ymlapiKeyandheaders, MCPenvandheaders) accept${NAME}/$NAMEfor an environment reference andliteral:<text>for verbatim text. A bare value shaped like an environment variable name (upper case, digits, underscores) is still read as a reference, and now fails closed when the variable is unset or empty; every other bare value keeps its env-then-literal behaviour, so keys such assk_live_...are unaffected. A key that is genuinely upper-case text rather than a variable name is writtenliteral:MY_KEY. - The
pi.registerProviderAPI example states the credential grammar it depends on. It showedapiKey: "GOOGLE_CLOUD_PROJECT", which now reads that environment variable and resolves to nothing when it is unset, so an extension author copying the example got a provider with no credential. - Republishing a whole session no longer holds a second copy of the transcript, and no longer reads the file back to learn nothing. A rewrite (compaction, elision, a title-change fallback, a recovered write fault) built the body by string concatenation and handed the filesystem one flattened string, then re-read the file first in case another window had appended to it: a 253MiB transcript of 118,358 entries spent 747ms, 1056MiB of peak resident memory, and 604ms of that in one unbroken stretch with the event loop stopped, 246ms and 322MiB of it on a read whose usual answer is that nothing changed. The body is now produced in chunks of about a megabyte by a factory the storage backend can ask for twice (the Windows EPERM fallback writes the same body a second time), each chunk written as it is produced, and the read-back is skipped while the file is still the same inode at the same length this session published. The same rewrite is 509ms, 44MiB, and no pause longer than 3ms;
fork()on the same transcript is 515ms and 158MiB against 558ms and 612MiB. A second writer still changes both the inode and the length, so its entries are still read back and kept. - Restoring a session reads its externalized payloads under a bound, and a session with none pays almost nothing to find that out. The load walked the whole transcript through
Promise.allover every array element and every object key, awaiting at each node: 2,000 ordinary tool entries with no externalized payload spent 17.9ms and about 27MiB of churn to discover there was nothing to read, now 1.7ms and 5MiB. A transcript with 200 externalized 512KiB payloads opened all 200 files at once and held 200 decoded buffers beside the 200 strings they decode into, peaking 122MiB above the transcript it produced; the restore now collects every reference in one synchronous walk and reads them eight at a time, which is 30.8ms against 50.7ms and a peak 100MiB above a 100MiB payload, with the event loop free to run while the reads are in flight. - A diff no longer rebuilds the two files it was handed in order to look up bracket context.
generateDiffStringsplit both sources into lines, and the boundary lookup joined each line array back into a source because it was not given one, so every diff copied both whole files a second time: 18MB of copies for one preview pass on a 9MB file, and a streamed edit preview does that pass again each time arguments arrive. The lookup now receives the text each side already holds. - A streamed edit preview no longer re-reads and re-parses its whole target on every chunk the model types. The replace mode's preview read the file again each pass, and both diff generators looked up their bracket-context rows by scanning the entire source twice per pass — once for the file on disk, once for the file the edit would produce — which the native parse cache cannot answer above 4MiB because it retains nothing that large. Streaming a one-line replacement against an 11.7MiB, 100,000-line file spent 1.9s per pass, so two seconds of 30Hz arguments landed 4 previews, read 46.8MiB, burned 7.8s of CPU, and showed a preview 2.1s behind what had been typed (3.8s at the tail), with the args-complete pass blocking the event loop for a further 1.9s. The streaming pass now reads through a cache keyed by modification time and size, the args-complete pass still reads fresh, and a source over the size the parse cache retains renders without off-window boundary rows instead of paying a whole-source scan per redraw. The same two seconds now land 49 previews, read the file once, spend 2.4s of CPU, and stay 36ms behind the stream with a worst case of 103ms; the final pass costs 36ms. A read window on a file that large stops paying the same scan.
- The MCP loader no longer imports a type it stopped using when it started forwarding discover options through one object. No behavior change; the workspace lint gate was failing on it.
- The first frame no longer waits for a hardware probe. Building the system prompt looked up the GPU name, and on a cold cache that lookup spawns
lspciornvidia-smi: 224-557ms measured on one workstation, spent before anything is drawn, for a prompt line no frame displays. The probe now runs unwaited and writes its cache for the next launch, so exactly one launch per machine omits the GPU row and every launch after it has the name from its first build. The answer a launch starts with is the answer it keeps: a probe landing mid-session does not add the row to a later build, because that row lives in the cached prompt prefix and re-anchoring the prefix costs more than the line is worth.scripts/bench-startup.tsmeasures the boot path anddocs/internal/startup-budget.mdrecords the baseline it was measured against. - A session that died on an uncaught exception is logged as an error, and a terminal that closed with nothing in flight is not logged as a warning. The
session_exitrecord carries two facts — the kind of teardown and how many tool calls it orphaned — and its severity was chosen from the first one alone, sowarncovered a crash and a closed window equally: across 19 local log files, 23 exits were recorded at warn, 17 of them asighupwith no pending work and 4 of them fatal. The level is nowerrorfor an unhandled throw or rejection,warnwhen tool calls were left without results, anddebugfor a signal, aprocess.exitor a normal dispose that lost nothing.sessionExitLogLevelinsession/exit-diagnostics.tsowns the ladder. - The anchored HUD stops animating when the mode it belongs to stops. Its motion frame was
unref()ed, which keeps a timer from holding the process open and does nothing to stop it firing, and nothing disarmed it on teardown — so a stopped mode went on stepping its rails, rendering both blocks, and reading the settings singleton for as long as the process lived. Teardown disarms it, and a torn-down mode refuses to arm a new one, which matters because every board write runs the arm site. - The working directory leaves the cached prompt prefix. The base system prompt is what the provider caches, and it stated the directory verbatim — "Today is
<date>, and the current working directory is'<path>'" — so a re-root that changed nothing else still discarded the cached prefix for the whole conversation to restate one path. Measured on this repository, moving from the root topackages/utilsaltered exactly one line of a 92,921-character prompt; across 19 local log files, 210 of 232 recorded invalidations were acwd-change, about 85,000 characters re-read each time. The date and the directory now arrive as asession-statemessage with the turn, restated only when they change, the way recalled memories already do. The rebuild on re-root is unchanged, because the rules, skills and workspace tree really are directory-derived — what changed is that a move which alters nothing but the path rebuilds to byte-identical bytes and records no invalidation, while a move to a project with different rules still does. The "not a project root" paragraph also stops naming the path, since the message already carries it. Two directory-derived facts stay in the prefix and still invalidate on purpose: the workspace tree, which is a picture of the directory and is off by default, and the active-repo-context block. - A discovery failure from software nobody started is no longer a warning. Model discovery runs against every provider veyyon knows about, three of which are local runtimes it probes on loopback without being told to, so a machine that does not run llama.cpp, LM Studio or Ollama collected a warning per refresh for each of them: 110 of 137
model discovery failed for providerrecords across 19 local log files were a refused connection to a port with nothing behind it, and the 27 records that named something actionable — an Anthropic 404, six xAI OAuth 403s, an aborted Devin request, one llama.cpp 502 — were four fifths buried. A provider with a stored credential, an explicitly configured base URL, or an endpoint that is not on this machine still warns with every field it carried before, and so does an unconfigured one whose endpoint answers at all, because something is listening and is broken. A loopback port that refuses the connection is recorded at debug. The three implicit local runtimes are one table rather than three near-identical blocks, so a fourth is exercised by the same tests the moment it lands. - Image generation is disabled by default. Enable Generate Image in the Tools settings to expose
generate_image. - The Todos board above the composer keeps the tree list it has always been — a header naming the phase the plan is on, one row per phase with its tally, and the tasks of the phase being worked nested under connectors — and gains a rail as its left edge. Every row starts on
block.rail, which is the one arrangement the house rail animation can find, so light travels down the block while the plan is being worked and the rail is flat while it is not. The stages already finished are not drawn: the header'sphase n/totalstates how far the plan has come, and a column of closed tallies made the block read as one undifferentiated chunk. - The task in flight carries one small square alternating between
▫and▪, at the row it belongs to. A cell from the density ramp fills the whole cell and a terminal cell is half as wide as it is tall, so a ramp cell at the task indent read as a rectangle switching on and off, louder than the row it marked. Every other row keeps the checkbox vocabulary and is still. - The board moves only while the agent is streaming, compacting or running post-prompt work, and stops on the frame a turn ends or an interrupt lands. A task marked in progress persists across the turn boundary, so a board keyed on task state alone moved for as long as the operator sat reading a plan nobody was working on, and the anchored clock repainted two regions to draw the same thing.
todoBoardMarkerAnimatesandtodoBoardRailTravelsare the one place that is decided, for the mark, the rail, and the clock alike. - An open plan's transcript card is one line naming the header, the done count, the phase and the task the write moved, because the anchored board already draws the list and the two surfaces were drawing it twice.
- A pending task a detached subagent is working on takes the accent and the in-flight mark, which is what states someone other than the main agent is on it.
- The
/secrethelp is grouped by the question being asked rather than by declaration order. One heading covered eleven verbs, three of which delete something — a secret, a whole vault, and a vault file that cannot be read — so choosing betweenrm,clearanddiscardmeant reading all eleven lines. There are now four groups: storing a credential, seeing what you have, changing one secret, and removing secrets. - Every documentation page a user reads lives in one tree. Twenty-nine topic files sat flat under
docs/beside a handbook that covered the same subjects, so a reader looking for the settings catalog, the RPC surface, the theme schema or the keybinding defaults found a page that was in neither the handbook's table of contents nor its search index, and a contributor adding a page had two plausible homes and no rule for choosing. The flat files were reference material and their handbook counterparts were guides, so nothing was merged away: each one moved beside the guide it belongs to (reference/settings.md,reference/rpc.md,architecture/secrets.md,context/context-files.md), every one is inSUMMARY.md, anddocs/README.mdis the map of which tree a page belongs in.docs/internal/stays internal, and the runnable extension, hook and marketplace examples moved topackages/coding-agent/examples/because they are code. - There is one way to capture a picture of the product, and the documentation describes one. Two capture paths shipped side by side — a recorder driving a real terminal on a private display, and a tape renderer with its own baseline block — and each was documented as the only authority, so a before-and-after pair could be assembled from one arm of each and still satisfy the words in the repository. The tape path is gone: its tapes, its drivers, its container install, and the
gallery --screenshotflag that fed it. A settings differential is now recorded from the same scene twice, seeded throughSCENE_SETTINGS, andscripts/there-is-only-one-capture-path.test.tsfails on a tape file, a path named after the tool, a mention of it in any tracked file, or its reappearance in the recorder image. - Every extension path the operator named on the command line, in
config.yml, or through the SDK is loaded as a named path rather than as repository code. The project-trust gate withheld any extension whose resolved path fell inside the working tree, which caught--extension ./my-ext.tsand theextensionsconfig list as well as the files a clone ships, so a path the operator supplied had to be approved throughveyyon trustbefore it would load. Named paths are now passed to the gate at every load site (the CLI, the SDK's twoloadExtensionscalls, the tool registry, and the three task entry points), and a subagent inherits the named subset explicitly instead of re-deriving it.test/security/a-path-the-operator-named-is-not-repository-code.test.tspins the distinction, and a load site that forgets to pass its named paths gates more rather than less. - The repository rules and the documentation they point at are split by what they are.
AGENTS.mdstates the rules and names the script that enforces each one; the mechanism and the history behind them moved todocs/internal/bun-surface.md(the Bun surface, its counts, the worker-hosting contract) anddocs/internal/repo-gates.md(the two workflows, why the Rust gate sits in the fast one, why the changelog gate runs on push). The handbook lost its self-referential openers and its anthropomorphic voice: a setting sets, a command prints, a check rejects.docs/handbook/src/reference/settings-reference.mdis generated, so its wording is fixed inscripts/gen-settings-reference.tsand regenerated. - A launch no longer builds a schema library it will not use. The theme reader, the config reader, the usage report and the five provider discovery readers each declared validators at module scope, so every session spent 362ms constructing them before a prompt was drawn, for files and requests most sessions never touch: importing
main.tsmeasured 1411ms and now measures ~1262ms. The theme file is checked byvalidateThemeJson, which returns the missing tokens and the problems as separate lists — the reader used to recover the missing-token list with a regular expression over a validator's prose — and derives the required-token list from theThemeColorandThemeBgtables, so a new token is required without anyone maintaining a second list.test/startup/a-launch-does-not-load-a-schema-library-it-will-not-use.test.tstimes a launch path's second import of the library and fails when a new module puts one back. colors.toolTextis gone from the bundled themes. Twenty-three theme files declared it, fourteen of them as an empty string, and nothing read it: it was not inThemeColor, so the validator never required it and no renderer ever asked for it. A custom theme that sets it is unaffected, because an unknown color token still passes.- Every tool block's call row, result rows and renderer-less output hang from the same rail at the transcript's inset column.
- An inline tool renderer's own leading indent is removed before its rows are framed, so every tool's title starts at one column.
- The receipt row and the anchored boards start on the rail at the transcript's inset column instead of one cell inside it.
- The Subagents block plays one rail sweep for the whole block instead of flashing the rail beside whichever agent last reported.
- The rail's travelling light advances at four rows a second instead of eight.
- A streaming edit or write block carries the rail light on its newest row rather than at a position taken from the clock.
- An overlay's first frame is its settled frame: a card no longer unfolds row by row and no highlight sweeps across it as it opens.
Fixed
- Memory credentials resolve through the config-value grammar.
mnemopi.llmApiKeyandmnemopi.embeddingApiKeywere handed to Mnemopi as raw setting text, so${VAR}reached the memory host as the credential and!commandsent the command text; the failure arrived as a 401 naming nothing. A named variable that is unset now sends no credential at all. - A
readthat clips a wide line no longer blocks an insertion beside it. The read tool applies a per-line column cap, and a clipped row was recorded as neither seen nor anything else, so the edit tool refused every hunk anchored there — includingINS.PRE, which leaves that row byte-identical. Adding one line above a multi-kilobyte row meant reading the whole row back with:rawfirst. The clipped rows are now recorded as clipped, which grants exactly one thing:INS.PREandINS.POSTbeside them.SWAP,DELand the.BLKforms on a clipped row are still refused, and a line the read never rendered at all is still refused for every form. - The anchored todos board's in-flight glyph stops one ink level below a full cell. It ran the status spinner's own density ramp, which peaks on
█once a cycle; in a dense status row a full cell is not the largest ink present, but on the board it is the largest ink any row draws, so at the top of the ramp the pulse read as a block appearing rather than as a cell breathing. The board's ramp is derived from the active one by dropping its top level,· : ░ ▒ ▓ ▒ ░ :, so a theme that overridesspinnerFramesis carried with no second setting to keep aligned, and a preset whose frames are not a density ramp —ascii's| / - \— is left alone. The status line keeps the full ramp. - The collab guest allowlist no longer names a command that does not exist.
themewas listed and is not a builtin, andhelpwas listed as its own entry when it is an alias of/welcome, so a guest typing/helpwas checked against a name the registry resolves elsewhere. The allowlist is checked against the builtin registry, andtest/collab/a-guest-runs-only-commands-that-exist.test.tsfails on an entry that names a missing command or an alias. - An MCP server whose config still holds an unresolved
${VAR}incommand,args,cwd,urlorenvPassthroughis no longer started. An unset variable with no default used to stay in the value as literal text, so it reached the spawn as a program name or an argument, or a URL as a hostname, and the failure that followed named the variable's text rather than the field. The connection is refused first, with the field and the variable named and no value quoted. - A project-trust decision is written through the shared atomic-write helper, so a store cannot be left half-written by a crash or a full disk. It used to write in place, which is the one file whose corruption withholds every extension, hook and MCP server a project supplies.
- The HD recorder resolves its own working directory and proves it can write there before it records. A container that could not write the output directory failed at the publish step, an hour into a take, with the frames already discarded.
WORK_DIRselects the directory and a write probe runs in the container during preflight. - A config value that names an environment variable is no longer replaced by the variable's own name when the variable is unset.
apiKey: GITHUB_TOKNor a CI secret that never reached the job used to resolve to the stringGITHUB_TOKN, which veyyon then sent as the credential and the far end answered with its opinion of a bad token, mentioning neither the variable nor the typo. The value now resolves to nothing: a provider key is not installed, an MCP connection is refused before any request is made, and a warning names the variable and the setting it belongs to without quoting any value. - Stopping an MCP stdio server now ends every process it started. A server run through a wrapper (
npx,uvx,docker run, a repository script) was left behind when the wrapper was killed, so each reload and each failed handshake leaked one server process. Teardown now signals the whole tree, escalates once if the tree ignores the polite signal, and is bounded so a reload cannot hang on it. - An MCP credential that cannot be presented refuses the connection instead of connecting without it. A revoked credential, a refresh token the auth broker holds and redacts locally, and a credential store that could not be read all ended at one log line, after which the connect went ahead with no
Authorizationheader at all — so the operator saw the server's answer to an anonymous request (an HTTP 401, a provider error page, a lockout after enough of them) and nothing about the credential or the command that fixes it. Each state now names itself and the action:/mcp reauth <name>for a rejected credential, the same through the broker for a broker-held refresh token,/mcp reconnect <name>for a store that failed. Nothing is sent, so the far side gets no failed-auth attempt to count. A refresh that fails while the access token is still valid keeps connecting, and a server with no stored credential still connects as configured. - A short-lived MCP credential that rotates is re-read instead of re-sent. A
!commandheader or env value —!op read op://vault/mcp/token,!gcloud auth print-access-token— was cached under the command text, which is byte-identical before and after the secret behind it changes, so once a value was cached the product kept sending it: the server answered 401, the transport retried with the same header, and only restarting the process helped. Worse, the auth-retry hook was installed only for servers with a stored OAuth credential, so a server authenticated purely by a command had no refresh path at all. A 401 or 403 now re-runs that server's commands and retries with what they print,/mcp reconnect <name>re-reads one server's credentials, and/mcp reloadre-reads every configured server's; a command used outside MCP config, such as a providerapiKey, keeps its cached value. An automatic reconnect after a dropped transport deliberately does not re-read, because a lost connection is no evidence about a credential and a password-manager command re-run per reconnect is an unlock prompt per reconnect. Two endpoints answering 401 at the same moment is one rotation and costs one execution of the command, and a failing command's 30-second back-off is not shortened by any of this. - A misspelled per-tool approval policy blocks the tool instead of silently unblocking it.
tools.approval.<tool>acceptedallow,denyandpromptand returned "unconfigured" for everything else, so a hand-editedbash: denyywas indistinguishable from no policy at all — and onyolo, where an unconfigured tool is allowed, the typo auto-approved the tool it was written to stop. Any value present under that key and not one of the three now denies that tool, on every rung and under the/yolosession bypass;denyrather thanprompt, because the bypass lifts a prompt. An absent key, an empty record, and a key whose value isundefinedare still unconfigured, so a clean install is unaffected, and only the named tool is affected — the rest of the record still applies. A warning at startup names the setting, the value found, the fact that the tool is denied, and the three values that work, so a fail-closed policy is not a silent one. - The context gauge stops ignoring the provider count the running turn already produced. The in-flight estimate is kept only until a step of the turn reports real usage, and which steps count as "this turn" was decided by comparing an index against the count of everything the prompt submitted. A turn submits more than the question — the session-state line carrying the date and directory, recalled memories — so that count landed past the turn's own assistant steps and every provider figure they carried was rejected: a step reporting a 9,000-token prompt was reported as the 68-token turn-start estimate, and the gauge stayed there for the rest of a long tool turn. The boundary is now where the turn begins, and what the prompt already counted is identified by the message itself rather than by how many there were, so the tail is neither skipped nor counted twice.
- A bounded range read touches the file once.
read path:50000-50019on a 3.5MiB, 100,000-line source read 3.08x the file's bytes: the line streamer scanned it for the window, bracket context read and split all of it, and the snapshot tag read and normalized all of it again. The window, the context rows and the tag now come from one materialization, which measures 1.08x. A file whose bytes are not already LF-only keeps the streamed window and shares only the text, because byte accounting counts source bytes and a CRLF line carries a byte the normalized one does not; a leading BOM is now detected from the bytes, sinceBun.file().text()drops it and the streamer displays it. Wall time is unchanged: 93% of a large range read is tree-sitter parsing the whole file for bracket context, which is a separate cost. - A session booted against another profile's agent directory now discovers that profile's MCP servers.
discoverAndLoadMCPToolscopied three of its caller's discover options by hand and droppedagentDir, so the headless and SDK path connected the process-active profile'smcp.jsoninstead of the one the caller named. The options type now extends the discover options and the loader forwards them by rest, so a field cannot go missing the same way again. - A goal set before the first turn is no longer deleted with the session file. A session whose journal held only the goal record plus a model pick counted as "draft-only metadata" and was removed on close once its draft was gone, which is the ordinary shape of a session where someone sets an objective and closes the window: the objective was the only copy and it went with the file. Whether a journal is droppable is now a question about the list rather than about one entry — a goal that is still the last mode change keeps the file, a goal the operator dropped leaves
mode_change("none")behind and is selector state again, and a plan-mode entry written byplan.defaultOnStartupis dropped exactly as before. - A resume that declines to restore a goal says so, and turning Goal Mode off no longer destroys the objective. With
goal.enabledoff, a session carrying a goal recordedmode_change("none")on the next reconcile: not a suppression but a deletion, so turning the setting back on restored nothing and the session came back with no goal and nothing on screen or in the log saying why. The record now stays on the branch, inert, and the operator is told which objective is stored and inactive. A stored record the shape check cannot read is still cleared — nothing could restore it — and that clearing is reported too. Both records name which stored goal the decision was about, so a log read after the fact identifies the objective rather than only the mode entry. - An active goal keeps driving after a provider hiccup the session recovered from. A retry resumes the killed turn with a fresh
agent_start, and goal mode reset its "did this turn call anything" evidence on every one, so a continuation that ran tools and then lost the transport came back as a retried attempt that only talked and was read as the model having nothing left to do: the goal stood down mid-work, the footline still saidGoal, and the session sat idle until someone typed a message. Tool calls a killed attempt already made now survive into the retry of the same turn,auto_retry_startbeing the only notice the mode gets that the work continues, since the dead attempt's ownagent_endis superseded by the recovery. A turn the mode does see end in error — retries spent — no longer decides anything either: it used to latch the stand-down permanently. Consecutive dead turns are tolerated up to three, then goal mode stops driving and says so, so a provider that is genuinely gone cannot spin the goal forever. - Why a turn was aborted has one name, and only an operator interrupt pauses an active goal. Every abort routes through one method whose goal-facing reason defaulted to "the operator stopped this", so a site that aborted a turn to do its own work and did not spell the option out paused the goal exactly as an Esc would; plan mode's silent approval abort was such a site. The two readings are now the exported
GoalAbortReason, both seams take that type, the pause is decided in one place rather than in a fast-path condition that could not add a case, and a third reason cannot be added without classifying it — the sweep that exercises them is keyed by the union itself. - A verification stamp on an internal doc survives a documentation path rename. The freshness gate compares the doc's last commit date against its stamp date, so folding the flat topic pages into the handbook — an edit that rewrote link targets and nothing else — turned eight verified pages into "re-verify and re-stamp", for a change no verification covers: where a sibling page lives says nothing about whether this page matches the code. A stale stamp is now cleared only when the doc's text differs from the snapshot it was stamped against once every
*.mdpath token is folded away, the snapshot being the commit that WROTE the stamp rather than the code commit the stamp names. Any prose edit still fails, a dropped path reference still fails, a stamp dated before the commit that wrote it is refused the exemption, and every page that keeps its stamp this way is printed by name. veyyon promptanswers in the format it was asked for.--jsonwas read fifth in a chain of early returns, so five of the six views the command has returned their text table before reaching it:--prompts --json,--prompt <id> --json,--tools --json,--section <id> --jsonand--statement <id> --jsonall printed padded columns and exited 0, and a consumer that asked for JSON got a parse error with nothing saying the flag had been dropped. Which view an invocation means is now decided in one place, separately from which format it is rendered in, so every view has a JSON form: the prompt list carries each id with its template path, the tool table carries the per-tool token split beside the prompt's own cost, and a refusal — an unknown prompt, section or statement id — carries its message and its non-zero exit as a parseable object. A rule this configuration leaves out reportspresent: falsewith the condition that would include it, rather than an error, because a rule that is off is not a failure.- A chosen web search provider is the only provider
web_searchuses. Picking one — throughproviders.webSearchor a per-callproviderargument — hoisted it to the front of the full chain and left the other twelve behind it, so an engine that answered with nothing handed the query to a different engine: choosing DuckDuckGo could send the query to a keyed provider the operator was deliberately not spending, and choosing a keyed provider could fall through to a credential-free one that returns bot-challenge pages. A per-call argument for an engine with no credential skipped the choice entirely and ran the wholeautochain.autois now the one value that ranges over the roster, one owner (selectSearchProviders) decides the list for both routes, and a configuration that cannot be satisfied — a provider chosen and excluded, or every provider excluded — is refused with a message namingproviders.webSearchandproviders.webSearchExcluderather than widened into a search nobody asked for. The zero-available message names the chosen provider and the credential it wants. - An eval-only prompt override reaches the model instead of only the inspection commands.
VEYYON_EVAL_PROMPTSreplaced text in the aggregate prompt registry, but a module that sends a prompt imports its row table directly, so the override announced itself and changed nothing a model was sent: withtools/bashreplaced,veyyon prompt --toolsstill reported the shipped 971-token description. A benchmark arm built on that measures its own control while the results table names it a treatment. Every row table undersrc/prompts/now applies the override where text is read, and the same command reports 4 tokens for the replaced description and 12757 rather than 13724 for the whole tool set. - A prompt override naming an id no registry holds is refused at prompt assembly, where every registry in the build is known, and reported with the nearest registered ids. The check used to live inside each registry, which cannot tell a typo from an id a sibling owns:
@veyyon/ai's registry is constructed first and holds no tool descriptions, so a validtools/bashoverride killed the process at startup.src/prompts/all-registries.tsis now the single owner of which registries a model can be sent from, andveyyon prompt --promptsreads the same list. The refusal names each unknown id on its own line and explains what an id is in the same words the bench runner uses for the same mistake, from@veyyon/utils. sanitizeStatusTextstrips DCS, SOS, PM, APC, and 8-bit ANSI escape sequences via@veyyon/utilsinstead of delegating tonode:util'sstripVTControlCharacters, preventing string sequence payloads from leaking into status lines under Bun 1.4.- A setting the system prompt depends on rebuilds that prompt whoever writes it. Two owners decided that: the settings screen asked the prompt-gate registry, and the session asked a private table of eight paths that never restated six of the registry's live gates. So writing
personality,tools.format,inlineToolDescriptors,includeModelInPrompt,tui.renderMermaidortools.intentTracingfrom anywhere but the settings screen — a slash command, an SDK or ACP host, a plugin — changed the configuration and left the model reading a prompt that described the previous one, with nothing logged; and flipping one of the five paths both lists held rebuilt the prompt twice for one change. The trigger now lives once, with the prompt, in the session's effective-setting listener, and reads the registry for which settings reach the model. The session's own table keeps only the three that gate no prompt text at all (async.enabled,subagent.isolation.mode,subagent.maxNestedSpawnDepth, which decide thetasktool's description and schema), and a failed rebuild is logged as a warning rather than at debug, because the settings screen is no longer there to report it. - Minimized shell output no longer carries the tail of an escape sequence as text. The minimizer's
strip_ansiknew CSI and OSC, and every other escape fell to the rule that drops the escape byte and keeps what follows — so a command whose progress bar parks the cursor withESC 7handed the model7…8,resetcontributed a strayc, an editor's charset select contributed(B, and a sixel image or a kitty graphic arrived as its entire payload in the middle of a line. It now reads the same four grammars the TypeScript half reads, from the same cross-language corpus, and a truncated sequence still keeps every byte after the escape so a capture cut at a buffer boundary loses nothing. - A session now subscribes to settings changes unconditionally. The subscription was an optional call on a method
Settingsalways has, left behind from a week when two suites built a session around a stand-in that lacked it: a real gap would have taken the prompt rebuild, the todo-reminder reset on disable and every live CPU-limit change with it, and said nothing. Those suites hand over a realSettings, so the only thing the optional call could still do was hide the next one. readaccepts a line selector onhistory://, sohistory://Scout:120-160pages a transcript instead of answeringUnknown agent: Scout:120-160while listing Scout among the known agents.historywas missing from the selector allowlist, so the whole<id>:<selector>string reached the protocol handler as an agent id; the error then pointed at the agent rather than at the selector, which reads as a lost transcript.history,issue,memory,prandveyyonwere also missing from the internal-URL prefix list, so those five were measured against the cwd boundary as filenames and had their backslashes rewritten as path separators. Both tables are now checked against the router itself.- A board that arrives already finished draws nothing rather than playing the exit sweep for rows it never showed. The clearing animation was armed whenever the incoming plan was complete and the previous one was empty, which is exactly the shape of a resumed session and of any single call that hands the HUD a closed plan, so the anchored region swept a strike across tasks and ran light down a rail that had never been on screen. The sweep now requires a board that was open on screen and closed in this update.
veyyon://serves a page that moved. The resolver matched the exact path and nothing else, so a reference to a page by its old location — in a rule file, a saved session, a comment, an issue — answered "not found" for a page that is still shipped, from a new directory. An exact path still wins; a bare name that exactly one page carries resolves to it; and a name several pages carry is refused with both candidates named, because guessing betweenfeatures/secrets.mdandarchitecture/secrets.mdis worse than saying which two exist.- The generated settings reference renders the pipes and placeholders its cells contain. A table cell is split on
|before inline code is parsed, so thebashInterceptor.patternsdefault — which carries(cat|head|tail)— rendered as a dozen stray columns with most of its text dropped, and a<name>placeholder in a description was read as an HTML tag and deleted, leaving~/.veyyon/personalities/.mdas the instruction for where a personality file goes. Both are escaped where the cell is written, and the handbook parity check compares what the renderer prints rather than what the source spells. veyyon acprun directly points at the log directory that exists. Its stderr notice, and the comments in the auth-broker CLI and the browser-open fallback, all named~/.veyyon/logs/, which is the pre-migration bare-root path; logs are per profile, at~/.veyyon/profiles/<name>/logs/, so a client integrator following the message found nothing there.- A misuse of the
tabAPI inside abrowsercell names the API instead of failing as aTypeErrorseveral frames deep.tab.$$(".row")answeredtab.$$ is not a functionandtab.hover(".btn")answeredtab.hover is not a function, neither of which says what the facade does have; a call that omitted its argument reached the implementation and crashed onselector.trim()ofundefined, which names a property of the argument rather than the argument. The facade is now wrapped at both call sites — the worker's tab API and the cmux run facade — so an unknown member reports the members that exist, with the closest known replacement spelled out for the two that were reached for by name ($$→tab.observe()ortab.page.$$,hover→tab.page.hover), and a missing, blank or non-string required argument reports which argument of which method is missing. Neither method was added: a new tool surface owes its own proof, and the guard points at what is already there. Language probes (then,constructor,toString, …) still answerundefined, so the object stays awaitable and inspectable. - A subagent that sends its whole
yieldresult as a JSON string gets the result recorded instead of a refusal it cannot act on.result: "{\"data\": {…}}"answeredresult must be an object containing either data or error, which names no shape to send: one recorded child re-sent the identical stringified payload five times, rewording the prose around it and adding fields that were never the problem, and its parent got nothing. 38 sessions hit that refusal. The string is now parsed the way the argument-repair pass already parses a stringified argument object —yieldis the one tool that setslenientArgValidation, so it never reached that pass — and aresultthat still is not an object is refused with a message naming what arrived (a string,an array,nothing) and both accepted shapes. That refusal is also bounded now: only the empty-result branch counted against the retry budget, so a caller that kept sending a string could retry forever while its parent waited; the fourth consecutive one aborts the child. - Neither anchored block above the composer wraps any more. Both clamped every row to one cell inside the width they were handed, and both were handed the terminal's full column count — but they are mounted in a
Textcarrying a one-cell margin on each side, and that component soft-wraps its content two cells earlier than the terminal does. Every row was therefore two cells too wide, and the tail of it landed on a line of its own at the margin, outside the block's rail: a capture of two live lanes at 131 columns shows each lane's model badge alone on the line beneath it. Both call sites now derive their budget from the same margin the mount resolves, so tight layout is followed rather than guessed at. The widths were not the bug — both blocks obeyed the bound they were given, which is why a sweep of every column count from 1 to 220 could not see it — so the regression suite drives the real interactive mode and asserts the mounted block renders exactly the rows it emitted. - A model can now create the session's initial persistent goal directly from an ordinary task prompt. When goal support is enabled, the
goaltool remains available before entry, while paused, and after completion; creating a goal persists goal mode, updates the status UI, and arms continuation without requiring the operator to run/goal setfirst. Plan and vibe modes still keep the goal tool out of their restricted tool sets. - Dropping a
todotask is no longer counted as doing it. The closing tally counted every TERMINAL status as done, soop: "drop"on one of six tasks answeredDropped: Add error handling. Next: … Overall: 2/6 done, 4 open.on one line, claiming two completions where the board held one completion and one abandonment. The arithmetic was right — closed and open are complements — but the word was not, and a model reading its own board back could not tell work it finished from work it gave up on. A tally now readsOverall: 1/6 done, 1 dropped, 4 open., says nothing about dropped work when there is none, and admits onlycompletedto the done side, so a terminal status added later lands indroppedrather than being absorbed intodone. - A finished working loader is unmounted instead of merely stopped, so its row cannot outlive the work it was announcing. Clearing the loader dropped the reference and left the component mounted in the status container, which kept drawing
▌ <task> · 0:00 [esc]— a frozen clock and an interrupt hint for something that had already finished — on every subsequent frame. A stopped-but-mounted chrome row is byte-identical frame after frame, which is exactly what the renderer treats as settled content, so once the viewport scrolled past it the row committed into the terminal's own scrollback and stayed there permanently, wedged between two finished tool cards. /secret listsays what the composer chip is counting. The chip counts what the obfuscator will substitute and the list read the vault, so a session masking ten auto-detected environment values showed10 maskedbeside "No active secrets. Nothing is being substituted right now." — both true of the same session, and no command in the product could name any of the ten. Values detected in the environment or declared insecrets.ymlnow carry the variable name or the file path as a label, the list reports them from the same counter the chip reads, and a value that arrived with no label at all is counted and reported as such rather than silently dropped from the total. A label is not a name: it makes a value findable and grants no#NAME#expansion. The empty-vault answer no longer claims nothing is being substituted while something is.- Every link in the documentation resolves. Folding twenty-nine flat topic pages into the handbook moved each one two directories deeper without rewriting the links inside it, so 76 internal links pointed at paths that no longer existed — a page about MCP configuration reaching for
../../../mcp-config.md, the advisor page citing source files three levels above the repository — and three anchors named headings the merged reference page never had. Each link is repointed at the page it means, andbun scripts/check-doc-links.tsreports none dead. - A wrapped row of a streaming diff keeps its gutter, so a line too long for the block still reads as one row of the diff.
- A streaming multi-file edit preview is bounded as one block rather than per file, so the preview stays inside the window it is budgeted for.
- The Todos board draws nothing rather than overflowing its mount when the terminal is too narrow for one task row.
- The eval tool's trailing rows — the JSON tree, the timeout line, the notice and the warning — hang from the rail with the rest of the block.
Removed
veyyon galleryno longer takes--screenshot,--out,--fontor--font-size. The flags rendered the gallery into a PNG through a second capture stack — a headless browser driving a tape recorder — that produced frames at a different terminal, font and colour configuration from the one every other proof uses, so two frames of the same surface could not be compared.veyyon galleryprints the gallery; a picture of a real screen comes from the recorder described in the handbook's verification page, and that is the only capture path.- The system prompt no longer computes a date it does not render. The date moved into the per-turn
session-statemessage when the working directory left the cached prefix, and thedateanddateTimevalues were still assembled for the prompt template on every build, where no section reads them.test/session/the-session-state-block-states-the-host-local-day.test.tsstates which surface owns the date now: the block states the host's local calendar day, west and east of UTC, and one instant in two zones produces two different days.
@veyyon/hashline
Breaking Changes
- The minimum supported Bun runtime is now 1.4.0.
Added
Snapshot.clippedLinesandSnapshotStore.recordClippedLinesrecord the lines a producer showed only a prefix of, so the patcher can tell a clipped line from one that was never rendered at all. A store implemented outside this package must add the method.editRewritesItsAnchorandcollectRewrittenAnchorLinesanswer whether an edit replaces the bytes of the lines it anchors on, besidecollectEditAnchorLines, which already owned which lines an edit set anchors against.
Changed
- Applying a patch no longer builds a per-line origin table nothing read, cutting the cost of a single edit by 36% on a 100,000-line file and 22% on a 1,000,000-line one. Output is byte-identical.
Fixed
- A pure insertion may anchor a line a producer displayed but clipped at its per-line column cap. Such a line is deliberately kept out of the seen-line set, because an edit that rewrites bytes nobody read is the mistake the set exists to stop — but the guard refused
INS.PREandINS.POSTthere too, and those leave the anchored line byte-identical and use it only as a position, which is what the content tag certifies. Adding one line beside a multi-kilobyte row therefore required reading that whole row back with:rawfirst. Every destructive form (SWAP,DEL, and the.BLKvariants) on a clipped line is still refused, and a line never rendered at all — an elided body, a folded summary row, a line outside the read range — is still refused for every form including an insertion, since without even a prefix there is nothing to identify.
@veyyon/mnemopi
Breaking Changes
- The minimum supported Bun runtime is now 1.4.0.
@veyyon/natives
Breaking Changes
- The minimum supported Bun runtime is now 1.4.0.
Changed
- A grep that matches in every file it searches no longer allocates a vector per file to decide which of that file's matches to return. The aggregator collected each file's selected matches into a temporary
Vecand then pushed the rows out of it, and it grew the row vector by doubling: on 50,000 files of 1KiB with a match in each, four workers spent 15.4ms of a 91.2ms pass inside aggregation. Selection is arithmetic on counts now — how many of this file's matches the offset skips, how many the limit still allows — applied to the file's own iterator, and the row vector is sized by one counting pass before the first row is pushed, because 50,000GrepMatchrows is about 6MiB and the doubling copied more than the rows. The same query is 83.8ms with 10.6ms in aggregation, and a query dense in matches but sparse in files (2,500 files of twenty matches, the same 50,000 matches) is unchanged at 59.9ms. The residual gap between those two shapes is per-file result-object and N-API cost, not ripgrep's per-match collection, which is what the stage split was built to tell apart: a 20x difference in result objects with an identical match count. Row selection is proved against the algorithm it replaces, swept over file shapes, offsets, limits and all three output modes. - A filtered directory scan no longer copies the entries it is about to discard. Globbing and listing ask the walker for a small slice of a large tree, and the answer was assembled by cloning every cached entry and then dropping the rejects, so a glob keeping 10 entries of 100,000 still allocated 100,000 owned paths: 101,373 allocations and 13.8MiB per cache hit, with peak resident memory rising by the same 13.8MiB. The filter now runs where the entries live — inside the cache entry, or inside the fresh scan before it is handed to the cache — and only a survivor is cloned: the same hit costs 283 allocations and 0.0MiB, and a cold fill of it drops from 306,954 allocations and 45.2MiB to 205,864 and 31.4MiB, with peak growth of 1.0MiB instead of 15.7MiB. Survivors are marked in a one-bit-per-entry mask so the result vector is allocated once at its exact size (an unknown-count
collectdoubles a buffer of 48-byte entries and cost 9.8MiB of churn, a vector of indices cost 2MiB, the mask costs 12KiB), and a filter that accepts everything still clones the slice in one shot, as cheap as before.crates/veyyon-walker/examples/walk-cache-copy.rsis the instrument, and it keeps permanent "filter after copy" arms so the difference is re-measured on every run rather than remembered. - The parallel grep's worker scaling is now measured by a committed instrument, and the shared results mutex it was suspected of queueing behind is cleared by that measurement.
bench/grep-workers.tsruns one child process perVEYYON_WALK_WORKERSvalue (the walker reads that variable once per process, so a worker count cannot change inside a run), on disk and on tmpfs, and checks every worker count returns byte-identical path-sorted rows and identical counters before it reports a ratio. It carries three corpus densities on purpose: 50,000 files with one match each and 2,500 files with twenty each collect the same 50,000 matches while differing twentyfold in how often the accumulator is locked, so scaling can be attributed. At four workers the arms reach 2.37x, 2.70x and 3.22x one worker on disk (2.49x, 2.38x, 2.80x on tmpfs), and cutting lock acquisitions twentyfold moves that by 0.33x on disk and by nothing on tmpfs, while cutting match volume eightfold at a fixed lock count moves it by 0.52x. A temporary build that timed every acquisition confirmed it and was not kept: summed across workers, lock wait was 0.9% of aggregate cpu wall at four workers and 4.1% at eight, hold 4-12ms, the path sort under 4ms. The corpus generator gained the density knobs this needed (matchEvery,matchesPerFile,fileBytes), each recorded in the manifest so a corpus of another shape is regenerated rather than silently reused. - The grep benchmark measures a corpus it generates and refuses a speed claim it cannot support. It used to search this repository and the local Cargo registry, so its numbers described a different workload on every machine and after every commit; it compared a single total match count, which cannot see a path, line or text difference between the two engines; and it ran
rgwith stderr discarded and the exit code unread, so anrgthat failed to start measured as a very fast search of no files and still printedNx faster. It now generates a versioned, fixed-seed corpus of 10,000 files (~40MiB, one constant path length, 5% matching, with hidden, gitignored andnode_modulescontrols), compares content, files-with-matches and count modes row for row againstrg, records thergand addon versions, the runtime, the CPU and the corpus identity, separates a cold pass from the warm medians, and prints a ratio only when parity held, the provenance is complete and the run's own halves agreed within 5%. Parity covers path, line number and line text; the addon exposes no column, so column drift is outside what any comparison here can see.
Fixed
- A source that changed by one edit is no longer parsed from scratch. The retained tree matched on exact bytes only, so the two questions a streamed edit preview asks — the boundaries of the file on disk, then the boundaries of the file the edit would produce — missed the slot in turn and each paid a whole-file parse: measured on a 3.7MB, 40,000-line source, 102ms per
enclosingBlockBoundariescall, twice per redraw, for a preview that redraws while arguments arrive. A miss whose source shares a byte prefix and a byte suffix with the retained one is now served by editing the retained tree with that one run and reparsing against it, which tree-sitter answers in time proportional to the edit and which produces the tree a fresh parse produces. The same call is 47ms, so one preview pass over that source is 110ms where it was 215ms. Verified against a fresh parse for nine edit shapes (in place, inserted, deleted, at either end, multi-line, non-ASCII, truncated, replaced wholesale) at three windows each, and for a line inserted into the fixture of every one of the 57 supported languages. A source above the 4MB retention ceiling still has no entry to reuse and still parses in full. - A second window of a file just read no longer reparses it. Every syntax-aware call parsed the whole source from scratch, and after the walk above was pruned that parse was the entire cost: 205ms of a 205ms
enclosingBlockBoundarieson a 3.5MB source, paid again for the next window of the same file and again for a block resolved in it. The thread that parsed keeps the tree for the source it parsed, matched on the exact bytes — a digest would answer wrongly on a collision, and comparing 3.5MB costs 0.2ms against a 205ms parse — so an edit, a different language, or a different file is a miss. One entry per thread, and a source above 4MB is parsed without being retained, which also clears the slot rather than leaving a smaller tree resident behind it. A bounded range read of that 3.5MB file measures a 18.5ms p50 where it was 442ms before this release; the first read of a file still pays the parse. - A bracket-context lookup no longer walks the whole syntax tree to answer a bounded window.
enclosingBlockBoundariesvisited every node in the file to find the at most two boundary lines a 20-line window can produce, so the walk cost as much as the parse it followed: measured on a 3.5MB, 103,000-line source, 287ms of a 503ms call, and 43ms of 85ms on an 829KB one. The walk now descends only into subtrees that hold a visible line, which is exact rather than approximate — a node holds no descendant whose content escapes its own span — so the boundary set is unchanged, verified against an unpruned reference walk over every window of a fixture in each of the 57 supported languages. The same call is 224ms and 42ms, and aread path:50000-50019of that 3.5MB file went from a 452ms p95 to 256ms. - A launch no longer waits for the stale addon cache to be deleted. The prune of dead
<data home>/veyyon/natives/<version>/directories ran synchronously betweendlopenreturning and the first native call, so its cost was set by whatever was on disk: measured on the development host, one 150MiB cache cost 7ms, three cost 24ms, and three that also held 5000 small files cost 105ms, all of it before the first frame. The prune is now handed to the event loop with the unlink work off the calling thread, which puts the first native call at 123.0ms with 450MiB of stale cache against a 123.7ms clean baseline, where it was 146.4ms against 124.9ms. A process that exits within the tick reclaims nothing and the next launch prunes instead;scripts/ensure-native.tsstill prunes synchronously at install time, which is when a stale cache is created. xargsin replace mode (-I,-i,--replace) matches GNU on a line with nothing to substitute: leading blanks are stripped from the substituted line, a blanks-only line runs the command zero times instead of once, and whitespace-only input no longer appends an empty argument in ordinary mode. The empty batch this produced is what used to reachCommandBuilder::executeand panic withindex out of bounds: the len is 0 but the index is 0(recorded nine times in the crash logs); the panic itself was fixed earlier without a test, and that suite now exists.find -Llists a symlink it cannot resolve instead of dropping it. A link whose target is missing is listed silently and exits 0, a link whose target sits under a file is listed with itsNot a directorydiagnostic and exits 1, and a loop is diagnosed and listed nowhere — the three lines GNU findutils 4.9.0 draws. The link's own metadata comes with it, so-type lmatches it, and the walker's depth bounds still apply. Previously the diagnostic was printed and the entry was thrown away, sofind -Lover a tree with a dangling link both omitted it from the listing and failed with exit 1.- The
findtest suite runs. 65 of its 207 tests read atest_data/tree that vendoring never brought across, so every run reported 142 passed / 65 failed and a real regression would have arrived inside that noise. The tree is now provisioned from code on the first test that reads a fixture, which is also what git cannot carry: a symlink whose target must not exist, a loop, and a file whose exact byte count is asserted. Two of those tests were dead for a second reason —format_stringsrewrapped their expected output through the middle of a\nescape, leaving a literal backslash, a newline and the letternin a string no run offindcould produce. - The native text engine skips an Fp escape sequence when it measures and when it cuts.
ansi_seq_len_u16recognised the Fe class afterESCand stopped at0x40, so the two-byte private sequences below it —ESC 7,ESC 8,ESC =,ESC >— were charged as two printable columns by the cutter while the width oracle beside it drew nothing for them. A cut taken against one and rendered by the other lands mid-sequence, which writes the escape's payload byte into the visible row.
@veyyon/stats
Breaking Changes
- The minimum supported Bun runtime is now 1.4.0.
@veyyon/swarm-extension
Breaking Changes
- The minimum supported Bun runtime is now 1.4.0.
@veyyon/tool-render
Breaking Changes
- The minimum supported Bun runtime is now 1.4.0.
Changed
- A running tool card builds its streaming tail incrementally. It re-stripped the whole accumulated output on every arrival and then sliced the last 2048 characters, so a 1MiB stream delivered as 256 arrivals scanned 128MiB and grew from 0.23ms per arrival to 2.30ms; it now scans 1MiB total at a flat 0.09ms (211.7ms to 27.2ms overall). The displayed text is unchanged, including for bytes that arrive mid-sequence, and
PartialTailretains only the visible window plus a sequence that has not closed — a rewound or restarted buffer starts over rather than concatenating two runs.
@veyyon/tui
Breaking Changes
- The minimum supported Bun runtime is now 1.4.0.
Changed
- A streamed markdown frame reads, scans and copies only what arrived. The renderer re-read the settled transcript on every token: two whole-text regex scans for reference definitions and over-nesting, a whole-text normalization pass, a rescan of the frozen token range for a new freeze boundary, three whole-prefix string comparisons, and three copies of every settled row. All of it is now bounded by the arrived tail or answered by string identity, and the settled rows are held as one immutable array copied once, into the array the frame returns — that array is what the render contract hands to callers, so that one copy stays. Streaming 10,000 prose tokens through one component falls from 624ms to 216ms, and the marginal frame at the end of that stream from 0.082ms to 0.010ms. Rendered bytes are unchanged: every frame still byte-matches a cold full render, reference definitions and CR input still fall back to a full lex, and settled-row exposure still resets on a rewritten lineage.
packages/tui/bench/markdown-stream.bench.tsfails if a frame starts scanning or normalizing the settled prefix again. sweepSurface,SweepSpecand the sweep entry inMOTIONare gone: a surface no longer carries a travelling specular highlight, andfillSurfaceis the one material treatment left.
Fixed
- A pinned footer's rows are excluded from the history ceiling even when no root child claims the native-scrollback replay contract. The ceiling was derived from the last replay-capable child alone, so a host that declares a pinned footer over a plain container — no transcript implementing replay — had no ceiling at all: growth that pushed the viewport top past the footer's first row committed that chrome into immutable native scrollback and, in the tallest cases, took a destructive erase-and-replay of the whole screen to repair the prefix it had just violated. A host whose transcript does implement replay was already bounded and is unaffected.
- The tracked hardware cursor row slides with a virtualized root's compaction, so an incremental paint after a drop lands on the row it names. The drop renumbering moved the commit index, the committed prefix, the window top and the previous frame length onto the compacted frame and left the cursor row in the old coordinates, where it stayed too large for the rest of the session; every cursor-relative paint after that — the seam rewrite, the in-window partial, the direct write — moved up from a stale origin, so new rows overwrote live output above them and the previous paint's tail stayed on screen below, which reads as two stacked copies of an anchored block with the transcript rows it covered gone.
- An Fp escape sequence costs no display width, on the fallback measurer as well as the native one. Fp is the two-byte private class
ESC 0x30-ESC 0x3f—ESC 7andESC 8for cursor save and restore,ESC =andESC >for keypad mode — which every terminal consumes and draws nothing for. The fallback pattern only recognised the Fe range, so a string carryingESC 7measured two columns wider than it draws, and until Bun 1.4 taughtBun.stringWidthto skip the class the two oracles disagreed with each other. A block sized by the wrong one wraps a row early and leaves the column short for the rest of the frame. - A
ui.loop-blockedwarning names the span that blocked. The render pass and terminal input dispatch — the two synchronous spans an interactive session spends its time in — now push a loop-phase breadcrumb, so the watchdog reportsui.renderorui.inputinstead ofunknown. Across 56 local session logs the watchdog recorded 2249 blocks of up to 11 seconds and 2182 of them carried no phase at all, because only three call sites in the product pushed one: a stall was reported as having happened and nothing more. The cost is two array operations per frame and per keystroke. - One expensive paint no longer holds back the cheap frame behind it. The render loop's adaptive floor targets a 50% duty cycle and read the previous frame's cost to get there, so one full paint — a scrolled viewport, where the frame diff has nothing to reuse — put a 66ms floor under the cheap diff after it. A duty cycle belongs to a window, so the floor comes from a decayed estimate: a loop that paints slowly on every frame still converges to half the CPU, while an isolated spike does not delay the frame behind it. A published recording averaged 14.2 fps with 68% of its moving frames on the 30 fps capture interval.
@veyyon/utils
Breaking Changes
fetch-retryno longer exportsisRetryableError. It was a second retry classifier: this module owns what a transport states about itself (http2RetryVerdict,isRetryableStatus,isUnexpectedSocketCloseMessage,extractHttpStatusFromError) and@veyyon/ai/errorowns what a failure means, butisRetryableErroranswered the composite question with a transient vocabulary of its own, and the two lists had drifted apart by a phrase. An embedder asking whether a provider failure should be retried callsisProviderRetryableErrorfrom@veyyon/ai/error, which composes the transport facts this module still exports.- The minimum supported Bun runtime is now 1.4.0.
Added
source-declarations.ts:stringConstantsIn,declarersOfStringValueandstringConstantValueread the string constants a module declares and compare decoded values, so a one-owner gate no longer searches source text for a formatted line. A duplicate declared under another name, in single quotes, with different spacing, or behind a type annotation is now caught; a rename or a reflow of the owner no longer reports a failure that is only formatting.definePromptRowsdeclares a directory's prompt rows and is the seam where an eval-only prompt override applies. A module that sends one prompt imports its row table directly, so replacing text in the aggregate registry alone reached the inspection commands and nothing a model is sent. Costs nothing whenVEYYON_EVAL_PROMPTSis unset: the table is returned by identity.VEYYON_EVAL_PROMPTS(JSON of prompt id to replacement text) varies any registered prompt for one benchmark arm, so a tool description or a subagent prompt can be measured without editing a file both arms share. An active override announces itself once per id, naming the registry it altered.eval-prompt-overrides.tsowns the parse, the substitution and the announcement, andunclaimedEvalPromptOverrideIdsreports ids no registry took, for a caller that knows the complete registry set.- A registry no longer refuses an override id it does not hold. Four packages ship registries and they are constructed in import order, so
@veyyon/ai's — which holds no tool descriptions — was built first and refused a validtools/bashoverride on every read, killing the process at startup. An id belonging to a sibling is left for that sibling to claim. PROMPT_ID_SHAPE_HINTanddescribeUnknownPromptIdsown the words a refusal uses for a prompt id no registry holds: every unknown id on its own line with the nearest registered ids, then one sentence saying what an id is. Two places refuse the same mistake — the bench runner before a container starts and the app at prompt assembly — and each had written its own explanation, so one operator error produced two differently worded answers to the same question.
Release notes were shortened from 148,987 characters to fit GitHub's 125,000-character body limit. Read the complete package changelogs and full commit range.