v1.1.0
@veyyon/agent-core
Fixed
- A generated summary that repeats itself is refused, and the history it would have replaced survives. Emptiness was already refused in three places, because an empty summary deletes the conversation and reports success; a summary that samples one sentence until the budget runs out is the same loss wearing content, and it passed every check. It is reachable by design: compaction generates through
completeSimple, whose loop guard re-samples a stalled generation three times and then runs one final pass with the guard DISABLED so a stubborn loop returns raw output instead of a fatal stall — correct for a live turn that is on screen and can be interrupted, wrong for text that replaces the span it describes and is then read by every later turn as its own past. Every artifact a compaction leaves behind is covered, not just the one that was reported:generateSummaryrejects a degenerate summary the way it rejects an empty one,assertValidCompactionResultrejects one from any other source (remote summarizer, compaction hook) in bothsummaryandshortSummaryimmediately before history is rewritten,generateHandoffFromContextrejects a degenerate document rather than appending the<files>block to a loop, andgenerateBranchSummarydegrades to its explicit "No summary generated" fallback and logs a warning, since a throw there would block the branch switch on a provider hiccup, which is why its empty case does not throw either. The floors are the loop guard's own, so one text cannot be a loop in the transcript and a compaction in the archive. compactWithProviderforwards the live session identity (sessionId, provider session state, and the codex compaction context, taggedresponses_compact) to the transport, which is what a host keying request identity to the conversation needs. Server-side compaction now runs for a ChatGPT OAuth (codex) session, where it previously could not resolve a transport at all and every compaction fell through to a local summary.estimateTokenscountsfileMentionandpythonExecutionmessages, which both fell through todefault: return 0and cost a session nothing. A@filemention carries up to 50KB of file body per turn and a$cell carries its code and its output, all of it sent to the provider and billed, so the compaction trigger, the pruning budgets and the context gauge each read short by the whole payload: one measured session's gauge said "61% left" while the request it had just sent was 40459 tokens against a 32768-token window, and the provider refused it. Host-contributed roles are now counted from one table besidebashExecution, and a mentioned image is charged the same fixed estimate as any other inline image.
@veyyon/ai
Breaking Changes
AuthStorageOptions.loadBalancingnow defaults to off, which is what its own documentation had always claimed while the field was initialized totrue. Every embedder that passed nothing got account movement it never asked for, and the one host that passes the option explicitly masked the disagreement. Which account spends money is the caller's decision; the default is the one that decides nothing.markUsageLimitReachedon a default-constructed storage now records the exhaustion and returns{ switched: false, retryAtMs }instead of rotating. Opt back in withnew AuthStorage(store, { loadBalancing: true }).
Added
clearCredentialBlocks(provider, credentialId)is public and type-aware, so a host can lift a rate-limit hold on one account without knowing how the block scope for that credential type is keyed. Previously only the Codex reset-credit path could clear a hold, which left an xAI or Anthropic hold with no way back except waiting.detectDegenerateRepetition(text)asks the loop guard's verbatim question of a text that is already complete: is a unit repeated at least four times back to back, 180 chars of it, carrying a letter or an emoji. The streamed detector cannot answer it, because its unit is the lastlenchars of what it has seen — right for a stream that aborts on the first hit, blind to a run buried mid-text behind a tidy closing paragraph, which is exactly the shape a stored artifact carries. It finds the run directly (per unit length, measure how fartext[i] === text[i + len]holds) rather than re-asking the tail question at every offset, and reports the shortest unit that clears the floors, so the reason names the repeat instead of a multiple of it. Callers: anything that keeps generated text rather than displaying it as it arrives.
Changed
- An explicit credential choice outranks automation, load balancing on or off. A session pin (or the provider's stored selection) is exempted from its own rate-limit hold when the routing order is built, leads the candidate order, and is admitted by the OAuth pass ladder regardless of the hold, so a held account the caller chose keeps serving instead of being substituted. Automation among accounts nobody named is untouched: an unchosen held account is still passed over for a sibling. The setting governs the product's own initiative, never what the caller may ask for.
- A quota hold no longer displaces a chosen account, but a dead grant does. A hold is this library's own prediction of when a provider will serve again; an authentication failure is the provider's verdict.
rotateSessionCredentialrecords the latter in an in-memory auth-dead set, and a credential in it stops counting as the explicit choice until a refresh, a re-login, orclearCredentialBlocksretires the mark. The set is deliberately not persisted: after a restart the account earns exactly one more attempt. sessionCredentialRoutingreports a held choice as the account that serves, not as a prediction.activeIsPredictionis now reserved for a substitute nobody picked, which is what a host needs to tell "you chose X and it is serving through a hold" from "you chose X, it cannot serve, so the next request uses Y".peekApiKeynames the chosen account. It selects by credential type instead of going through the resolve, so availability ordering alone put a held account last and model discovery described a sibling while every real request went to the account that was chosen. Choice promotion now has one owner (#leadWithChosenAccount) called by every path that orders candidates, which is also what the availability sort's own documentation had been claiming while two callers relied on an exemption inside the sort. Those two callers were passing an argument the sort no longer takes: the package did not type-check, and one of them (sessionCredentialRouting's prediction) could never see a choice in the first place, because routing answers with the choice and returns before it asks for a prediction — that dead promotion is gone rather than kept.
Fixed
- OpenAI-compatible gateways that end a tool-use stream with a final usage frame but omit both
finish_reasonand[DONE]no longer strand a complete call behind the transient retry ladder. A trailing accounting frame now terminates only when every streamed call has an id, a name, and strictly complete JSON-object arguments; text-only and partial-call EOFs remain incomplete-stream errors, so transport truncation cannot execute repaired or ambiguous input. - A sentence repeated forever is now caught within a few repeats instead of a few thousand characters. The cheap verbatim detector probed unit lengths up to 60 chars inside a 250-char tail, and a real session streamed one 80-char sentence about fifty times with nothing complaining: the repeating unit was never a candidate at any length, and a 250-char window holds three repeats of it where four are required, so raising either number alone would still have missed it. The cap is 200 chars and the window 900, four repeats of the longest unit probed plus slack. The character test that rejects runs of digits and punctuation is answered once per window by measuring how far the nearest letter sits from the end, instead of re-scanning every candidate, so the wider ladder costs a comparison per length rather than a regex per length. Text repeated after a tool call in the same stream is still not watched — the guard disarms text detection on the first tool-call event — and that hole is pinned by a test asserting the current behaviour rather than left to be discovered.
- The output-loop guard watches every model, not just Gemini and DeepSeek.
isLoopGuardedModelgated the whole guard on a provider/id regex, so a Claude or GPT stream that repeated one word five hundred times was never inspected at all and the runaway was committed to the transcript. The detectors (verbatim repetition, near-duplicate paragraphs, recycled vocabulary) are model-agnostic and were calibrated to zero false positives across 536k real reasoning blocks, and a false hit costs a re-sample rather than a lost turn, so the carve-out only hid loops. The gate is nowisLoopGuardEnabled(options)— the only thing that turns it off ismodel.loopGuard.enabledorVEYYON_NO_THINKING_LOOP_GUARD=1. The Gemini-specific header-run detector keeps its narrower gate. - Google and Vertex requests no longer fail outright for anyone with secrets configured.
streamGoogleGenAIhanded itsonPayloadhook the SDK-shaped params object, whoseconfig.abortSignalis a liveAbortSignal, and the secret redactor behind that hook walks the payload and refuses any value JSON cannot express. Every request died with "the provider request contains a non-JSON object; confidentiality transform failed." The signal never crossed the wire (paramsToWireBodydrops it) and nothing downstream reads it, so it is stripped before the hook runs. - Gateway-routed requests (pi-native transport) no longer fail outright for anyone with secrets configured when the turn offers tools. The client handed the hook the raw
context, whosetools[].parametersare live arktype schemas — function objects the walking redactor refuses. The hook now receives the exact wire shape (the body is JSON by construction), so the redactor sees the serialized tool schemas like every other consumer. - The OAuth success page no longer stacks a paragraph and a Close window button under the verdict. The tab closes itself after sign-in, so the success state is the sun, the authenticated badge, "Signed in", and the brand; the failure state keeps its reason and the button, since it does not auto-close.
- Added
test/provider-payload-json-seam.test.ts, a battery that drives every catalog api's real request-build path and asserts the object handed toonPayloadis JSON-expressible. Catalog apis are enumerated at run time, so a new api or transport variant turns the suite red until it is driven. The per-transport incidents above (google'sAbortSignal, pi-native's arktype schemas, devin/cursor's protobuf messages) were each pinned by their own suite; this one closes the class. - Devin requests no longer fail outright for anyone with secrets configured. The provider handed its
onPayloadhook the raw protobuf message, but that hook is where the secret redactor lives, and the redactor walks the payload rewriting every string and refuses any value JSON cannot express. A protobuf message is never that shape:metadata.requestIdis a uint64 and therefore a bigint, and bytes fields areUint8Array. So every single chat request died with "the provider request contains a non-JSON value/object; confidentiality transform failed." — the provider was unusable rather than degraded, and the message named the transform rather than the cause. The hook now receives canonical proto3 JSON, which carries 64-bit fields as strings, so the redactor can read and rewrite the whole payload; the reply is parsed back before it goes on the wire, and a no-op round-trip is byte-identical. Nothing is serialized when no hook is installed. - Cursor requests no longer fail outright for anyone with secrets configured. Cursor carried the same defect as Devin above, and worse:
AgentRunRequestholds 26 fields a walking redactor cannot express, so the failure was unconditional rather than dependent on which optional fields a request happened to populate. The hook now receives canonical proto3 JSON and its replacement is rebuilt into the message before the request is framed. - Server-side compaction reaches the ChatGPT Codex backend.
resolveServerCompactionTransportacceptedopenai-responsesandazure-openai-responsesonly, so a codex model failed the api gate before the capability flag was ever read and every codex session compacted locally with no warning, because from the engine's side no transport existed to fail. The transport now resolves foropenai-codex-responsesand posts to{base}/codex/responses/compactwith the ChatGPT OAuth credential, the account id read from the token, the codex identity headers (thread, window, turn metadata) and the installation header the route requires. The body is shaped the way a codex turn is:store: false, the canonicalclient_metadatablob, and on a Responses Lite row the instructions moved into a leading developer item.createOpenAICodexDirectRequestin the codex provider owns that wire truth, so the compaction transport holds no second copy of the host path, beta header, originator or client version. ServerCompactionRequestcarriessessionId,providerSessionStateandcodexCompaction. A host that keys request identity to the live conversation needs them; the official and Azure routes ignore all three.- A grant the provider refused is never named as the account that serves next.
checkCredentialsis how a surface learns of a refusal before any request is sent, and it recorded the refusal in its own result while telling the routing layer nothing, so the account card could label a revoked loginserves nexton the very row that printedoauth refresh failed, and the session's first request was guaranteed to go to the one account already known to refuse it. A failed refresh inside the probe now records the same auth-dead mark the request path records, andsessionCredentialRouting's prediction skips a refused grant instead of ordering it by rate-limit availability alone — a hold is this library's prediction about a working account, a refusal is the provider's verdict about the grant. A credential type whose every candidate was refused is passed over for the next type, which is what the real cascade does when an OAuth resolve fails with a stored key behind it, and when every account of every type has been refused the prediction still names one, because a surface asking which account is next needs an answer even when none of them can serve.
@veyyon/catalog
Changed
- No user-facing change:
provider-models/openai-compat.tsis reformatted to what the repository formatter prints, so the Biome gate passes on it. Behaviour, descriptors and resolution rules are untouched.
Fixed
- The generator now reads OAuth credentials from the machine-wide shared-auth store when the broker-profile path finds none, so a catalog regeneration on a logged-in machine sees the same credentials the app wrote instead of reporting no credentials and silently falling back to the previous snapshot.
- A successful Antigravity discovery fetch is now the served-set truth for the
google-antigravitybundle section. Previous-snapshot rows the endpoint no longer serves no longer ride forward into the bundle, so the picker stops offering ids the subscription-gated endpoint would refuse at request time. The section regenerates to exactly what the endpoint serves; a failed or credential-less run keeps the previous snapshot as the offline floor. - OAuth providers models.dev catalogs only under their API-key twin now carry the declared reasoning surface on live-discovered models, not just on rows baked into the bundle. The twin knowledge (
xai→xai-oauth,openai→openai-codex,google→google-gemini-cli) previously ran only in the catalog generator, so a model the OAuth endpoint started serving between regenerations listed with no effort ladder while models.dev declared one;xai-oauth/grok-4.6was the reported case. Twin overlay rows are enrich-only: they fill surfaces on ids the endpoint actually serves and never introduce an id of their own, because the OAuth listing is subscription-gated and an additive overlay would offer models that fail at request time.grok-4.6joins the xAI OAuth curated seed and the wire effort allowlist, and the bundle bakes its declared ladder.google-antigravityis intentionally not twinned fromgoogle: its auth is the Antigravity IDE's unofficial OAuth surface, whose served set and effort variants differ from the Google API's and stay curated from captured client traffic. Two declarations stay unmapped by design:grok-4.20-multi-agent-0309is markedtool_call: falseupstream and OpenCode Zen'sgemini-3-prois marked deprecated, so both are filtered before their ladders can reach a row. - Antigravity discovery no longer leaves the
-tieredGemini flash deployments without an effort surface. The endpoint servesgemini-3.7-flash-tieredas the only 3.7 Flash id andgemini-3.6-flash-tieredbeside the 3.6 family, and both arrived as raw unknowns with no thinking block, so the picker offered no effort levels.gemini-3.7-flash-tierednow collapses to a logicalgemini-3.7-flashrow, andgemini-3.6-flash-tieredstands alone under its wire id because thegemini-3.6-flashlogical id already belongs to the per-tier effort family. Both carry the declared low/medium/high ladder on thegoogle-leveltransport, sending the tier asthinkingLevelin the request body. The surface is curated per id; an uncurated future-tieredid still gets no invented ladder. - The ChatGPT Codex backend declares
supportsServerCompaction, so a codex session compacts server-side instead of paying a second model to paraphrase the span. The flag resolved true only forapi.openai.comand Azure, on the stated ground that the codex session transport owns history state. It does not: codex-rs posts the span tochatgpt.com/backend-api/codex/responses/compactand stores the window it gets back, exactly as the official host does. A newcodexBackendhost class carries the classification, so provider idopenai-codexand anychatgpt.com/backend-apibase URL are covered while a repointed proxy row still is not.
@veyyon/coding-agent
Breaking Changes
BorderedLoaderis renamedComposerLoaderand no longer draws a rule above and below itself. It takes the composer's place while a command runs, and the composer zone has no box. An extension that importsBorderedLoaderfrom@veyyon/coding-agentmust importComposerLoaderinstead; the constructor and thesignal,onAbort,handleInputanddisposemembers are unchanged.DynamicBorderis deleted. It rendered one full-width horizontal rule and was the component every remaining sandwich reached for; with the last of those gone it has no callers. A block that needs to declare itself does it with a header on the rail./secret <value>is removed. A command comes first on every surface:/secret add <value>stores a credential in a terminal,/secret addon its own opens the hidden field,/secret from-env <VAR>reads it out of the environment, and a bare/secretprints the usage. A first word that is not a command is refused and nothing is stored. Reading an unrecognised first word as the credential saved one word and cost the grammar three mechanisms to contain it: every command had to be reserved in advance so a mistyped verb could not become a vault entry (/secret lststored the stringlstand switched protection on), a credential beginning with a reserved word then collided with the command, and that collision needed an escape spelling of its own. Behindadda value is read in exactly one place, so none of the three is needed. The refusal a terminal prints says the line is exposed — nothing was stored, and a credential the vault never saw is sitting in a scrollback it cannot obfuscate, so rotate it — and it never repeats the word it refused, because that word is very often the credential itself. A client with no field is refused the same way without the scrollback sentence: its line came from argv rather than a screen./secret -- <value>is removed, and the escape for a credential whose first word is a reserved word is/secret add <value>.--is shell grammar: it means "the options are over", and a slash command has no options to end, so the one spelling an operator had to be taught pointed at a convention that was never in play here.addalready did the job — it hands the rest of the line to the value reader verbatim, reserved first word and all — so the two were one escape with two spellings, and the surviving one is the word people try first. The removed spelling is REFUSED rather than read as part of the value:--is not a reserved word, so passing it through would have stored-- sk-live-xverbatim, and a credential with two dashes welded to its front expands under#NAME#into requests that fail somewhere unrelated, long after the line that caused it left the screen. A value that merely begins with dashes (--abc) is unreserved and is still stored byte for byte.- Every
--flagis gone from/secret, and every argument is a plain word.--from-env,--ttl,--scope,--limitand--nameare refused, each naming the plain word that replaced it:/secret from-env <VAR> [<name>] [7d] [project],/secret extend <name> 7d,/secret rm <name> [global],/secret clear profile,/secret log [<name>] [50],/secret discard project,/secret value <name> from-env <VAR>. A word means something because of the POSITION it sits in, or because it belongs to a CLOSED SET or SHAPE that provably cannot overlap another slot's: a vault is one of exactly three words, a lifetime is30m|12h|7d|2w|neveror any digit-leading word, a limit is digits only, a secret name may never begin with a digit and may never contain a hyphen. Position wins wherever the two could disagree, so/secret rm PROFILEremoves the secret namedPROFILEand/secret extend NEVER 7dextends the one namedNEVER-- names an option grammar could never reach, because there the word would have been read as the option's value. The removed spellings are refused only as the first word afteradd, so a credential that merely begins with dashes is still stored byte for byte, and a word a command does not read is refused by POSITION without ever being quoted, since the realistic slip is muscle memory foraddunder another command and that word is then the credential itself. from-envis a command of its own rather than a modifier onadd. It sits besideaddin the entry group of both help texts, takes the variable first and the name second, and reads a lifetime and a vault after them in either order. The name is required on a client, which has nothing that can ask for one, and optional in a terminal, where the same field that names a pasted credential asks afterwards. Splitting it out exposed a real defect: storing a first credential switchessecrets.enabledon, and that opt-in readsubcommand === "add", so a client -- whose only way in isfrom-env-- stored its first credential with protection still off and the placeholder never substituted. Every storing command now turns it on, from one exported list, and the regression sweep derives which commands store from whether an entry appeared in the vault rather than from a list of names, so a storing command added later is covered the day it parses./secret addon a client is refused with its own sentence rather than parsed. A surface with no field cannot hide typing, so an inline credential there is retained in the client's own request history; the refusal says nothing was stored, namesfrom-envand the exact line to run, and repeats NEITHER word afteradd, because nothing distinguishes a name followed by a credential from a credential whose first word looks like a name.addstays listed in that surface's usage with the reason it does not work, since it is a declared command an ACP client can see, and a listed command whose only documentation is the error it returns reads as broken instead of as refused on purpose.- Every
--flagis gone from/stats,/mcpand/sshtoo, so no slash command in the product takes an option./stats [<port>];/mcp add <name> [http|sse] [url <url>] [token <token>] [run <command...>],/mcp remove <name>,/mcp smithery-search <keyword...> [<limit 1-100>] [semantic];/ssh add <name> <host> [user <user>] [<port>] [key <keyPath>],/ssh remove <name>. Each removed spelling is refused naming the plain word that replaced it, rather than reported as an unknown option: an operator who types--port 8080is told to write8080. A value whose text is arbitrary keeps a leading keyword (url,token,key,user), a transport and a vault are closed sets, a port and a limit are integers, andrunhands the whole remainder to the child process. Where a plain word is read by its shape, the command reads no keyword of that shape, which is what makes the detection provable rather than lucky:/statsreads one thing, and past its two positionals/ssh addreads only the literalsuserandkey, neither of which is a run of digits. /mcpand/sshno longer take a scope on any surface, andprojectoruserwritten as a plain word is refused with the reason rather than read. SSH hosts live in one config file and MCP servers in one profile file, so the word selected nothing on the surface that refused it and selected the wrong file on the surface that honoured it; a word that reads as having chosen is worse than a missing one, and one that silently redirects a write is worse than both. The TUI and the text/ACP handler now deliver the same sentence from one constant, because two copies of a rule an operator is refused with drift, and a test pinning one of them stays green while the other says something else.- The durable-history gate that keeps a credential-bearing slash command out of recall and out of
history.dbnow matches a credential-bearing argument name as a PLAIN WORD as well as in both dashed spellings. The grammars became plain words in this release, so a matcher that required a dash would have classified/mcp add srv url https://x token sk-live-…as safe and written a live bearer token to recallable history. The command whose grammar HAS a credential slot gets one more test and it fails closed: everything past its positionals must be a shape that grammar reads, so a value-lesstoken, a misspelledtokn sk-live-…and a half-remembered option spelling are all treated as secrets rather than matched against an allowlist that cannot see a typo. The vocabulary is restated beside the gate rather than imported from the parsers, which import the gate; drift is safe in exactly that direction, since a word added to a grammar and not added here makes the command unrecallable instead of making it a leak.
Added
/statsnow exists. The dashboard's argument parser and its launcher were written, exported and covered by a suite whose first line calls it "the argument string of the/statsslash command" — and nothing in the product called either one: there was nostatsentry in the declarations and none in the registry, so/statsansweredUnknown command,Usage: /stats [<port>]named a command that did not exist, and the dashboard was reachable only asveyyon statsfrom a shell. It is declared and dispatched now, with[<port>]as its inline hint, and takes the port as a plain integer defaulting to 3847. A parser with a full unit suite and no caller reads exactly like a finished feature, which is how it survived; the new suite drives the real ACP dispatcher instead of the parser, so removing the declaration, the registry entry, or the handler's use of the argument turns it red.- The composer chips (
interrupt,background,dequeue) are click targets. A left click on a chip runs the same action its keybinding runs. Hover paint stays off: the main session holds press/release tracking only, so drag-select in the terminal keeps working. - The
/pausescreen resumes on a click. It is a fullscreen overlay, so it already held the whole mouse-tracking set and every report reached it and was dropped: the pointer did nothing on the one screen whose only job is to let you out. A left press anywhere on the scene now resumes exactly as Esc does, motion, drag, release, wheel and the other buttons still do nothing, and both the full scene and the compact card name the click in their hint. - Every modal card folds away when it is dismissed, on the same clock and the same curve it opened on, played backwards and shorter. A card dismissed while it is still opening leaves from where it had got to rather than snapping open first. The animation is paint and nothing else: the keyboard, the mouse and the session behind the card are handed back the instant Esc is pressed, so a fading card cannot swallow the next keystroke, and a card disposed halfway through its fade still leaves rather than staying on screen. A terminal that skips the open unfold — non-truecolor, or
display.transitions: off— skips the close too. - The row under the pointer lights up over 90ms rather than switching on, in every modal picker and on the settings screen. Moving the pointer down a list cross-fades: the row being left is still visible while the row being arrived at comes up, so a drag reads as the list tracking the pointer instead of a hard band strobing row to row. The band's color is mixed out of the same ground a card unfolds out of, so it arrives from the page rather than appearing on it. A 256-color theme keeps the switched band, since every intermediate color would quantize onto a different palette entry and read as the band changing hue.
- The composer's suggestion popup grows into place instead of appearing whole. Its rows arrive over the same 220ms curve a card unfolds on and resolve out of the same ground, so the composer is pushed rather than jumped — the popup opens on nearly every keystroke, which is exactly why a cut there is the most visible motion in the product. It grows once per appearance, not once per keystroke: a refresh that rebuilds the list keeps the height it had reached. A click during the grow accepts the suggestion under the pointer, a dismissal is instant, and a terminal that skips a card's unfold skips this too.
- The settings card's category sidebar cross-fades under the pointer, like the pane beside it. The sidebar is a tab bar, which was the one pointer surface the band fade never reached, so the same card faded on the right and switched on the left, two columns apart in one frame. The card lends the bar its repaint and takes it back when it closes: a dismissed card leaves nothing running on the shared clock, including a fade still travelling when Escape landed. A terminal without truecolor, or a user with transitions off, keeps the switched band byte for byte.
- The extension ask dialog cross-fades the option row under the pointer, like every other picker. It hit-tests and answers on a click, so it was already a pointer surface — it just banded on the frame the motion report landed, which is the strobe the rest of the product stopped doing. The dialog now takes the card's repaint and gives it back on dismissal, so nothing outlives an answered question, and a terminal without truecolor or with transitions off keeps the switched band byte for byte.
- The
/copypicker cross-fades the row under the pointer, and every picker gives the shared animation clock back when it is dismissed. The copy tree paints its own rows, so it switched the band on the frame a motion report arrived while the pickers beside it faded. The teardown is the bigger half:/tree,/history, the branch-from-message card, the thinking picker, the slash-command picker, the reset-usage picker and the debug picker all open through one show site that hid the overlay and told the card nothing, so a band still travelling when Escape landed kept asking for frames against a card that would never be painted again. The show site hands the card back now — including a card that closes itself before its overlay exists — and/copy, which does not use that show site, does the same at its own. - Every overlay card opens with light crossing it, instead of appearing. While it unfolds each row arrives on its own ramp inside the 260ms curve, so twenty rows give twenty overlapping fades rather than one strength for the whole card, and one specular highlight then travels across it over 520ms on a diagonal, lifting the selection band and every chip it crosses rather than erasing them. All twenty-one cards get it from one seam, so no card animates differently from the card beside it. A settled card carries no fill of its own: it is line art on whatever the terminal is showing, and the light is the only thing that paints. The motion is off unless the terminal can take
48;2truecolor AND the ground behind the card is actually known: a terminal that answered no OSC 11 leaves the theme's declared ground as the only guess, and titanium declares black, so mixing out of it on a grey terminal would have derived every colour from the wrong page. The light is released the moment a card starts leaving and when it is dismounted — it outlasts the unfold by design, and a card dismissed mid-entrance used to leave it running on the shared clock. /secret clear <profile|project|global>empties one vault and names the placeholders it dropped. It is a single locked vault transaction rather than a loop overremove, so a credential stored while it runs cannot survive a command that reported the vault emptied, and a half-completed clear cannot be reported as success. There is no default scope and no bare-word form: the narrowest copy of a credential is the one you can reach, so a vaultlessclearrefuses and names the word to add instead of guessing which of the three vaults to empty. Names that a wider vault still resolves are reported as removed but not as revoked, because#NAME#there still spends a live credential; an expired entry is dropped from the file and left out of the report, since it could no longer expand.secrets.expiryWarnings(Privacy, default on) turns off the unprompted warning that a secret is about to expire. It gates only the notice nobody asked for:/secret liststill prints a STATUS column and the status-line chip still shows the vault, because answering a question with silence is a different feature from not interrupting.accounts.loadBalancingis off by default, and the account card can lift a hold. The setting decided which account spends money and it shipped on, so the product picked for you and the card's own copy said it was off; the account you chose is now the account that serves, hold or no hold, and automation only ever moves between accounts nobody named. A held row carrieson hold for <duration> · c lifts the hold, andcclears that account's rate-limit block for its own credential type — before this only a redeemed Codex reset credit could clear one, so an xAI or Anthropic hold had no way back except waiting it out, including a hold the provider had already lifted.- The row under the pointer answers on every row, including the one the keyboard cursor is on. The band was suppressed there on the theory that a selection's own paint is the stronger signal, which left every list and every modal picker with one cell the pointer could not reach: the row the eye was already on lit up for nothing, and reaching its neighbour meant pointing at something else first. Pointer and keyboard are one highlight now — a card whose cursor row already carries a band keeps that one, and the eleven cards whose cursor row is accent-only take the pointer band like any other row. The pointer still never moves the cursor: a mouse crossing a card must not change which option Enter answers.
- The extension ask dialog's tab strip answers the pointer.
TabBar.tabAtwas implemented, the geometry to hit-test it was on hand, and the dialog never called either: hovering a question tab did nothing and clicking one did nothing, so a multi-question ask could only be navigated with Tab. Hovering a tab bands it and clicking one switches to that question or to the review tab; hover stays separate from the active tab there, because activating on hover would change which question is on screen as the pointer crosses the strip. install.sh --forceandinstall.ps1 -Forceinstall over a file at the target path the installer cannot account for. The file is moved to<name>.unowned.<pid>and its new path printed; nothing is deleted, and no sweep or uninstall touches that name. Without the switch the refusal is unchanged.- Subagent models can be pinned per spawn depth with
subagent.modelByDepth, a record of depth to model chain:"1"decides what direct children run,"2"what grandchildren run, and so on, each value in the same string-or-list chain shape assubagent.model. A row outrankssubagent.modelfor a spawn at exactly that depth; depths without a row resolve as before (subagent.model, then the agent definition'smodel:, then the session's live model), and a row whose chain matches no available model refuses the spawn and namessubagent.modelByDepth.<n>instead of falling through. Keys that are not positive integers are reported by settings validation at load, naming the entry. Settings → Subagents → Models gains a "Models by Depth" row that edits each depth with the same chain picker as Subagent Model.
Changed
- A
todoresult reports the plan in one line of arithmetic instead of three, and lists only work that is still OPEN.Remaining items: 1.sat directly aboveOverall: 5/6 done, 1 open., which is the same number twice, above a preview that spent four of its five rows re-printing tasks the board had already drawn as closed. A result now reads5/6 done · 1 open · phase 2/3 Validation (2/3)followed by the open rows and a… N more opentail. The 200-character sentence explaining that the in-progress pointer auto-advances is gone: it is standing policy, the tool description states it, and the model therefore reads it every turn whether or not each result repeats it. What that sentence carried which the rule does not — that the active phase can sit behind out-of-order completed work — survives as, worked aheadinside the phase's own parentheses, on the results where it is true. The reported case drops from eleven lines to three. - A failed
todoupdate warns in one line. The whole result text was inlined into the notice, so a failure produced an amber block of the entire ledger directly above the card that draws the same ledger properly. The notice takes the headline and leaves the report to the card. - The todo board's entrance no longer types. Each row was written in from the left behind a block cursor, which cut rows mid-word (
Reject a credential who▮) and drew a lone track cell for every row that had not started yet, so a panel whose only job is to say what the plan is was unreadable for the whole fourteen-frame envelope — and the product's own recordings are not allowed to show typing. A row now arrives whole and brightens, dim through muted to its own colour, one frame behind the row above it, so the wave travelling down the block is what reads as assembly and every frame is legible. A row whose settled colour is already the dimmest never brightens at all, because ramping it up and back would flash the eye onto finished work. - The todo board's phase rows lose their gauges and its weight is inverted. Four phases carried four 12-cell gauges, each approximating a fraction printed two columns to its right, and at that width a gauge cannot tell
0/2from1/4: a phase row is now a marker, a name and its count, the header keeps the board's one gauge, and the freed column is what the task rows are legible in. Completed rows were drawn in the loudest colour on the board while the task actually in flight was quieter, so the eye landed on what was already finished; closed work now recedes to dim and struck, a phase that has not started is bold muted, and the one task in flight is the only bold accent row on the block. - A finished todo board draws nothing in the HUD above the composer. It collapsed to
▪ Todo list done · 6 tasks, which is the sentence the transcript card for the write that closed the list had just printed from the same owner — both on screen at once, one of them anchored for the rest of the session. The HUD is for work in flight, the card is where history lives, and the region being gone is how an anchored block says there is nothing open. Its header also names its unit:Todos · phase 1/3sat one line above phase rows ending· 0/2, the same shape counting two different things. /secret's help footer and its refusals are built from the grammar table instead of written out, so the help cannot describe a surface that no longer exists. The vault word gained a fifth reader and the hand-written lineon add, rm and discardwas false in the same change that added it; the footer lines and the sentence an operator is refused with now readon from-env, rm, clear, scope and discardfrom one owner. Which commands refuse a missing vault is data on the same table rather than prose plus a chain of conditions, so a command cannot be declared as needing one and then be allowed through without it.- Every bar in the product is drawn in eighths of a cell by one owner, so a value moves instead of jumping. The
/usagebars,/accountand the account card's usage windows,/context, the tiny-model download row, andveyyon usage,veyyon tiny-modelsandveyyon grievances pusheach built their own bar out of█and░, which gives a ten-column bar ten states: 3% moved nothing and a crossed column jumped a whole cell. They all draw throughsubCellBarnow, at eight steps per column, with the same colours and the same column layout — a bar is still exactly as wide as it was, since the account surfaces line their bars up by column position. The context/usage bar also loses its own▒/▓approximation of a third and two thirds of a cell, which was the only sub-cell precision in the product and read as a shading artifact rather than as a position. The glyphs come from the symbol preset:asciigets#and-at whole-cell resolution, because a font without the partial blocks draws a replacement box in the middle of the bar. - The tiny-model download bar travels to each new percentage instead of appearing at it. Its ratio is a
SettleValueon the shared clock under thesettlespring, so a download reporting in 1% steps and a cached shard completing in one event both read as movement; the percent beside it still prints what was last reported, since the number is the fact and the bar is the travel toward it. The first reading lands with no travel, the row stops the settle when it leaves the transcript, and a terminal without truecolor or withdisplay.transitions: offgets the jump it had. The three CLI bars stay static: they are one-shot\r-rewritten lines with no render loop for a clock to drive, and so is the/usagereport, which is one string presented into the transcript once. /resume,/tree, the branch-from-message card and/historycross-fade the pointer band like every other picker. Those four paint their own rows rather than building aSelectList, so they switched the band on the frame a motion report arrived while the rest of the product faded, and which behaviour you got depended on which card you opened. All four now band through the same strength-aware paint, so the row the pointer leaves is still visible while the row it arrives at comes up, a settled row is the exact byte sequence it always was, a card with no repaint to lend keeps the switched band, and a dismissed card drops the band instead of leaving one behind under a pointer that is gone.- The todo reminder and the rule-injection notice are raised cards on a coloured rail, not full-width inverted slabs. Both built
new Box(1, 1, t => theme.inverse(theme.fg("warning", t))), which pads each row out to the terminal width and inverts it, so a note about twelve todos was a saturated mustard rectangle carrying black text from column 0 — the loudest object on a grey transcript, and the only one touching the left edge. The hue now lives in a rail glyph down the left of the block, the block is exactly as wide as its own widest line, it sits at the composer's inset, and its background is a lift off whatever ground the terminal actually reported, away from that ground's own luminance, so a paper-white terminal gets a card that darkens rather than one that glares. Inverting also spent the foreground, which is why the rule notice could only use bold and italic inside the block; it colours rule names and descriptions now. A terminal that cannot take 24-bit colour, or one whose ground nothing answered for, keeps the rail and the colours and paints no surface. - A tool block is as wide as its own output, not as wide as the terminal.
renderOutputBlockdrew its frame from column 0 to the last column and padded every row out to meet it, so reading one line of a file produced a rectangle the width of the screen with one line of text at the left of it, and a state background painted that whole rectangle — the wall version of the slab. The block is measured against its widest line now: the frame closes after the content, a state background is a plate the size of the block rather than a band across the screen, and a header longer than the content keeps the block open wide enough to hold it. Every tool in the product draws through that one function, so all 22 renderers change together. Content still wraps at the width it always did, so a renderer that budgets rows againstoutputBlockContentWidthcounts the same rows; at the terminal edge it is the block's right-hand air that is dropped, never a column of text.bash-interactivekeeps its full-width frame, because it mirrors a live PTY whose width is the terminal's. - A tool block hangs its output on a rail instead of sitting in a box. Hugging the box to its own ink fixed the wall and left the box: a rule with the title cut into it, a wall down each side, a rule under the last row — five glyph kinds and two whole rows of chrome around one line of output, on the most repeated object in a session. A block now draws a title line and one thin glyph down the left of the output, in the state's colour, with nothing above the title, nothing below the last row and nothing to the right of anything; a result with no body is one line where it used to be three. The rail is two columns wide, exactly what the two walls cost, so
outputBlockContentWidthis unchanged and every renderer that budgets rows against it counts the rows it always counted, and a state background is still a plate the size of the block.bash-interactivekeeps its own frame on purpose: that block mirrors a live PTY whose width is the terminal's, so a hugged or railed frame there would misreport the geometry the program inside it draws to. - A settled overlay stops copying the screen once a frame.
applyModalRevealruns on every frame an overlay is open, and it rebuilt the frame array even when the entrance had finished and there was nothing to paint: same bytes, new array, every row copied, for nothing. A frame with nothing to treat is now handed back as the array it arrived as, which is also the identity the reveal suite asserts — and since a settled card paints nothing at all, that is now every settled frame on every terminal rather than only the ones that report no truecolor. - Every remaining hand-painted hover surface cross-fades its pointer band, so no card is left where the row under the pointer switches on. The reset-usage picker,
/move, the extension list and the extensions dashboard's tab bar, the hook selector, the/mcp addwizard, the OAuth picker and the sign-in scene, the model hub's scope sidebar and roles pane, the model list embedded in the hub, the model picker, the account manager's provider sidebar, and every model panel on the settings screen all band through the same strength-aware paint now. Doing the whole set rather than the reachable half turned up two defects: a card whose host hands the repaint in through its CONSTRUCTOR rather than throughsetOnRequestRenderbuilt no fade at all, so the seam existed and the real host never reached it; and the/mcp addwizard banded its own selected row, which reads as two selections at once. Teardown is the other half —/mcp add's wizard, the model picker, and a settings panel swapped out when a submenu steps back to its list all hand the shared clock back, so a band still travelling when Escape lands goes with the card instead of ticking against something that will never be painted again. A terminal without truecolor, or transitions off, keeps the switched band byte for byte. - Settings → Subagents says "subagent" wherever it means a spawned worker. The tab held an
Agentssection carrying anAgent Rosterrow, anAgent Delegationrow beside it, and a roster screen headedAgentswhose per-agent page was headedAgent: <name>, so one thing wore two names on one screen. The section isSubagentsnow, the rows areSubagent RosterandSubagent Delegation, and the screens readSubagentsandSubagent: <name>. No setting key changed, so nothing in a config file moves. Max Nested Spawn Depthsits in the Subagents section directly underSubagent Roster, not under Limits. It is the ceiling every per-subagent override inherits from, and the roster's own depth picker names it, so the blanket value and the overrides that outrank it were two sections apart with nothing on either screen saying so.- A subagent's settings page owns what that subagent runs, and recurses. Every page — the agent's own, and each level under it — carries the same four rows:
Enabled,Model,Effort, andSubagents, the door to what that level may spawn. Unset means the level above; the agent's own page unset means the blanketSubagent Model/Subagent Effort, then the agent file's frontmatter, then the session's model. The badge names the exact path that decided, so the value on screen and the value a spawn uses cannot come from different places.Subagents → EnabledIS the depth limit, level by level, and the per-agentMax Nested Spawn Depthnumber is gone from every screen: a ceiling edited on one screen and read on another was the whole defect. Asubagent.agents.<name>.maxNestedSpawnDepthalready in a config file is still honored and still means the depth it always meant, is named once in the log with the screen that replaced it, and is dropped only when a chain is written over it. The blanketMax Nested Spawn Depthkeeps answering from the first level no chain names, so an install that configured none of this behaves exactly as before. - Settings → Subagents has one section for what a subagent is and what it runs.
Subagent Model,Subagent Effort,Models by DepthandShow Resolved Model Badgesat in a separateModelssection below the roster, so the screen that showed what a lane runs and the rows that decided it were two sections apart. They are in theSubagentssection now, and theModelssection is gone. No setting key changed. - The subagent roster edits the model and the effort it shows. Its first two rows are
ModelandEffort, both labelled "every subagent", and each opens the same chain picker and the same effort list the tab rows open, writingsubagent.modelandsubagent.thinkingLevel. The per-subagent page previously printed "Change it in Models · Subagent Model and Subagent Effort" and changed nothing; it carries its ownModelandEffortrows now, which write that subagent's own row (see above). Batch Delegation,Models by DepthandShow Resolved Model Badgeare behind the Advanced fold, and the isolation riders (Merge Isolated Work,Commit Isolated Work) and the soft-budget notice are hidden while the feature they qualify is off, rather than sitting inert on the tab.- Choosing
Inheritfor the subagent effort clears the setting instead of storing an empty string, so an inherited effort reads as unset everywhere rather than as a configured blank. - Every overlay card opens on the shared animation clock and resolves out of the theme's ground as it unfolds. The unfold was a clip on a timer the card owned: full-strength chrome from its first frame, so a card opening while anything else animated ran on a second timer that never shared a frame with it. The card now eases on the one clock under the
enterpreset, and each visible row is blended from the ground to full strength as it arrives, which is the difference between a card arriving and a wipe with a moving edge. A theme without a declared page background fades from black or white to match its appearance, and an indexed-color frame is left alone rather than being repainted from a palette the terminal may not be using. - The cut-short batch marker takes its indent from
COMPOSER_INSET_COLSinstead of two literal spaces. No visible change: the rail is two columns, so the bytes are the same ones. The rail now has one owner, and the marker's suite asserts it sits on it. - The selected and hovered row is a band with a direction instead of a flat rectangle of one colour. Its first cell is the accent at full strength, and from the second cell the background ramps out of
selectedBgtoward the ground the row sits on, eased so most of the colour lives in the first third — a flat slab says nothing about which end the cursor came from, which is why a selected row read as a rectangle somebody drew rather than as a surface the cursor is resting on. The ramp is quantised to one span per eight columns, three at the fewest and ten at the most, so a 40-column row costs five extra escapes rather than forty on a row that repaints on every keystroke. A row that carries no styling of its own also gets a brighter label over that first third; a row that paints its own colours keeps every byte of them. Hover is the same treatment at a strength, so a settled hover is byte-identical to a selection and every colour in the gradient, the accent cell included, is mixed out of the ground on the way in. The row's printed width is unchanged — the treatment only adds zero-width escapes, which is what the mouse routing depends on — and a terminal that is not truecolor gets the flat band byte for byte, since every intermediate colour there quantises onto the nearest palette entry and the ramp would read as the band changing hue. - The system prompt no longer tells the agent that it needs no permission to commit and never waits to be asked. Landing each green chunk as its own commit is still the instruction, and the prohibition on push, force-push, revert, reset, checkout-over-changes, clean, stash-drop and branch deletion without the user asking is unchanged; what is gone is the framing that granted a permission on the user's repository that the user never gave, in a prompt every session reads. The orchestrator notice carried the same two sentences and loses them too. Block 0 of the cached prefix is 106 bytes shorter, which its digest gate records.
- The transcript viewer and the plan-review overlay no longer import
transitionsEnabledand never call it, andspliceAtColumnsin the theme no longer names a localescapeover the global of that name. Both were flagged by the linter and neither changes a painted byte: the two components already read the transitions setting through the motion they lend their lists, and the renamed local is the ANSI run it always was. - The todo tool block is a telemetry panel instead of a checklist. The header carries the board's standing (
Todo 4/9 tasks █████▍░░░░░░), each phase gets one named row with its own gauge and its own count, and phase membership stopped being a dim(Auth)parenthetical repeated on every task row. A fully closed phase collapses to that single gauge row, which is where the wall of struck-through history went; a collapsed board lists a phase's open work plus the one task that closed on this write, so the strike still plays where the reader is looking. The├─/└─glyphs are gone: a collapsed board is one level deep inside a rail that already means containment, so the tree drew a hierarchy that was not there. The roman numeral stays, because the composer's phase rail formats phase names through the same owner and dropping it here alone would make two surfaces disagree. Trailing counts align to the widest row rather than the terminal, so the block keeps hugging its own ink. - Every landed todo write animates, not only one that closed a task. Rows type themselves in behind an accent block cursor, staggered a frame apart, while the header gauge charges from zero to its value; the closing task's strike is sequenced after the entrance rather than drawn over a half-typed row. The envelope is unchanged at 14 frames and 910 ms and the stagger stops accumulating, so a long board lands in the same window as a short one — a settled tool block is committed to native scrollback, and an animation that outlives the commit would freeze a half-drawn frame into history. A row that has not started yet is its cursor cell rather than an absent row, so the block's height is identical on every frame. Frame zero is the settled board:
spinnerFrameis a shared field that non-animating surfaces (veyyon gallery, the HTML export, the collab guest) set to a constant and render once, so the animation runs from frame one and the driver's counter starts there. - The rail beside a tool block moves while the tool runs, and settles when its result lands. A highlight travels down the rail every 60 ms while a block is live, over a rail cooled toward
dimbetween passes, and the frame the result arrives one 630 ms pass runs down the block from the top handing each row the colour it settles on. The rail had two colours and a hard cut between them, so a command that ran for four seconds sat beside a line that never moved and then changed colour in a single frame; the spinner was no help, because bash, read, fetch and ssh's result declare neither animated preview, so the blocks an operator watches longest got no frames at all. The motion repaints the rail cell and nothing else: the row count, every visible character and every byte from the rail glyph rightward are the renderer's, a row with no rail is handed back untouched, and the last frame of the settle IS the block's own bytes, so a block committed to native scrollback mid-pass cannot freeze a half-drawn rail into history. Only a block whose live rail was actually painted settles, so a rebuilt transcript — constructed and handed its result in one tick — draws its history flat instead of playing two hundred passes at once. A 256-colour terminal animates in the steps its palette has, a rail with no colour at all is left exactly as drawn, anddisplay.transitions: offarms neither interval. - Delegation gates and the verify step say the same rules in fewer words. No gate, proof type, or test-quality bar left.
- No user-facing change: a generated session title is flattened onto one line by
collapseWhitespace, the one repo-wide owner of that idiom, instead of re-inlining the regex beside the tag stripper. The inline copy was what the collapse source lock exists to catch, and it made the lock red.
Fixed
- A generated session title keeps a markup tag the message it names typed. A session opened with "fix title generation for
<think>tag parsing" was titled "Fix tag parsing", because the guard that stops a local model publishing<tools>as a title stripped every complete tag, including the one the work was about. A tag is now dropped only when it does not appear in the message that started the session, so leakage still goes and subject matter stays. - The context gauge says
? leftinstead of100% leftwhen it does not know. Used tokens are anchored on the last assistant's real prompt-token count, so right after a compaction there is no anchor and the session reports nothing — which the footline turned into zero tokens, a full bar and the words100% left, in the one moment it knew least, while/contextanswered "usage is unavailable" about the same session. The unknown now travels: the status line's breakdown reportsnull, the gauge renders the? leftits own formatter could already spell but nothing could reach, and a collab host sends the null on the wire (whereContextUsagehas always been nullable) rather than flattening it to zero for every guest. A real zero still reads100% left, because that is the number.SegmentContext.contextTokenswent with it: the47k/170kreadout that consumed it was removed a while ago and the field has been dead since, so nothing was left to disagree about. - Enhanced paste asks the terminal for DEC private mode 5522 instead of writing the set itself. The escape and its reset now belong to the terminal, which writes them only after its own DECRQM probe confirms the mode, so kitty no longer logs
Unsupported screen mode: 5522 (private)twice per session for a feature that never armed. Handling of kitty's OSC 5522 clipboard packets — the part that actually works — is unchanged. - A session is never named after a control token. The title role resolves to the tiny model, then the commit model, then the session's own model, so on a machine with no tiny model installed the titler is whatever is serving the session — and a local Qwen3 answered the title prompt with
<tools>. That was accepted as prose: written to the session header, painted on the footline, and set as the terminal title, so the session read as a placeholder nobody had filled in. A generated title that is only markup is now refused in every spelling a local chat template emits (<tools>,</think>,<tool_call>,<|channel|>,<function_call>), which leaves the session unnamed and gives the next message a fresh attempt — the same path thenonesentinel already takes. Prose that arrives beside a leaked marker keeps the prose and drops the marker, and a title using an angle bracket in ordinary arithmetic (Make retries < 5 fail fast) is untouched. - The footline's secret chip no longer reports a count nothing else in the product agrees with. It printed
3 secretsin a session whose/secret listanswered one active secret, and both numbers were right: a session builds its protection fromsecrets.yml, the vault, AND every environment variable whose name matches an env keyword, and an auto-detected environment value is registered with no name, so it is masked on the way out but cannot be spent as#NAME#and the list has nothing to call it. The chip added the two together under one word. It now reads1 secret · 2 masked, so its leading number is the same quantity the list enumerates and the values that are only being masked are still declared rather than hidden. The split is made once, inliveSecrets, and the rule for deciding whether a live placeholder carries a name has one owner (placeholderSecretName) instead of a slice-and-test at each of the two call sites. - A tool block never draws its rail in a colour its own ground swallows. Twelve renderers ask for the rail in
borderMuted— the todo board, bothwritepaths, all fiveaskprompts, bothast-editpaths,inspect-imageand the search results — and on titanium that resolves to#202329against a#1e2127ground: two levels apart on the worst channel, measured off a real terminal capture, so those blocks drew a left edge that was not there while the bash block beside them kept its own. Sixteen of the bundled themes shipped the same rail,dark-cosmosat one level anddark-lunarat one.renderOutputBlocknow resolves the requested colour against the ground actually on screen and falls back todimwhen the two are within twelve levels, so the repair lands once for every renderer and every theme rather than in the todo board alone;dimis the fallback because that is the colour every settled block already draws. A theme whosedimis also on the ground keeps what it asked for, since there is nothing better to offer it. - An animating tool block no longer tells the engine its rows are history. Rows below the live-region start are the committed prefix the renderer audits, and a byte that changes there is repaired by erasing the screen and replaying the transcript — the flash this release spent its length removing. A block finalizes the moment its result lands, which is exactly when the rail's settle pass and the todo board's entrance start repainting its rows, so every landed tool block asked for that repair fourteen frames in a row. The live-region seam now reports row 0 while either animation is running, and
isTranscriptBlockFinalizedis untouched, because displacement and sealing read that and a block whose result has landed IS finalized. - The todo gallery fixture no longer paints closed work as an open box. Its second task carried
status: "done", which is not aTodoStatus— the valid words arecompleted,in_progress,pendingandabandoned— so it fell through to thesatisfies neverbranch that exists to catch exactly this and rendered as pending. A settled gallery snapshot also no longer races a tick: for asuccessorerrorstate the renderer stops the animation before it paints, which is the determinism that file already said it wanted, while a streaming state keeps its live frame. - A word that used to be an option is refused with the sentence naming what replaced it, whether it is written
--scopeor as the plain wordscope. This covers/mcp add,/mcp remove,/ssh add,/ssh removeand/stats: each keys its removed spellings by bare name and turns a key into a reason, but each parser consulted that map only for a token starting with-, so the dashed spelling got the reason and the plain spelling got a bareUnknown argument: scope— or, on/stats,Invalid port: port. The operator who typed the word the old grammar taught was told only that it was not understood, never which word replaced it./mcp addand/mcp removealso had the words written into their conditions (token === "project" || token === "user") beside a map that already listed them, which had drifted: it missedscopeandtransport, and a key added later would have kept its dashed refusal and silently lost its plain one. Every one of them now reads the map, so the two spellings cannot disagree and a key added later covers its plain word without anyone remembering to. A word that never was an option still gets the short refusal, because giving a mistyped hostname a lecture about plain words is true and useless. /mcp add <name> project …on a client no longer reports a server it did not configure. The text/ACP handler kept the scope the TUI had already dropped, defaulted toproject, and wrote<cwd>/.veyyon/mcp.jsonthroughgetMCPConfigPath("project", …);loadAllMCPConfigsreads no project-level source, so the file it wrote is loaded by nothing and the client was toldAdded MCP server "x" (project).A success line for a write that configured nothing is the worst of the three outcomes available, because the operator stops looking. Every read and write that handler makes now resolves the profile'smcp.json— add, remove, list, enable, disable, test, resources and prompts — the success line names no scope because there is only one place to write, and/mcp listno longer shows a repo-declared server it could never have connected to. The suite that closed this for the TUI controller now drives both surfaces from one file: it had pinned the incident and not the class, which is why the same defect was still shipping one module over.- A word that means "empty the vault" is no longer filed as a credential.
/secret clear, and the same forwipe,purge,emptyandreset, fell through the command grammar toaddand stored the word itself as a secret value — so the one command an operator reaches for to get credentials OUT of the vault put one in, generated a name for it, and flippedsecrets.enabledon if the vault had been empty;/secret clear --allstored the literalclear --all. All five spellings are reserved together rather than only the one that was reported, because a grammar that reserves some emptying verbs and files the rest as values is the same defect with a different word in it. - The ask tool asks a card how wide it is instead of assembling the card's geometry itself. Its custom-input title has to be pre-wrapped to the content width of the medium
ModalShellthe editor draws it in — wrapping at the terminal width hands the card lines it wraps a second time and the option list comes out ragged — and it computed that width by importingMODAL_SIZING_MEDIUM,sizingForAreaandcomputeModalDimsand restating the composition. The layout owner now answers the one question,mediumModalContentWidth(cols, rows), so the sizing a medium card uses stays this module's decision and the tool holds no copy of it. Same wrapping width; a terminal too small for a card at all still falls back to the editor's own pad columns. - A hook field's card is as wide as the sentences it has to say. Every hook prompt is a medium ModalShell card at 60% of the terminal, and both the title and the hint chip are cut to that width: on a 100-column terminal the credential field asked for "Paste the secret value here. You can name it afte…" and promised "hidden as you type, stored encr…", losing the two statements that stop an operator answering a masked field with the secret's NAME. The card's width floor now rises to fit its own title row and its own hint row, each priced by the layout owner rather than by a second copy of the border arithmetic, so a card whose text already fits keeps the shared proportions and a terminal too narrow for the sentence still clamps to the screen instead of overflowing it.
- A turn that ends on a short answer no longer paints a screen-sized band of blank rows over the conversation. The home anchor routes viewport slack above the transcript so a young conversation hugs the composer, and it measures that slack from the composed frame — which is only ever empty room because the frame is never shorter than the window. A virtualized transcript broke that: it hands committed rows to native scrollback and dropped every one of them, so a long session measured as a short one and the anchor wrote up to 23 blank rows over 82 rows of live history, leaving a stray fence and rule floating above the HUD. The transcript now keeps a viewport's worth of committed rows in the frame, so the measurement is honest and the engine has history to re-show instead of blanks. Measured over a 24-turn session at 24 and 40 rows tall, virtualized and not: a 23-row band becomes 1 row, the conversation is on screen, the composer is on the bottom row, and the repaint count is unchanged. Guarding the routing instead — route nothing once anything has scrolled — was measured on the same session and fixes nothing: the band simply moves below the composer and strands it 18 rows off the bottom edge.
clampLowfrom@veyyon/utilsnow does the block separator's width clamp, and the subagent settings reader uses the sharedisRecord. Both were inline copies the shared owners exist to prevent; a non-finite width or a stored array now falls to the defined bound instead of through the guard, which no shipped path can reach today.- The light that crosses an opening card travels its whole length instead of dying the instant the card stops growing. The sweep runs on a 520ms curve on purpose, twice the 260ms unfold, so the card is in place while the highlight is still moving across it — but the driver reported a finished sweep as soon as the unfold settled, which cut every one of them in half. Measured on a real terminal: the card arrived at t=19.550 and every pixel was static from t=19.800, 250ms of a 520ms light. The light now ends when the card is dismissed or dismounted, which is what already cancels it; a card whose unfold finished before anything read the sweep is still lit flat, since starting a fresh 520ms animation on a card that has been sitting still is a flash, not a reveal.
- The screen no longer flickers about ten times a second during a live turn. The composer footline's context gauge rode a spring on the shared 60 Hz motion clock, and a streaming turn revises the token estimate continuously, so the spring was re-targeted on nearly every frame and never settled: the host was asked for a repaint at roughly 60/s for the whole turn (measured 595 requests in 10 seconds), the engine's adaptive throttle turned that into a repaint about every 100 ms, and on a long transcript each repaint erased native scrollback and replayed the transcript, which is the flash. The gauge prints its reading again, and the two travelling viewports (the fullscreen agent transcript viewer, the plan-review body) land on their offset. Hover-band fades and the suggestion-popup grow are unaffected: they are gesture-scoped and settle in a few hundred milliseconds.
- A fading band and an unfolding card resolve out of the ground that is on screen, instead of the one the theme declares. Titanium declares a black page background and
tui.paintGrounddefaults toauto, which refuses to paint black onto a grey terminal — so the row sat on the operator's own grey while every mix travelled out of black, and each band flashed DARKER than both the page and the band on its way in and again on its way out. Measured off a real xterm at 60fps: a leaving row read#090401between a#1c1f26page and a#231310band. The ground now comes from one owner and in one order — the ground this process painted, else the one the terminal reported over OSC 11, else the theme's declared ground for a terminal that answered neither — and the paint decision records what it painted in the same call that paints it, so a policy that declines to paint can no longer leave the animations mixing out of a colour nothing put on screen. A theme whose ground the terminal was actually painted with is unaffected, byte for byte. - The context gauge, the
/contextpanel and the compaction trigger count a@filemention and a$python cell. Both roles reach the provider — a mention as adevelopermessage wrapping every file body it read, a cell as a user message wrapping its code and its output — and both were estimated at zero tokens, so a session that mentioned a file reported a fraction of the prompt it had just sent. Measured against a 32768-token endpoint: the footline read61% leftwhile the request in flight was 40459 tokens, and the endpoint refused it. Every role the outbound converter handles is now counted, and the suite derives what each role must cost from what that converter actually puts on the wire, so a role added later cannot be free. - The system prompt no longer tells the agent to commit often. That rule was arbitrary and could contradict a project's own commit policy; commit cadence now comes from the operator or the project's
AGENTS.md. - An install interrupted between placing the binary and recording it is repairable. The installers wrote the binary first and its ownership receipt second, so a kill, a lid closing, or a single antivirus sharing violation in that window left a real release binary at the target path with a receipt still describing the binary it replaced — and from then on install refused it ("refusing to replace ... because it has changed since this installer wrote it"), uninstall left it, and the only remedy was deleting a ~150MB executable by hand. A provisional receipt naming the incoming bytes is now written BEFORE the swap and retired after it, so at every instant the file on disk is described by one of the two records. Installing the same release over a byte-identical binary leaves the file untouched and only rewrites the receipt, which is also what stops a same-version reinstall from touching a running image on Windows.
veyyon updatewrites the same provisional record, and reuses the hash it took of the staged download instead of re-reading the installed file. install.sh --uninstallandinstall.ps1 -Uninstallreclaim the files an update attempt leaves under the names the updater actually writes. Each attempt names its staging and its rollback copy after that attempt's UUID (veyyon.<uuid>.new,veyyon.<uuid>.bak) so two concurrent updates cannot truncate each other's download, while both uninstall sweeps still matched only the fixed and dot-numeric names of two releases earlier. Neither shape on disk matched, so uninstall reported "the install directory is left empty" over a directory holding a full copy of the binary — ~150MB per orphaned attempt. All three shapes are recognized now, by one predicate per platform that agrees with the updater's own; a backup saved by hand under a name of its own is still left alone.install.sh --uninstallexits 0 when it succeeds. It ended on a conditional PATH-reload hint, whose false branch was the last command in the script, so a successful uninstall on any machine with no PATH line to take back exited 1 andinstall.sh --uninstall && ...read it as a failure.veyyon updatereclaims stale backups on every attempt instead of only after a successful one. The sweep ran at the end of the swap, so a machine whose updates kept failing — the machine most likely to be holding orphaned ~150MB copies — never reclaimed any of them. It now runs at the start of the update's locked section, where the current attempt's own backup does not exist yet.- The installer's refusal says which record it consulted and what to do next, and distinguishes a file that CHANGED from one that could not be read at all. An unreadable binary is an ownership question with no answer, not evidence of tampering, and reporting it as "it has changed" sent users looking for a file nobody had modified. On Windows the receipt's hash is also retried before giving up, because the file it reads is a ~150MB executable that was just renamed.
veyyon updatesays what it did when no theme has been loaded. The success line and "Already up to date" readtheme.status.successoff a binding that holdsundefineduntilinitTheme()assigns it, so a caller that drives the update flow without a theme — an SDK embedder, or a test child — died withCannot read properties of undefined. The success line was the costly one: it runs after the binary is replaced, verified and the backup reclaimed, so the throw reported a finished update as a failed one. Both lines fall back to the✓every built-in theme resolves that symbol to.- An effort picker offers only levels the endpoint declares. A session on Kimi K3 was offered
minimal, which no K3 row in the catalog declares:moonshotai/kimi-k3declareslow, high, maxandcloudflare-ai-gateway/moonshotai/kimi-k3declaresmaxalone, so the pick stored a value every K3 endpoint then clamped away. Any surface that failed to resolve a model fell through to the configuration vocabulary — the set of spellings a config file accepts, not a claim that an endpoint accepts them — and the same fallback would have read a ladder out of an id likecursor-grok-4.6-medium, whose id IS its effort and whose row exposes no effort control at all. No model now means no levels. The two rows that have no model and never will, Subagent Effort with no chain set and Default Effort's any-model row, offer the union of what this session's catalog declares, so every level on them is addressable on a model you can select; a chain naming a model this session cannot resolve says which pattern it cannot read instead of listing a ladder; andauto's own row description names the levels in scope rather than the vocabulary'slow–xhigh, since a description is as visible as a label. - The tiny-model download block sits on the transcript's left rail with no rule. It was the last bordered band in the transcript: a full-width rule above and below two short rows, each padded edge to edge from column zero, so a background download of a title model was the loudest thing on screen and sat two columns left of every other block. It now prints what is downloading and how far it has got, at the shared inset.
- The composer chips answer a click in a session that never scrolls.
escape interrupt,ctrl+b backgroundandalt+up dequeuewere click targets whose clicks the terminal was never asked to report: the engine took the mouse for scroll isolation alone, so until the transcript grew past the viewport the chips were inert text, which is every fresh session and every short one. The bar now declares its targets to the engine while a chip is on screen, and stops declaring them the moment the row goes blank, so nothing is taken from the terminal while there is nothing to click. - Every transcript divider is a short mark on the transcript's left rail instead of a rule across the viewport. The compaction, handoff and branch dividers centered their label inside a rule padded out to the full width, which put a second full-bleed horizontal on a transcript that already carries one left rail and made a compaction point read as a page break. They now draw the same
────────── compacted · ctrl+omark the cache-miss divider already used, and all four start at the composer gutter every other block starts at rather than at column zero, which is where they had to sit while they spanned the screen. Both shapes come from one function, so a new divider cannot reintroduce the old one. - The setup wizard's footer keys are chips, and the three the wizard itself acts on answer the pointer. Onboarding drew its keys as one dim line of plain text and handled no click on it, so the first screen a new user sees was the one screen where
esc leave setupcould be read and not pressed.← back,→ skip stepandesc leave setup(orctrl+c leave setupwhen a scene claims Esc for a sub-state of its own) are click targets that do exactly what their keys do, hover lights the chip under the pointer, and a scene's own hints stay inert because the wizard cannot press a scene's key on its behalf. The strip is laid out by the same packer every card footer uses, so a wizard row wraps like a card row and the exit is never left alone on a row of its own. - The ask dialog's option rows answer the pointer. Mouse events stopped at the footer chips, so hovering an option did nothing and clicking one did nothing. Hover now bands the whole option row (label and description lines), a click does exactly what Enter does on that row — answers a single-select question, toggles a multi-select one, opens the inline input on Other — and a wheel notch moves the cursor like an arrow key. The submit tab's wheel scrolls its summary.
- The
/btwand/omfgtranscript blocks sit on the transcript's left rail. Each drew a full-width rule above and below its four short lines and indented its content one column, so the loudest thing on screen was chrome and it sat off the rail every other block follows. The rules are gone, the content is at the shared inset, and each block opens with the command that made it. - The blocks a command prints sit on the transcript's left rail.
/hotkeys,/tools,/changelog,/context,/memory view, the MCP and SSH command replies, and the debug system-info and terminal-state panels each drew a full-width rule above and below their content at column zero. They now print at the shared inset with no rule. - The pinned error banner and the debug protocol panel no longer draw a rule above and below themselves. The banner sits in the composer zone, which has no box, and the panel is a transcript block; both now print at the shared inset, with the error colour and the title doing the work the rules were doing.
- The
/treesession picker is a floating card and answers the pointer. It was the last list picker that swapped the composer out for a bare bordered stack, so it had no card to close, no close glyph, and no mouse targets at all while advertising "up/down move, enter jump". It now opens as a fullscreen ModalShell overlay with the house footer chips: hover bands the entry under the pointer, a click jumps to that entry exactly as Enter does, a wheel notch steps the selection, and the close glyph, the close chip, or a click outside the card dismisses it. The viewport is sized from the card's own chrome plan instead of half the terminal height, so a short terminal no longer paints entries the card then truncates. While the label editor owns the card, close abandons the edit and leaves the picker up. - The
/mcp addwizard is a floating card and answers the pointer. It painted a bordered stack into the composer slot and spelled its keys out as bracketed hint lines inside the body, one line per step, with no mouse handling: an option could not be clicked, the wheel was dead, and there was no close glyph. It now opens as a fullscreen ModalShell overlay with house footer chips that name the keys the step in front of you actually takes —enter continueon a text field,navigateplusenter selecton a list, andesc cancelon the first step againstesc backon every later one. Hover bands the option under the pointer, a click takes that option exactly as Enter does, a wheel notch steps the selection, and the close glyph, the close chip, or a click outside cancels the whole wizard rather than stepping back one screen. A pointer event over a text field is inert, so a stray wheel notch no longer discards a half-typed server name. - The model picker's list answers the pointer. The overlay consumed every mouse event at the chrome hit-test, so hovering a model row changed nothing and clicking one did nothing. Motion, click, and wheel now forward into the browser: hover bands the row, a click selects (click again activates, the settings idiom), and the wheel pans the window.
- Assistant answers no longer render plain prose at the terminal's default foreground. The answer's markdown carried no default text style, so paragraphs without bold, code, or links fell back to whatever the terminal's default is (gray on many setups) while the thinking block beside them was themed; a sparse-markup answer — the shape some models produce — read as one unstyled gray slab. Answer prose now carries the theme's text color everywhere.
- The six selector overlays answer the pointer at the row level. History search, the reset-usage picker, the branch-from-message picker,
/move, the copy selector, and the session picker each parsed mouse input and spent it all on the footer chips: hovering a result row changed nothing, clicking a row did nothing, and the wheel was dead. Hover now bands the row under the cursor, a click selects and confirms the row like Enter (the reset picker's two-press arm-then-confirm survives as two clicks), and a wheel notch steps the selection like an arrow key. - The login screen is a ModalShell card and answers the pointer. It was a DynamicBorder sandwich that replaced the composer while a provider flow ran, with a hand-built hint line for a footer and no mouse at all. It now opens as a fullscreen overlay card with house chips, so the
[x]glyph, a click outside the card, and the cancel chip each do what Esc does, and the optional-name question still skips rather than undoing a login that already landed. The authorize URL is wrapped rather than clipped, so a long OAuth URL keeps its query parameters. - The fullscreen transcript drill-in is a ModalShell card and answers the pointer. The Agent Control Center's read-only viewer painted a DynamicBorder sandwich with a hand-built hint line (
Enter:send Esc:close ctrl+o:expand …), and the wheel was the only pointer gesture it understood. It is a card now with house chips, so the[x]glyph, a click outside the card, and the close chip each do what Esc does, the expand chip toggles the same expansion its keybinding toggles, and the wheel still scrolls the body. The scroll viewport is sized from the shell's own body budget instead of a restated chrome count, so the editor and stats rows cannot be truncated off the card. - A tool batch cut short no longer dumps its ledger into the transcript. The turn-level form of the partial-completion ledger is a synthetic user message addressed to the model — call ids, retry orders — and it rendered verbatim as a dimmed user bubble, several dense rows per interrupted batch. Both transcript surfaces now show a one-line marker instead: the batch was cut short, with the run counts. The ledger itself still reaches the model unchanged.
- Settings submenus answer the pointer. The host has always dispatched mouse events into an open submenu, but nine of the ten submenus had no route for them, so moving the cursor over a submenu row changed nothing and clicking a row did nothing despite the footer advertising "click pick". A shared base now carries every list-backed submenu: hover paints the same band the arrow keys produce and a click selects the row under the cursor. The settings suites drive each of the ten submenus with SGR motion and click bytes and assert both.
- Pointer motion over a ModalShell body row no longer dies at the chrome hit-test.
hitTestModalChromereportshover-shortcutwith a null id whenever the cursor is anywhere inside the modal, which every host read as "a shortcut event, consume it", so body motion never reached the row-hover branch below. Hosts now consume only an actual chip hit and let inert motion fall through; the settings selector, model picker, session and user-message selectors, ask dialog, agent dashboard, and the rest of the sixteen ModalShell hosts all share the one helper. compaction.modelFallbackStrategy: any-modelno longer stakes the session on the single widest-window model. The tier now walks every authenticated candidate widest-first, so a dead credential on the widest row falls through to the next usable one instead of failing compaction under the strategy that exists to never fail. Three tests that hard-coded bundled model ids (google-antigravity/gemini-3-pro) were decoupled from the bundle so a catalog regeneration no longer breaks them, and the subagent settings migration sweep classifiessubagent.modelByDepth.- The transcript no longer blanks itself mid-stream.
TranscriptContainersplices out the rows the engine reports committed to native scrollback, which held the composed frame near one screen but left the engine's commit index pointing at the pre-splice coordinates; the next frame read the shift as a committed-prefix divergence and, withtui.scrollbackRebuildon (the default), erased native scrollback and replayed a frame whose history the container had already dropped. Fourteen turns at a twelve-row viewport lost the first seven turns and twenty of twenty-nine rows across ten erases; the same run now takes one full paint, no erase, and keeps every row. Faster output made it worse, because output rate is what drives compaction. Multiplexer panes were never affected: the rebuild is gated off there. - A reasoning trace that opens a code fence no longer reads as an answer that was cut off. Prose-only thinking display elides fenced code, and it elided it into the end of the preceding sentence as a bare
..., so a turn that spent minutes writing a document inside its reasoning showedAcceptance criteria:, then1..., and then nothing more until the turn ended. The marker now names how many lines it is hiding, and the count grows with every line the model streams into an open fence, so the block keeps moving while the fence is open and a finished block states how much of itself is hidden. A fence the model never closes, which is what nested fences of equal length produce, gets the same marker instead of ending the block on a sentence that looks truncated. - The hook selector is a floating card and answers the pointer. It is the surface behind every
askquestion, every extensionui.select, the large-paste prompt, the MCP registry list and the TTSR save prompt, and it was a bare list in a DynamicBorder with the keys spelled out as a hint line under it: no close glyph, no chip, and no mouse handling, so hovering an option did nothing and clicking one did nothing. It now opens as a fullscreen ModalShell overlay with the house list chips (up/down navigate,enter select,esc/ctrl+c close), except where the caller named its own keys — an ask question that toggles says so — in which case those become the chips verbatim. Hover bands the option under the pointer, a click takes that option exactly as Enter does (including a click on its description row, while a disabled row stays inert), a wheel notch steps the cursor, and the close glyph, the close chip, or a click outside the card cancels. The delete confirmation inside the session picker renders the same component embedded, so two cards never nest: it draws no frame of its own and the picker carries the pointer into it, where the chips swap to that dialog's keys and Yes/No answer a click. Theoutlineoption is gone from the selector and from the extension select-dialog API, since a card is that box. - The hook input and the hook editor are floating cards and answer the pointer. They are the surfaces behind
ui.inputandui.editor, every credential question and the ask tool's custom answer, and each was a stack between two rules with its keys written into a dim line: no close glyph, no chip, and no mouse handling. Both open as fullscreen ModalShell overlays now, with chips that read the live binding rather than a written-out chord — the editor's submit chip names both follow-up chords (ctrl+qandctrl+enter), which the old hint line omitted even though both have always submitted — and the field's own hint leads the input's chips. A click on the submit chip does exactly what Enter does, and the[x]glyph, a click outside the card, or the cancel chip does what Esc does. A masked field stays masked on screen while its submit chip sends the real value. The editor keeps an embedded presentation for the advisor's instructions pane, where the host owns the card: there it draws no frame of its own and keeps the dim key line. The ask tool's custom answer opens in its own overlay with the question's card hidden beneath it, so two cards never stack. - The Settings → Plugins tab answers the pointer, and the card's footer names the keys of the view in front of you. It was the last surface with no chrome at all: each of its views — the plugin list, the npm detail, the marketplace detail, a config sub-pane — printed its keys as a dim inline line ("Enter to configure · Esc to go back", five copies of the idiom), while the card's own footer went on advertising the generic "enter change" and the pane ignored the mouse entirely, because the host routed pointer events only into a settings list this tab does not have. Hover now bands the plugin row under the cursor, a click opens that plugin exactly as Enter does, a wheel notch steps the selection, a click on the value column toggles a plugin or opens its config picker, and the "esc back" chip walks one view up the tab's own stack. The five dim hint lines are gone. A SettingsList pane now has one pointer route (
routeSettingsListPointer) shared by the settings overlay and this tab, rather than a second spelling of click semantics per screen. - Devin and Cursor are usable again with secrets enabled. Every chat request to either provider failed outright with "the provider request contains a non-JSON value/object; confidentiality transform failed" — not a degraded response, no response at all, and a message naming the transform rather than the cause. Both providers speak protobuf and handed the secret redactor the live message; the redactor walks the payload rewriting every string and refuses any value JSON cannot express, which a protobuf message never is (a uint64 field is a bigint, a bytes field is a
Uint8Array, and Cursor's request carries 26 such fields). Both now hand over canonical proto3 JSON and parse the reply back before it reaches the wire. A session with no secrets configured never installed the hook and was never affected, which is why this survived: it is invisible until the moment secrets are turned on. - A
findwhose output pipe closes early no longer panics.find … | head, or any consumer that stops reading, closed the pipe underneath a write the code unwrapped, so an ordinary shell idiom aborted the process with a Rust panic instead of the silent exit every other tool in the pipeline performs. Writes now carry their error to the caller, across the printer, the-printfformatter,-delete,-execand the permission matcher rather than at the one site the crash was first seen.
Removed
PluginSelectorComponentis deleted. Marketplace plugins were removed earlier and its one entry point has been a stub that says so ever since, leaving the component with no callers, no tests, and a DynamicBorder chrome nobody can reach.- The built-in
commit-driftrule is gone. It nagged the agent to commit after a self-chosen number of uncommitted files, which is an opinion about how a repository should be worked, and it collided with operators' own standing git rules whenever the two disagreed. Rule body, tracker, session wiring, thecommit.nudgeAfterFilessetting, and the rule's dedicated suites go with it; a persistedcommit.nudgeAfterFileskey loads harmlessly (unknown keys are preserved verbatim, not errors).
@veyyon/natives
Fixed
findno longer crashes the worker thread when its output pipe closes early. Every write on both offind's print paths —-print/-print0and-printf— called.unwrap(), so an ordinary truncated pipeline likefind . | head -1arrived as a BrokenPipe panic on atokio-rt-workerrather than as the write error the matcher already knew how to report. Writes now end the entry quietly, and the error reports that used to be issued alongside them can no longer panic either, since stderr is a pipe too and both ends close together.
@veyyon/tui
Added
- A left click on a composer autocomplete suggestion accepts it, through the same apply path as Tab. Container routes a pointer event to the child under the pointed row, so a click reaches a component mounted inside the pinned footer.
- A left click on the editor's text places the caret on the character under the pointer. The row and column are read from the last paint, so a prompt gutter, a wrapped line, a scrolled draft and the framed variant all resolve the same way; a click past the end of a row lands at the end of that row, and a click on the text while a suggestion popup is open dismisses the popup, since its prefix no longer describes where the caret is. Motion, release and wheel reports are still ignored.
- An overlay can play itself out before it leaves. A component implementing
beginOverlayExit(requestRender, done)is kept in the overlay stack and painted until it says its last frame is drawn;canAnimateOverlayExitreports whether a component offers one. The exit is PAINT ONLY:hide()releases focus and drops the card out ofhasOverlay()at once, so a dismissed card can never answer a keystroke, and a component that declines the exit or does not implement it is removed exactly as before. - One animation clock for the whole terminal, exported as
motionClock, with the product's curve table (MOTION), an interruptible mass-spring-damper, and the frame transforms an animation drives (blendHex,fadeLineTowards,revealedRows). A surface names a preset instead of owning a timer and inventing a duration, so two surfaces animating at once share a frame instead of beating against each other, a finished animation is dropped and the ticker stops, a retargeted spring keeps its velocity instead of restarting from rest, and a frame gap longer than 100ms advances one frame instead of replaying the stall in one lurch.enabled: falselands the value on its target and never registers, which is what a terminal with transitions off shows. - The pointer band fades in and out instead of switching.
HoverFadeis a cross-fade keyed by a list's own row identity, running on the shared clock: the row the pointer arrives at travels up while the row it left travels down, so a gesture never shows a frame with no band on it, a row left and re-entered resumes from where it got to, and a settled fade-out is forgotten rather than accumulating one entry per row the pointer ever crossed.SelectList.setHoverMotionandSettingsList.setHoverMotiontake the host's repaint, since the frames between two mouse reports have no input to hang off;disposeHoverMotiongives the clock back. A list that was never given motion, or given it withenabled: false, paints the switched band byte for byte. The band theme hook now takes a strength, so a theme decides what 0.4 looks like while the list decides when. - A block appended under a live surface can grow into place instead of cutting in.
BlockRevealclips a rendered block to the rows it has reached and resolves each visible row out of the ground behind it, on the shared clock:arm()when the block appears,disarm()when it goes,apply(rows)on every frame. It is armed by APPEARANCE rather than by render, so a block whose contents are rebuilt on every keystroke grows once per appearance instead of replaying the animation per character. The composer's suggestion popup rides it throughEditor.setAutocompleteMotion/disposeAutocompleteMotion: rows arrive over the enter curve, a click during the grow lands on the suggestion it looks like it landed on (a row that has not arrived is not in the frame to be clicked), a dismissal is instant because the prefix those rows describe no longer says where the caret is, and an editor with no motion lent paints the popup exactly as before, byte for byte. TabBarfades its hover band like every other pointer surface.setHoverMotionlends the bar a repaint so the band under the pointer cross-fades on the shared clock, keyed by tab ID rather than by position — a bar re-tabbed under the pointer (settings search rebuilds its tab set on every keystroke) keeps the band on the tab, not on the slot. Both render paths take their style from one owner, so the horizontal bar and the vertical sidebar cannot disagree;hoverTabnow receives a strength alongside the text; and a bar with no motion lent, or lent it withenabled: false, paints the switched band byte for byte.disposeHoverMotiongives the clock back.- A gauge can walk to its new value instead of teleporting to it.
SettleValueruns one number on the shared clock under thesettlespring: the first value it is given lands (a gauge sweeping up from zero on its first paint animates the session starting, which is not a change anyone made), every later value travels, and a value retargeted mid-flight keeps its velocity, so a reading revised every few frames is one continuous travel rather than a stutter.epsilonrefuses to spend a frame on a change too small for the surface to show,enabled: falselands every target immediately and registers nothing,reset()makes the next value a first sighting again for a host that swapped what it is measuring, anddispose()gives the clock back.MOTION.settlehad been in the curve table since the clock landed with nothing using it. - Every horizontal bar the product draws has one owner and eight steps per column.
subCellBar(ratio, width, options?)quantises the fill in eighths across the whole bar and derives the cells from that count, so a ten-column bar has eighty-one reachable states instead of eleven: a value rising by 3% moves it, and a value crossing a column shows the crossing through▏▎▍▌▋▊▉rather than jumping a whole cell. A ratio landing on a column emits no partial glyph (never█▏where██is meant) and a ratio just under one keeps its▉instead of rounding into a column it has not reached. The row is always exactlywidthcolumns, at every eighth of every column, because hit-testing and layout elsewhere are computed from positions in the row. Colour is the caller's: the function returns glyphs, and the callers split the string at its first track cell into a fill tone and a track tone. The glyph ramp is an option (SUB_CELL_BAR_RAMPby default, withEIGHTH_BLOCKSandbarGlyphEighthsexported alongside it) so a terminal whose font has no partial blocks is sent a ramp with none, which degrades to whole cells and still rounds to the nearest one rather than truncating. - A rendered line's background can be repainted column by column, which is what anything moving ACROSS a row needs.
paintLineBackground(line, width, painter, window?)walks a line once, tracks the truecolor background in effect, and asks a painter what each column should be instead;paintBlockBackgrounddoes the same for every row of a block. A new escape is written only where the answer changes, so a caller quantising its gradient into ten steps pays ten sequences rather than one per cell — these run on every frame of an animation over every row of a card. Text, foregrounds and attributes survive byte for byte, an indexed background (48;5;n) is reported as unknown and left alone rather than guessed at from a palette the terminal may not be using, and aColumnWindowbounds both the painter and the padding, so a treatment on a card centred in a wider area cannot reach the page beside it. A component's own39mdoes not count as closing the pass's paint: a bare foreground reset leaves the background alone, and reading it as a close left the paint running off the end of the window it owned. - A block can be painted as a lit SURFACE rather than as line art.
fillSurfacefills a block with an elevation gradient — a few percent above the ground at the top row, a shade below it at the bottom — and leaves alone every cell the component gave its own background, since a selection band, a chip, a swatch or a scrollbar thumb is the component saying that cell is not the surface.sweepSurfacecrosses the block with one specular highlight on a raised-cosine falloff, quantised to sixteen steps and skewed so it travels as a diagonal, lifting whatever background each cell already has instead of replacing it: the light crosses a band without erasing it.cascadeStrength(row, rows, progress)gives each row its own ramp inside one block-wide progress, compressed so the last row still lands at 1 — the difference between an animation with as many frames as the block has rows and one with a frame for every frame of the clock. Both take a column window, and everything is a pure transform over lines a component already rendered, so no component learns it is being lit and every frame is byte-assertable. MOTION.sweepis the 520ms curve a specular highlight crosses a surface on, andMOTION.enteris 260ms rather than 220ms: a card's rows now arrive on staggered ramps inside that window, so the same duration reads as shorter than it measures.- A surface can be a STACK of materials instead of one wash.
SurfaceSpecnow names the elevation at the top row and the elevation at the bottom row (both off the ground, so a plate's foot cannot sink below the page the way a fall toward black made it), plusbands— runs of rows at their own flat elevation, which is what a header tray and a recessed footer tray are.liftHex(ground, amount)moves a colour off the ground in the direction that is actually visible on it, chosen by the same BT.601 luminance the terminal uses to call itself light or dark, so a card on a paper-white terminal darkens instead of lifting toward an invisible white; the specular sweep follows the same direction.surfaceRowColorresolves one row's material, bands included.
Fixed
- A terminal mode is never set before the terminal says it implements it. Veyyon armed kitty-style enhanced paste by writing
CSI ? 5522 hat every startup, on every host. No shipping emulator implements that DEC private mode, and kitty — the terminal the ancillary spec was written for — answers both the set and the matching reset with[PARSE ERROR] Unsupported screen mode: 5522 (private)in its own log, so the feature never armed anywhere and each session wrote two parse errors into the user's log for a capability nobody had. The mode now rides the existing DECRQM probe: it is asked about at startup like 2026, 2048 and 2031, the set is written only on a positive report, and the reset on shutdown only by a terminal that actually armed it — including through the blind emergency-restore path, which keeps a module-level record for exactly this reason. The three facts that decide it (the app asked, the terminal confirmed, it is not already armed) are checked in one place rather than at each call site. kitty's OSC 5522 clipboard protocol is a different mechanism and is untouched: it needs no mode set at all. - A frame that shrinks below the viewport now has history to fill the screen with, instead of a band of blank rows where the conversation was. The engine re-shows committed rows when a frame gets shorter than the window ("duplication, never loss"), and it can only re-show rows the frame still holds — but a virtualized root compacts committed rows out of the frame entirely, so a session that had already scrolled had nothing left to re-show and painted seven rows into a thirty-row viewport.
NativeScrollbackCompactiongainedsetNativeScrollbackRetainRows, fed a viewport's worth before every render, so a screen's worth of already-assembled history stays in the frame while compaction still does its job for everything above it. Measured on a 24-turn session whose turn ends on a two-row tail: a 23-row blank band becomes 1 row, at both 24 and 40 rows tall, with no change to the erase count. - The motion clock's frame delta, the surface lift and cascade ramps, and the channel and colour mixes call
clamp/clamp01from@veyyon/utilsinstead of each writing the clamp out by hand. Behaviour is identical for every finite input; aNaNframe delta or strength now lands on the low bound rather than propagating. - Chrome no longer enters native scrollback, so a tall todo list or subagent HUD no longer strobes the screen and leaves a blank void where the session was. Everything above the window top was committed as history; once the pinned chrome outgrew the viewport that boundary landed inside the chrome, whose rows are rewritten every frame, so the very next frame's prefix audit found them changed and repaired it the only way it can — erase native scrollback and replay, every frame of a live turn. Commits are now capped at the end of the last root child that claims the native-scrollback contract (the transcript); anything mounted after it is chrome, painted and never committed, and a frame that cannot commit takes a bounded in-place repaint instead of a destructive one. Measured in a real terminal at 100 columns, forty turns under a 22-row HUD: ED3 erases 9 → 0 at 30 rows, and 1 → 0 at 24, 20 and 16.
- A live turn no longer repaints the whole screen about ten times a second. Two surfaces put a spring on the shared 60 Hz clock and re-targeted it continuously while a turn streamed — the status line's context gauge, whose reading is revised on every token, and
ScrollView's travelling offset — so the clock never stopped and the host was asked for a frame on 99% of frames (measured: 595 render requests in 10 seconds). The engine's adaptive throttle collapsed that into a repaint roughly every 100 ms, and on a long transcript each one was classified as a destructive rebuild, which is the flicker. Both springs are gone:ScrollView.setScrollMotion/disposeScrollMotionandStatusLineComponent.watchContextGaugeno longer exist, and the viewport lands on its offset as it did before. Gesture-scoped motion (hover bands, the popup grow) stays, because it settles and stops. - A virtualized transcript that is not the first root child no longer turns every streaming frame into a whole-screen rebuild. When rows are dropped, the engine slides its commit coordinates onto the shortened frame, and that slide spliced the dropped rows off the FRONT of the recorded prefix — correct only when the dropping child starts at frame row 0. The shipped layout always mounts a filler above the transcript, and every HUD sits in that band too, so the prefix was left misaligned by exactly the header height, the next audit called that a committed-prefix divergence, and the repair erased native scrollback and replayed the whole transcript: 20 full redraws and 20 ED3 erases over 40 streaming frames, against 0 and 0 once the splice happens at the drop site's own offset. That is the tearing and the blank bands during a streaming answer.
- An animation that cannot reach rest can no longer hold the clock live forever, asking for a repaint sixty times a second for the life of the process. Fourteen reachable specs did exactly that — a non-finite target or
from, a non-finite duration, a zero or negative damping, a zero stiffness or mass, arestDeltaof zero, a stiffness past the integrator's stability limit, a retarget to NaN, and a damping small enough to outlive the session. The clock now refuses a curve that provably cannot settle (it lands on its target instead, exactly asenabled: falsedoes), ignores a non-finite target, and holds a 4-second settle deadline measured from the last retarget, so a host that keeps moving a value is never cut off mid-travel. EveryMOTIONpreset costs the same frames as before: hover 6, exit 8, expand 11, enter 14, move 28, settle 39. - Kept the transcript on screen when a virtualized root compacts its committed rows. A root can now report the rows it dropped, and the engine slides its commit index onto the new frame instead of reading the shift as a committed-prefix divergence, erasing native scrollback and replaying a frame the component had already emptied. A root that drops rows without reporting them is rehydrated before any destructive replay, so it costs an extra full repaint rather than the history.
- Held mouse button reporting while a pinned-footer component has a click target on screen. A footer target used to be reachable only while the frame overflowed the viewport, since scroll isolation was the sole reason the engine took the mouse, so every footer click in a short session was reported to nobody. A component declares its targets through the new optional
MouseRoutable.wantsPointer(), and the grab is scanned from the frame-segment ledger after each compose, so it is held exactly while a target exists and the terminal keeps native drag-select the rest of the time. Thealt-arrowsscroll transport and scroll isolation being off both still refuse the grab. - Import order in the package barrel and line wrapping in
paint-columnsfollow the formatter, with no behaviour change: the same exports resolve to the same modules and every painted byte is identical. The lint report that found them also flagged a dead constant in the chrome-scrollback sweep, which counted the composer's single row and ignored the two markers that matter more — so a leak committing the HUD band while sparing the prompt sat inside the ceiling unseen. The sweep now counts every chrome marker, at the peak the buffer held over the whole run rather than whatever survived the last erase, against a bound the run measures for itself: the buffer may not hold more copies of a chrome row than the screen ever showed at once. Healthy arms hold exactly what they showed; commit the band and a 32-row HUD holds 32 against 14 shown, which is now red.
What changed
298 commits since v1.0.49.
Breaking Changes
- feat(ai)!: the account the operator chose is the account that spends
- feat(slash-commands)!: no slash command takes an option
- feat(secrets)!: a /secret line leads with a command
Features
- feat(coding-agent): the todo board reads as a panel, and writes itself in
- feat(coding-agent): a running tool block moves its rail, and cools once it lands
- feat(slash-commands): wire /stats, which was a parser nobody called
- feat(secrets): the escape for a reserved word is a verb, not a shell dash
- feat(secrets): an expiry warning nobody asked for can be declined
- feat(secrets): emptying the vault is a command, not a credential
- feat(overlays): a card is a ladder of materials, measured on the terminal
- feat: every bar moves in eighths of a cell, from one owner
- feat(theme): the band under the cursor has a direction
- feat(overlays): a card is a lit surface, and the light crosses it as it opens
- feat(tui): paint a background per column, and a surface that can be lit
- feat(settings): a subagent's page owns what it runs, and recurses
- feat(tui): a scrolled viewport travels through the rows between
- feat(tui): walk the context gauge to its new reading
- feat(tui): the copy picker fades its band and every picker gives the clock back
- feat(tui): the ask dialog cross-fades its option band
- feat(tui): fade the settings category band under the pointer
- feat(tui): the hand-painted pickers cross-fade the pointer band
- feat(tui): the suggestion popup grows instead of cutting in
- feat(tui): the pointer band fades in and out instead of switching
- feat(tui): a dismissed card folds away before it leaves
- feat(tui): one animation clock for the whole terminal
- feat(tui): accept a composer suggestion on click
- feat(coding-agent): move the transcript drill-in onto ModalShell
- feat(coding-agent): move the login screen onto ModalShell
- feat(coding-agent): make the composer chips answer clicks
- feat(coding-agent): answer the pointer on ask-dialog option rows
- feat(coding-agent): forward pointer events into the model picker's browser
- feat(coding-agent): answer the pointer at the row level in the six selector overlays
- feat(coding-agent): route pointer events into settings submenus and modal bodies
- feat(coding-agent): pin subagent models per spawn depth with subagent.modelByDepth
- feat(coding-agent): remove the built-in commit-drift rule
Fixes
- fix(site): serve the recorded hero at its real size
- fix(ai): accept complete usage-terminated tool streams
- fix(tiny): a title keeps the tag its own message typed
- fix(tiny): one owner flattens a generated title
- fix(proof): no recording script names the machine it was written on
- fix(modes): a warning is a notice, not a ledger
- fix(modes): a finished todo board draws nothing in the HUD
- fix(status-line): a gauge never states a number it does not have
- fix(tui): a terminal mode is never set before the terminal confirms it
- fix(title): a control token is not a session name
- fix(secrets): the footer counts what the list can name
- fix(ai): a grant the provider refused is never what serves next
Release notes were shortened from 137,480 characters to fit GitHub's 125,000-character body limit. Read the complete package changelogs and full commit range.