Changelog

What's new.

Veyyon's own releases, newest first, with what's queued for the next one at the top. The /changelog command in the CLI opens this page, and launch can check for a newer version. The full history, including everything inherited from oh-my-pi before the fork, lives in the CHANGELOG on GitHub.

Unreleased

next release
Added
  • The models page on veyyon.dev lists every provider and model veyyon supports, read live from the bundled catalog and auto-refreshed from the repository; bun run site:build regenerates website/models-data.json from packages/catalog/src/models.json.
  • A bare interactive launch replays the previous launch's card from a cache before the CLI's import graph is evaluated, then adopts those rows and corrects only what changed. bun scripts/bench-startup.ts --runs 5 --bin packages/coding-agent/dist/vey reports the card's first byte at a 34-35ms median composed and a 15ms median replayed. The recording is discarded unless the terminal size, the environment this process received and the binary's path, size and modification time all still match, and it ages out after 24 hours; a launch whose composed card disagrees with the replayed rows drops the recording so the next launch records a fresh one.
  • bun scripts/bench-startup.ts gains a replay arm and keeps the first-frame recording inside its scratch directory. The recording resolves its path from os.homedir(), which Bun fixes at process start, so the bench's seeded HOME did not reach it and a run read and overwrote the operator's own cache.
  • VEYYON_REPLAY_DEBUG names a file the launch appends its replay decision to. A rejected recording is otherwise indistinguishable from a slow launch, and the logger does not exist yet at that point.
  • /rephrase asks for the reply on screen again in plainer prose, and refuses unless the conversation is resting on a finished reply.
  • /autoswarm opens a setup console for the goal, breadth, attempts and certification, then runs autoresearch with breadth: each iteration builds several candidate arms, rejects the ones that are empty, out of scope, unreadable or duplicates, has the survivors cross-review each other, and keeps at most one; /autoresearch is unchanged and still serial.
  • Autoresearch and autoswarm have handbook pages.
  • Machine-wide resource limits cap CPU, memory, disk writes and process count across every veyyon process at once, beside the existing per-session limits, in /settings under Resources; both scopes default to no limit.
  • The two resource-limit scopes share one definition of each cgroup control-file format, with no user-visible change: the duplicate the machine scope carried while unreleased could write a freeze quota for a very small CPU budget.
  • bun run test:cgroup-proof drives both resource-limit scopes against a real kernel outside the test sandbox and reports each cap as held or not, refusing with a named reason on a host that cannot delegate cgroups rather than passing having proved nothing.
Changed
  • The hidden messages a session injects and the gates that fire them live in session/nudges.ts rather than as file-private literals in agent-session.ts, and the mid-run todo and prewalk gates key their tool tables through the TOOL registry so renaming a tool now fails to compile instead of silently disabling the nudge.
  • The subagent model resolver reads the shared-model switch once and builds the layers of whichever scope that selects, instead of gating each layer against a second reading of it, and the unread SUBAGENT_MODEL_SCOPE_VERSION constant is gone from the rejected-setting log record. No change to what any subagent runs.
  • The compaction policy vocabulary (check outcomes, the bar a pass is measured against, the truncation edge budget, the prune cache window and idle flush, and the recovery band) lives in session/compaction-policy.ts rather than interleaved with shutdown timeouts and credential backoffs in agent-session.ts, with no behavior change.
  • The thinking display's hidden-code fence marker is named hiddenCodeMarker, no longer colliding with the unrelated elisionMarker in @veyyon/utils/byte-truncate; the marker text is unchanged.
  • The read tool's URL routing, internal-URL routing, notebook reading, document conversion and structural summarizing each live in their own named method instead of inline branches of one 758-line execute, with no change to what any read returns.
  • The doc comments on the agent registry's conversation scope and address check, and on the subagent model and thinking-level resolvers, state the precedence and the boundary rule without the surrounding narrative; no behavior changes.
  • The queued-message predicates and the retry.fallbackChains selector vocabulary live in session/queued-message.ts and session/retry-fallback.ts instead of as file-private helpers in agent-session.ts; RestoredQueuedMessage is imported from the first of those.
  • The ACP permission gate's argument reading — which tools need consent, the prompt each call earns and the file locations it points at — lives in session/permission-intent.ts instead of as file-private helpers in agent-session.ts, which also drops that file's private copy of getStringProperty in favour of the @veyyon/utils owner. No change to what is gated or prompted.
  • The launch card paints about 8ms sooner: the bundled themes are embedded as text and parsed on the ask instead of building all 98 before the first frame, and the card path no longer evaluates node:assert/strict, node:crypto, node:inspector, node:child_process or node:zlib for calls it does not make.
  • The status row reads its truncation limits from tools/render-limits, a leaf that imports nothing, rather than tools/render-utils, which drops the tool renderers, path helpers and image resizing from the launch card's import graph; first-frame time is unchanged, because those modules only declare functions.
  • The home screen hero drops the recent-session row; /welcome still lists recent sessions.
  • The launch card paints the whole status row from config instead of a hand-written path and branch, so the profile, model, approval rung, branch and context gauge are on screen with the first frame; measured on a pty against the built binary, the row lands at 47-48ms rather than 1067-1083ms and the frame is editable at 49-50ms.
  • The status row's segment gathering and row fitting live in one module, modes/components/status-line/quiet-row.ts, which the live footline and the launch card both render through, so the two rows cannot carry different segments or order them differently.
  • A status-line segment reads a flat SessionFacts value block rather than the AgentSession itself, which is what lets the same segment table render before a session exists. No change to the mounted row.
  • bun scripts/bench-startup.ts renames its statusline arm to statusrow and times the status row being on screen; the old arm timed a segment only a session supplied and now reports nothing, because the card supplies the row.
  • bun scripts/bench-startup.ts times its statusrow and replay:statusrow arms against the approval rung on the row rather than the context gauge, which is the row's last segment and is shed on the bench's eighty-column pty: both arms reported no samples at all. They now read a 39ms median composed and 15ms replayed against the built binary.
  • The autoswarm setup console and the autoresearch experiment tool clamp their breadth and attempt counts through the shared clamp rather than local copies. No behavior change.
  • VEYYON_TIMING reports the window between process start and the launch card instead of hiding it: the tree now starts at the CLI entry and carries spans for the command load, the launch-card import, the prologue, settings, the theme and the paint, leaving only Bun's own start and the entry's static imports under (before instrumentation).
  • The launch card arrives in about half the time. The binary is now code-split, so the standalone loader links the CLI entry and the launch card instead of the bytecode of every subcommand, tool and agent-runtime module before the first statement runs, and whitespace and syntax minification are on. Measured warm on a pty, the card's first byte goes from 138-151ms to 57-72ms, the first keystroke echoes at 111ms instead of 188-207ms, and the binary is 231.7MB instead of 296.9MB. Function names are still kept, so a stack trace is unchanged.
  • The six modules on the launch path that reached for the @veyyon/utils barrel now import the subpath that owns what they use, so painting the card no longer evaluates the YAML parser, Handlebars and the prompt-variable layer that the barrel re-exports. The launch card's import graph drops from 311 modules to 268, its import span from 32.5ms to 20.2ms, and the card's first byte to 50-58ms.
  • The launch shell no longer evaluates a workspace barrel. The launch card, the editor and the status row reached @veyyon/tui, @veyyon/utils and @veyyon/agent-core for a handful of symbols, and each barrel is export * from over its whole package; every such edge now names the leaf that owns the symbol. Three modules were split along the same line: internal-urls/resolve-sync.ts takes the URL resolver out of the OSC 8 writer, modes/image-reference-markers.ts takes marker rendering out of the blob store, and modes/magic-keyword-notices.ts takes the three hidden notices out of keyword detection. Module evaluation for the three shell entries drops from 147ms to 63ms, the editor from 36ms to 3.6ms and the status row from 78ms to 6.5ms.
  • The status row reads its premium-request formatter from @veyyon/utils/format instead of @veyyon/stats/format, so painting the card no longer evaluates the stats package. No visible change to the row.
  • The status row's non-message token accounting lives in session/non-message-tokens.ts, so painting the launch card no longer evaluates the compaction layer or the tokenizer through session/context-usage.ts, and config/inline-tool-descriptors-mode.ts reads modelFamilyToken from @veyyon/catalog/identity/family instead of the identity barrel. No change to the counts the row reports.
  • The host capability probe and the environment it measures against moved out of the session budget module into session/cgroup-host.ts, and the capabilities a probe reports no longer carry the field it used to pick a cgroup parent. No behavior change.
  • The first-frame recording is written by startup/first-frame-recorder.ts through the shared atomicWriteFileSync instead of a hand-rolled temp-and-rename, so the cache file is created owner-readable only; the replay reader on the boot path still reaches node builtins only.
  • The launch card paints the working directory on the status row instead of leaving it blank until the session mounts. Measured on a pty, the row named the directory at 59-62ms with the card rather than at 1067-1083ms; the model, mode and context gauge still arrive with the session, to the right of it.
  • The status row's location is rendered by one owner, modes/components/status-line/location.ts, which the live row and the launch card both call, so the two cannot drift. shortenPath and sanitizeStatusText moved to their own modules, re-exported from tools/render-utils.ts and modes/shared.ts; defaultDisplayRoots and resolveDisplayRoots are now imported from the location module rather than from segments.ts.
  • The launch card paints the git branch on the status row beside the working directory. Measured on a pty, the branch appeared at 59-66ms with the card rather than at 1067-1083ms.
  • The status row's branch is rendered by one owner, modes/components/status-line/branch.ts, which the live row and the launch card both call, so the two cannot drift.
  • Which segments a status-line preset shows is resolved by one function, resolvePresetSegments, so the launch card honors the same preset and git.enabled rules the mounted row does. No behavior change to the mounted row.
  • The branch a repository is on can be read from .git alone through utils/git-head.ts, without running git; utils/git.ts composes that reader with the git symbolic-ref fallback a reftable repository needs, rather than keeping a second copy of the file parsing.
  • StatusLineComponent.watchBranch is watchGitState: one repaint request for every git read the row is painted from, rather than a name that described only the HEAD watcher. No user-visible change.
  • The model and effort a subagent runs are chosen on one of two exclusive scopes. Subagents → Same Model for All Subagents is off by default, and each agent's page under Roster decides; on, Shared Model and Shared Effort decide for every agent while the per-agent rows are hidden and keep what they hold for when the switch goes off. An agent naming neither runs the profile's default model role at medium effort, and subagent.modelByDepth decides nothing in either scope.
  • A subagent definition that names a tool nobody recognizes is reported with the file and the unknown names instead of loading an agent with no tools and a prompt pruned of everything.
  • User-authored subagents are discovered from ~/.veyyon/subagents/, shared across profiles, and enabled per profile.
  • The compaction loader names the engine on every pass, not only a remote one: "Compacting context... (local compaction)", "(openai remote compaction)", "(azure remote compaction)" or "(codex remote compaction)". A codex or azure server-side pass used to be announced as openai, and a local pass was not announced at all.
  • The model the advisor runs is asked in one place: Model → Advisor → Advisor Model, directly under Enable Advisor, hidden while the advisor is off. advisor is gone from the Roles table, which was a second surface for the same slot under a different name; the slot itself is unchanged, so @advisor and any existing modelRoles.advisor keep working.
  • The Advisor rows are contiguous in the Model tab: the Prewalk rows were declared between them.
  • The resolved-model snapshot a warm launch restores is verified against the bytes it was written from instead of being parsed and serialized back to check itself. The file is now a header line then the stage payload, and reading it costs 14.6ms instead of 27.6ms measured on a 9.2MB snapshot; a snapshot written by an earlier version misses and is rebuilt.
  • The launch card paints the real composer instead of a drawing of one. Typing at the card edits the editor the session goes on to use, so nothing has to be replayed into it at handover, and a submit typed before the session exists no longer discards the draft. Measured warm on a pty, a keystroke at the card echoes 3.2ms after the card's first byte.
  • The settings store holds no database handle. AgentStorage.forAgentDir is the one owner of the run's agent.db and opens it on first use, config/legacy-agent-db-settings.ts owns the first-run read of the pre-config.yml settings table, and the launch card no longer evaluates bun:sqlite or the SQLite credential store to read a setting. Measured warm on a pty, the card's first byte goes from 50.5ms to 42.4ms and a keystroke is echoed at 45.6ms instead of 53.6ms; a database that will not open now costs usage statistics with a logged reason rather than failing the launch.
  • The three hidden magic-keyword notices are in session/magic-keyword-notices.ts rather than under modes/, which was the one edge from the session into the UI directory with no drawing behind it. No behavior change.
  • The status row no longer carries the secrets segment. The secrets id is gone from every preset and from statusLine.segments, and a configuration naming it is rejected; /secret list states what a session has masked.
  • The roster states that an operator may write an agent, and names docs/features/subagents-authoring as the instructions.
  • The subagent authoring page states which frontmatter key spellings are read: thinkingLevel and thinking-level reach the same field, an underscore does not, and the bundled definitions use the dashed form.
  • The rewind and checkpoint entry readers and the side-channel reply bound live in session/rewind-checkpoint.ts and session/ephemeral-reply.ts instead of as file-private helpers in agent-session.ts; the doc comment describing the Anthropic request metadata payload is attached to the function it describes rather than to the tool-order check below it. No behavior change.
  • session/content-text.ts is gone; the session modules that flattened content blocks call the @veyyon/utils owner, which now carries the options that copy held. Two implementations of the same flattening each documented themselves as the only one.
  • The MCP command controller races its OAuth login and its connection wait through withTimeout and raceWithTimeout in @veyyon/utils rather than a file-private copy, and the protocol probe's truecolor bar converts hue through hsvToRgb in @veyyon/utils/color rather than a second implementation of the same conversion. Both emit what they emitted before, byte for byte.
Fixed
  • A session on a model whose thinking blocks are bound to their conversation prefix, such as Claude 5.1 and later, leaves an already-sent tool result in place instead of superseding it mid-history, which invalidated every thinking block recorded after it and drew a 400 on the next turn.
  • The installer refuses to replace a binary whose only ownership record is a pre-identity v1 receipt, instead of moving it aside. That receipt vouches for the path alone, so a user who deleted the installed binary and put their own file at the name left exactly one behind, and it was being read as permission to displace their file.
  • veyyon agents unpack writes to ~/.veyyon/subagents, the directory subagent discovery reads, instead of the profile's agent/agents dir, where an unpacked definition was reported as written and then never loaded.
  • veyyon config set and veyyon config reset no longer exit 1 with an ENOENT on a profile that has never been launched: the save now creates the profile's agent directory before it takes the config file's lock, which refused a parent directory that did not exist yet.
  • A /btw or /omfg reply long enough to be truncated no longer ends in a replacement character when the cut lands inside an emoji: the trim counts UTF-8 bytes but removes UTF-16 code units, so it could stop on half a surrogate pair.
  • A malformed content block no longer opens a blank line in a session listing or in rendered history. A text block whose text is absent or is not a string contributed an empty part, so the join placed a separator beside it, while a thinking or tool block carrying just as little text was skipped outright.
  • The composer hairline and the transcript rules no longer change shade about half a second after the launch card appears: the card mixes them out of the background this terminal reported on the previous launch instead of a static token, and tui.paintGround on auto decides the paint from that same recorded background rather than repainting the whole window when the terminal answers. The background is recorded per terminal in cache/launch-facts.json, whose shape version is now 4.
  • The launch card's context gauge no longer jumps when the session mounts in a project it has never measured: the reading filed under the model is now that reading with the measuring project's context files subtracted, so a card seeded in a heavy repository no longer states 77% left where the session settles at 88%. A project's own reading is unchanged and still wins where it exists.
  • The session mount no longer forces a full-viewport repaint over the launch card, so the screen no longer flashes and darkens at handover; the mount now writes only the rows whose content changed.
  • The launch card states the model name, git state and context percentage recorded at the end of the last launch instead of placeholders, so the status row and hero do not change when the session mounts; each fact falls back to its placeholder when the release, the model or the project changed.
  • The launch card records what a project knows and what a model knows separately, so working in two projects no longer erases both and leaves the context gauge reading ? on every start, and a project opened for the first time states the model's display name and effort instead of a raw id. The file is versioned, each map is bounded to its 24 most recently written entries, and a copy written by a previous shape is discarded rather than read.
  • The launch card states the context gauge in a project it has never measured, using the at-rest reading the configured model last took anywhere, so a project opened for the first time draws a real bar instead of ? left and an empty one; the project's own reading still wins where it has one, and the gauge falls back to ? only until that model has idled once.
  • The launch card states the effort the last launch ran at, so the status row no longer grows a @high tail when the session mounts and shifts every segment beside it; the rung is dropped when the model changes and erased when the row stops printing one.
  • The launch card prints the configured model id's last path segment rather than the whole qualified id, so a namespaced id no longer costs the status row its context gauge on the first launch of a project.
  • The launch hero states the configured model instead of no model yet · /login when no display name has been recorded yet.
  • The context percentage the next launch states is recorded only while the session is running the configured default model, so a session started with --model, switched with /model before anything was sent, or fallen back to another model no longer leaves the card a gauge measured against a window its model does not have.
  • The context gauge renders every percentage at one width, so a reading that arrives or changes no longer shifts the status row beside it.
  • The launch card lays its status row out against the same width the live row uses, so a narrow terminal no longer shows the card keeping a segment the running session immediately drops.
  • The screen no longer shakes while an answer streams into a viewport it has not filled: each streamed chunk repaints its own block alone, and the anchor fill above it is now resized in that same frame rather than reused at the previous frame's height, which composed one row past the viewport and moved the window per row of the answer.
  • The composer no longer lifts off the bottom row for a frame when content collapses (a tool card closing, the working indicator retiring): the anchor is sized from the children about to render rather than from the frame that already composed, so the placement no longer needs a second paint to correct it.
  • Context budgeting is unchanged from 1.3.0: the unreleased reserve for the model's output allocation is withdrawn, because subtracting it from the usable window moved every model's compaction threshold, roughly doubling how often compaction fired and invalidating the prompt cache on each pass.
  • A turn too large for compaction to summarize is truncated in the middle, keeping the head and the tail, instead of pausing automatic maintenance; the removed text is written to a recovery artifact the notice names. A session whose newest turn was a single oversized message could previously make no progress, and rewinding the tree did not clear it.
  • A payload the outbound secret scan refuses for its size is treated as a context overflow, so the session compacts and retries instead of stopping at "the provider request exceeds the confidentiality scan byte limit" on every attempt. The scan runs before the request is sent, so nothing else had reported the turn as too large.
  • An image a kitty-protocol terminal cannot be handed reports the format as the reason instead of claiming images are switched off, and a picture whose conversion is still running no longer prints a placeholder that a moment later becomes the picture.
  • A key pressed before the launch card appears is drawn into the card's composer about a millisecond later instead of 156ms later, so the composer no longer sits on screen ignoring what is typed into it while the main module loads.
  • The status row's dirty marker appears when git status answers instead of waiting for whatever redraws next, which in a resting session is the next keystroke; the row had been showing a clean branch over a tree nothing had looked at.
  • The status row keeps the dirty marker the launch card painted instead of dropping it for the width of its own git status, so a handover on a dirty tree no longer shows the branch change colour twice; a scan that finds the tree really did move still repaints it once.
  • A turn that ends on text after a tool call is recognized as finished even while a session subscriber is still running, so the todo reminder, the rewind pass and the session-stop hooks no longer skip a turn whose final message arrived behind a slow subscriber.
  • A memory limit pins the capped subtree's swap to zero, so the cap bounds the whole anonymous footprint; while unreleased a 256 MB machine cap let a single process reach 5,520 MB by swapping.
  • The machine limit requires a parent that delegates two cgroup levels, so a host that delegates one — a container whose cgroup root holds processes — reports per-session limits held and the machine tier unheld, instead of reporting a machine cap the kernel never applies.
  • The CPU-limit probe and the limiter resolve one environment, so the probe can no longer report support for a cgroup path the limiter does not write to.
  • A probe directory left behind by a killed veyyon no longer makes the only usable cgroup parent look unusable, which dropped the machine to no budget at all until it was deleted by hand.
  • /cpu-limit status reports whether the kernel is holding the machine-wide limit, naming the resource it refused, instead of printing the configured cores on a host that holds none of them.
  • /cpu-limit states that a session CPU cap above the machine cap is bounded by it, rather than printing the two numbers with nothing relating them.
  • A background bash job joins its session's CPU and memory budget instead of running outside every cap.
  • A model whose compaction route answers 404 reports the downgrade to local compaction once, instead of once for the 404 and again in different wording on the next compaction.
  • A session that never enabled goal mode no longer reports "Goal mode stopped driving" after three consecutive provider-killed turns; the failed-turn counter and its stand-down warning now require a running goal.
  • A goal now starts with its full failed-turn tolerance instead of inheriting the failures an earlier goal in the same session left behind, which stood it down on its first error rather than its third.
  • A turn-ending provider error too long to render inline now reports how many lines were dropped instead of ending without a trace.
  • The installer asks what is already installed before downloading, so a machine already on the released version finishes in seconds instead of fetching the whole binary to discard it.
  • The installer repairs an install whose binary was replaced since it was written — a local build copied over it, or a write interrupted mid-swap — by moving that file aside and installing, instead of refusing and leaving the machine on the old version; a file at a path the installer has never installed to is still refused untouched.
  • A completed goal now reports the tokens the turn that completed it spent after the goal tool ran, including a subagent that returned in the same batch, instead of stopping its count at the tool call.
  • /cpu-limit no longer sets a budget: it reports both scopes and lifts this session's CPU cap, and points at /settings under Resources for configuration.
  • An ACP client following a tool-call location now opens the file, not a name ending in the read tool's line range.
  • The settings screen states that left returns to the category list, and no longer expands a row that has no description, which consumed the next left with nothing on screen to show for it.
Removed
  • subagent.sharedModel, subagent.model, subagent.thinkingLevel and subagent.modelByDepth decided the model and effort for every subagent at once and are rejected; a config still holding one is reported once, naming the agent page that replaces it.
  • The --subagent-model launch flag, which set the model for every subagent in the session.
  • The --project flag on veyyon agents unpack, which wrote definitions to ./.veyyon/agents; there is no project scope for subagent discovery, so those files were never loaded.
  • The tagline under the wordmark on the session welcome hero, which the launch card and the mounted hero each printed.

1.3.0

2026-08-28 latest View on GitHub ↗
Breaking
  • The model-facing workspace search surface is now one mandatory search tool taking ordered required type ("files" | "text" | "structure") and input, replacing the separate glob, grep, and ast_grep tool IDs. Retired per-engine and search.enabled values are discarded while text-context values and persisted tool inventories migrate.
Added
  • search takes paths for a text search, returning the matching files with per-file counts instead of match lines; the shell route it replaces (rg -l) is intercepted, and searching buildSystemPrompt under packages/coding-agent/src costs 3,492 tokens as match lines against 215 as a file list.
  • Esc pressed twice within half a second over a composer holding text discards the draft; undo brings it back, and a single Esc still leaves the draft alone.
  • /advisor reports advisor status, opens the WATCHDOG.yml roster editor and applies a save to the running session, starts or stops the advisor for the session, and copies the advisor's own transcript; the subsystem shipped complete but no command, key or menu row reached it.
  • session.newKeepsBackground decides what /new does to a turn still streaming: off (the default) stops it and closes its provider stream before the new session starts, on keeps the old conversation running and says which one.
  • The status line carries a background chip counting conversations this process is still running that no screen is showing, present in every preset and silent at zero.
  • The terminal renderer composer zone gains a formal defect oracle and automated invariant sweep suite covering prompt counts, output bleed, row mixing, footer alignment, mouse click routing, caret positioning, overflow, pad transparency, hairline integrity, and virtual scroll stability.
  • prewalk.cheapModel and prewalk.strongModel configure the cheap model prewalk switches into at the first edit and the strong model it starts on.
  • /prewalk accepts an optional model argument to arm a per-session target model override.
  • edit.afterEdit selects what one turn that changed files owes before it finishes: verify (the default) runs one check when none followed the last edit, review reads back every code file changed since the last user message and judges correctness, maintainability and cross-file contracts, off neither; the legacy edit.critiqueCodeMutations boolean migrates to review or verify on load.
  • Configurable launch.cleanupWaitMs setting (default 15 minutes) purges exited launch daemon records from memory and disk after a retention TTL.
  • Exporting a session to HTML streams the snapshot into the output file instead of assembling the whole document in memory, taking an 80MiB transcript from 1007MiB of peak resident memory to 532MiB with byte-identical output.
  • A session snapshot that contains a reference cycle fails the HTML export with an error instead of writing until the disk fills.
  • bench/session-memory.bench.ts reports heap after a forced GC, current RSS and high-water RSS at each of three phases (module baseline, SessionManager.open, buildSessionContext) over a synthetic transcript sized by SESSION_MB.
  • Added model.toolCallLoopGuard.readSubsumptionThreshold (default 3) to steer models that re-read unchanged code lines back-to-back before consuming full context.
  • VEYYON_DEBUG_STARTUP=1 writes one line per phase of a prompt submission (compaction check, plan arm, context build, memory context), so a slow submit names the phase that spent the time.
  • read takes depth and limit arguments for directory listings, and a read of the session working directory root with neither now returns a concise top-level listing with per-subdirectory entry counts instead of the recursive tree.
  • A tool result that carries an image now states whether the picture reached the screen, so a model reading a file describes what it shows instead of reporting that it displayed it.
  • A picture the block gives up on after the fact, because the session's image budget demoted it or a Kitty session could not convert it, is stated to the model as undrawn instead of being reported as displayed.
  • statusLine.segmentOptions.path.displayRoots names the workspace roots the working directory is shown relative to, with ~ accepted for the home directory, replacing the two hard-coded conventions (~/Projects and /work); the first matching entry wins and a non-absolute entry is dropped and named in the log once.
  • read accepts a semicolon-delimited list of internal resources (skill://demo/one.md;skill://demo/two.md), the same list form search takes, and returns one section per entry.
  • Eval kernels gain kv, a bounded JSON store under the session's artifacts directory that survives kernel resets and is shared between JavaScript and Python without cross-session filename collisions or lost concurrent updates, and defs(), which lists the names user code has defined in the kernel.
  • Every supervised process termination records which component ended it and why, with distinct attribution for each path (operator stop, signal, restart, broker shutdown, idle reaper, OS signal, broker recovery, launch failure, external signal, and natural exit); launch list output shows the lifetime owning condition and retained completion records with exit codes, reasons, and output tails, queryable after the name is reused and across broker restarts.
  • A click on the working directory, git branch or pull-request text in the composer status line widens the location to the row and retracts the model chip to pay for it, animated over the shared expand curve, and a second click reverses it; display.transitions: off lands on the click frame.
  • /omfg forges rules that carry the extended TTSR frontmatter (astCondition, interruptMode, pathScope, repeatMode, repeatGap, repeatCompactions, warmupMatches), confirms ast-grep conditions against the conversation's tool history through the same gate chain a live stream applies, and fails loudly on a malformed optional field instead of dropping it.
  • /omfg saves forged rules to the active profile's rules directory only; the project target is gone, because project .veyyon/rules was never discovered across sessions.
  • Settings → Stream Interrupts (TTSR) groups the profile's own rules under a leading User created section instead of From native, ahead of foreign-tool and built-in sections.
  • An opt-in eval.pyWorkspace experiment teaches the agent to keep large tool results and repeated repository operations inside the persistent Python kernel, reducing intermediate transcript output without changing kernel execution.
Changed
  • A search result carries its limit and truncation notice again. The unified tool nested each type's result one level deeper than the output layer reads, so a capped search files returned five paths and said nothing about the cap, no search of any type ever named a raised limit or a page selector, and an already-spilled result was spilled and truncated a second time.
  • A capped structure search pages with skip and names the match count instead of advising limit, which the tool accepts only for a file search: following the old advice cost a rejected call, an error result and a retry.
  • A structure-search metavariable binding is elided past 60 bytes, because a binding is a source range inside the match the result has already printed line by line. A multi-node capture arrives joined onto one line, so $$$BODY was a second copy of the whole body, and a capture spanning lines entered the result with no line number for a caller to anchor on. Over five patterns of this repository the bindings cost 8,414 tokens on top of 9,052 tokens of match text, and the same five searches now cost 12,769 tokens against 19,826. A value inside the bound is kept, since it names which fragment bound to which variable for about ten tokens.
  • A text search states its per-file match cap and its internal ceiling. A search of one file returned the first 200 matches and said nothing, so a file with 4,000 matches read as a file with 200; a multi-file search capped each file at 20 with the same silence; and a search that stopped at the 2,000-match native ceiling reported a file count that was a floor as though it were a total. skip pages files and reaches nothing past either cap, so each notice names narrowing the pattern.
  • A search call rejected for a cross-type field names the fields that type accepts. In a recorded trial a model sent limit to a text search, read Search type "text" does not accept: limit, and spent a second request rediscovering the field by removing it; the rejection already costs a full round trip, so it now states the set that would have worked.
  • A read whose line bound came from the default rather than from the caller is held to the tools.artifactSpillThreshold budget every other tool result is held to, instead of a compiled 512-bytes-a-line allowance: reading packages/coding-agent/CHANGELOG.md cost 19,768 tokens and now costs 10,586, and :50 cost 23,643 and now costs 10,357. A read that names a line count still returns those lines.
  • Reading a notebook, a converted document, an archive entry, a URL body or an internal resource is bounded by tools.artifactSpillThreshold instead of a compiled 50KB constant the setting could not reach, so lowering the setting now lowers those results as it already lowered bash, eval and ssh output.
  • A @path mention in a prompt is bounded by tools.artifactSpillThreshold instead of a compiled 50KB constant, and a capped mention names the budget in effect rather than that constant; the file body, an over-long first line and a mentioned directory's listing all take the same budget.
  • A multi-range read whose first line alone exceeds the output budget names that line and its size, instead of a backwards [Lines 1-0 …] window whose continuation selector pointed back at the line that just failed.
  • A directory listing from read takes the tools.artifactSpillThreshold budget: the unsliced listing followed a compiled 50KB constant the setting could not reach, and a listing sliced by :N-M carried no byte bound at all. A capped slice names how many listing lines remain and the selector that continues them.
  • An archive directory listing from read takes the tools.artifactSpillThreshold budget instead of a compiled 50KB constant the setting could not reach.
  • A structural summary from read stops at read.defaultLimit lines or the tools.artifactSpillThreshold budget, whichever binds first, clips lines to tools.outputMaxColumns, and names the bound that stopped it and the line that continues it. A summary is a projection over the whole file and carried no bound at all, so a selector-free read of a declaration-dense file returned the whole projection: packages/catalog/src/discovery/cursor-gen/agent_pb.ts cost 326.5KB, about 82,000 tokens in one result, and now costs 3,138 tokens. Across 4,039 summaries in this repository the median is 57 lines; the 54 files over 300 lines cost 311,211 tokens between them and now cost 174,450.
  • A PDF image-member list and an agent://<id>/<field> extraction from read take the tools.artifactSpillThreshold budget. Both returned the whole collection whichever size the document or the artifact happened to be: a scanned PDF listed every extracted image, and an extraction takes no line selector, so nothing could page it. A capped list names how many members it dropped, and a capped extraction is cut by bytes and names its full size and the URL that pages it.
  • A read of a directory that names no depth lists the top level instead of recursing two levels: every entry up to 100, each subdirectory annotated with its direct-child count, and a footer naming depth: 2 for the recursive listing. Only the session working directory root answered this way before, and the second level is a default nobody asked for: over seven directories of this repository the selector-free read cost 27,568 tokens and now costs 7,131, with packages/coding-agent/src at 8,163 against 1,273. A directory wider than 100 entries states how many it held back and names depth: 1 for the flat listing of all of them; a caller that names depth or limit is unaffected.
  • A search_tool_bm25 call activates the tools it matched well instead of every ranked match up to its limit of 8, which under tools.discoveryMode: all is the whole hidden set. An activated tool's schema is re-sent on every later request of the session, so one loose query cost more than the mode saved: "keep track of what is left to do" activated todo plus set_cwd, task and web_search for 2,239 tokens a request where todo alone costs 1,048, and across eight such queries the tail was 3,319 of 7,349 tokens. A tool now activates when it scores at least half the best match, so a near-tie still activates and the same queries cost 4,030; a weaker match is returned in also_matched and a query naming it activates it.
  • tools.artifactSpillThreshold bounds what a spilled tool result costs a request, not only when an artifact is written. The window kept inline was tools.artifactHeadBytes + tools.artifactTailBytes, so a 404KB result delivered 39.8KB at a threshold of 8KB and the same 39.8KB at 200KB; it now delivers 7.8KB and 39.8KB. The head and tail settings shape the window inside the budget and keep their ratio, and the elided bytes stay recoverable through the artifact:// id. This covers every tool result except read, which is bounded by the lines its caller asked for.
  • The debug tool loads only where at least one configured DAP adapter command resolves, so a host with no debugger installed no longer pays about 1,000 tokens of debug schema on every request for a tool whose every call would fail on the missing adapter.
  • search.contextAfter defaults to 1 line instead of 3. A tool result is sent again on every later request of the session, so each line of a search result is billed once per remaining request; over eight searches of this repository the wider window cost 16,836 tokens against 11,483.
  • The eval prompt-override registry, the system-prompt eval hooks, the argot cache and the reroot hint name @veyyon/evals paths instead of the retired @veyyon/metaharness, @veyyon/typescript-edit-benchmark and @veyyon/deepswe-bench packages. No behavior change.
  • The /providers account card filters its provider sidebar: ctrl+s enters search, typing narrows the list by fuzzy match on provider name and id, the arrows move within the matches, and esc leaves search before it closes the card (#922 by @Crqptx).
  • A parked subagent is pruned rather than closed: subagent.autoClose.enabled, .parkedMs and .waitingMs are now subagent.prune.enabled, .afterMs and .waitingAfterMs, existing config files migrate on load, and the settings tab states park and prune as two stages in their own groups.
  • A parked subagent keeps its roster row for an hour, and two hours when it stopped waiting on a peer, instead of five and thirty minutes.
  • A subagent restored from a previous run is aged from its own transcript rather than from the moment this session found it, so restored agents no longer sit at "just now" forever and are pruned on the same budget as the rest.
  • session.newKeepsBackground states that a change needs a restart, because switching it does not affect the running session (#928).
  • A turn that changed files takes at most one continuation before it finishes; the verification pass that always ran unconditionally is now the verify value of edit.afterEdit and no longer stacks a second forced continuation under the review pass.
  • The Julia, Python and Ruby eval kernels share one execution loop instead of three copies of it; no change to how a kernel behaves.
  • Reading a file or fetching a URL no longer loads the document converters, and a web search no longer loads the browser fingerprint generator, because the constants those paths wanted are separated from the libraries that sat behind them, taking about 40ms off session startup.
  • The launch card is painted and flushed before the agent runtime graph is loaded, taking an interactive launch from a blank terminal for 760ms to a typable composer at 111ms.
  • The agent runtime is loaded in stages that hand the event loop back between them, so a character typed while the launch card is up is drawn in 6ms instead of 198ms; the load itself takes the same time.
  • /agents is the subagent dashboard: the surface is named that in its title, its /agents description, its keybinding descriptions, /hotkeys and the handbook, in place of "Agent Control Center".
  • The subagent dashboard no longer opens across every conversation the process is running: /process-manager and the a scope toggle are removed, and the card is scoped to the conversation on screen.
  • A locally built binary minifies identifiers the way the released one does, cutting it from 310.7MB to 303.4MB and its launch card from 143ms to 131ms; function names are still kept, so stack traces are unchanged.
  • veyyon --help renders its command list from registry summaries verified against command statics and loads only the hidden default command for its flag table, reducing a measured warm Windows invocation from 1.2 seconds to 0.13 seconds.
  • The CPU model is read once per process instead of on every system prompt build, removing about 30ms from the window before the composer accepts input.
  • No user-visible change: the once-per-process CPU model cache gained a reset the test suite calls, so a suite that fakes the platform reads its own answer instead of the one an earlier suite in the same process cached.
  • Row band painting compiles its escape pattern once for the process rather than once per painted row.
  • The default launch command imports the session runtime and ACP terminal authentication only when it runs, so loading its flag table no longer imports the runtime graph.
  • The vibe screens, the image-inspection call and an LSP hover code block draw no border of their own inside a tool block, so a block keeps one left edge; a tree connector remains only where a row belongs to the row above it, in the eval value tree, the search line gutter, the job tree and the LSP reference tree.
  • Classified runner output (cargo, bun, Go, ctest, dotnet, clippy, golangci-lint, Gradle lint, pytest, and tsc/eslint-family) opens with a result-contract header, [clean] <command> or [errors] / [errors N] <command>, carrying the verdict above a body of retained diagnostics.
  • Files → LSP is one enterable row whose nested page independently controls language servers, the agent tool, diagnostics after write or edit, format after write, lazy startup, and diagnostics deduplication; --no-lsp still disables the full stack.
  • Startup paints the resting composer, with its real hairline, ghost prompt and exact row count, in the first frame from one static component shared with the mounted zone, instead of reserving eight blank rows until mode initialization finished and letting the prompt arrive seconds into a cold launch.
  • Multi-target structure searches now execute concurrently while preserving globally ordered paging, totals, parse errors, cancellation, and target-order failures.
  • A picture a terminal will not draw now leaves a row naming the file, the media type, the pixel size and the cause, in place of [Image: image/png], including when a Kitty session cannot convert it to PNG.
  • Session creation overlaps two serial file reads with neighboring startup work: rules discovery starts as soon as the session context loads rather than after the skills await, and the vault key read starts before the secrets, env and vault entries load.
  • ModelRegistry persists its static bundled, cached-standard and cached-discovery layers to a content-verified resolved-models.json that a warm launch restores instead of rebuilding, invalidated by catalog, cache-row and custom-model content changes or a cached row crossing its 24 h freshness window, but not by SQLite sidecar churn or a provider re-verifying models it already had.
  • A failed MCP tool call decides on a reconnect from the shared socket vocabulary plus this layer's own stale-session rules, so an unreachable or unresolvable host reconnects the server the way a refused connection already did, while a live server answering 500 or holding a request past its deadline stays a failed call.
  • The debug log records which classification rules decided a failed turn's retry, next to the classified kind, so a retry nobody expected is diagnosed from the log instead of by re-reading the provider's sentence.
  • The browser tab worker and supervisor state why each teardown step and each optional probe discards its failure; behavior is unchanged.
  • The browser tab worker and supervisor reach bestEffort and optionalResult through @veyyon/utils/discarded-fault rather than the package barrel; behavior is unchanged.
  • Daemon completion parsing and eval-store serialization errors use shared type guards; behavior is unchanged.
  • Broad multi-file text searches now keep only deterministic representative matches inline and save the complete formatted result behind an artifact:// reference. The preview budget follows the turn-aware output curve from an 8 KiB search ceiling (~2 KiB early at turn 0), emitting up to two representative matches per file while preserving counts and warnings; explicit single-file and line-range searches retain their full output, and only visible representative lines are recorded as seen for anchored edits.
Fixed
  • Tool discovery ranks a tool on its full description instead of its one-line summary alone, which left 96 to 99 percent of each tool's own text out of the retrieval corpus. Over the 17 built-ins, launch scored zero for "tail the output of a server I launched" and eval scored zero for "evaluate javascript and keep the variables", while short-description tools such as goal and set_cwd ranked in six of ten realistic queries; launch now ranks first in eight of those ten.
  • Tool discovery indexes a compound word both whole and in parts, so a lowercase query reaches the word it names: SQLite was indexed only as sq and lite, TypeScript only as type and script, and IPython only as i and python, so queries spelling any of them scored zero against the tool that documented it.
  • A search limit notice is true of the result it prints beside. A text search over 84 files reported "at least one file had more than 20 matches" for a page whose largest file held 12, reported "stopped at its internal ceiling of 2000 matches" for a result of 63, and marked its complete file total "84+": the per-file flag was computed over every matching file rather than the 20-file page, and one flag meant both an exhausted 2000-match fetch budget, which leaves files unopened, and a single file's clipped match list, which does not.
  • A structure search excludes matches in prose grammars (markdown, reStructuredText, AsciiDoc, LaTeX, plain text, logs, CSV, TSV) and states how many it withheld, instead of reporting documentation paragraphs as code matches: over this repository logger.warn($$$ARGS) returned three changelog paragraphs averaging 2,000 characters, none of which contains the string logger.warn.
  • The search tool states that case is on by default and that passing false matches case-insensitively, so a case-insensitive text search no longer depends on guessing the default.
  • A directory listing reports a native addon that could not load instead of answering "(empty directory)". A container whose glibc was older than the shipped addon required got an empty listing for a full checkout on the first read ., both in the tool result and in the workspace tree the system prompt carries.
  • A directory listing reports the errno for a directory the process cannot open instead of answering "(empty directory)", which a model reads as a directory worth no further look; only a directory that is not there still answers empty.
  • An explicit --model pointing at a dynamically-discovered model (a providers /v1/models` entry or models.dev overlay absent from the bundled catalog) no longer fails with "not found among N models" when the background discovery refresh has not completed before model resolution. The deferred pattern path does a synchronous cache-aware discovery pass when none of the patterns resolve against the static catalog, mirroring the fallback already present for default-role models.
  • Unified search now preserves purpose-specific field semantics through the Antigravity tool-schema adapter, replaces primitive search tools in explicit tool lists, keeps plan/subagent/bash guidance aligned with the active tool, and redirects intercepted shell searches to search.
  • A bash-interceptor rule that still names a retired search primitive (grep, glob, find, ast_grep) now redirects to search naming the type field the tool accepts, instead of the purpose field it rejects.
  • File and structure search results are head-truncated at their own byte budget with the full output saved to an artifact, instead of relying on the shared spill layer's middle-elision. File search uses a 4 KB head window (paths are dense and mtime-sorted, so the most recently modified files are on top), and structure search uses the same 8 KB budget as text search. Both recover the full output through an artifact:// footer.
  • veyyon --help describes the grep dev command as the standalone native text-search probe, which is what the command itself says, instead of naming the retired standalone grep tool.
  • Bundled edit and write guidance now uses TTSR's deferred reminder path instead of inheriting the global interrupt policy and aborting the active model response. Every bundled tool-scoped rule must now declare its interrupt policy explicitly.
  • A streaming answer lands in the empty space below the conversation instead of pushing every row already on screen up one row per token, so the screen no longer shakes while a model talks into a viewport that is not yet full.
  • A settings search box reduced to nothing but spaces leaves search and shows the settings list again, instead of holding an apparently empty box over zero matches until esc.
  • A permission prompt for a long command keeps its answer rows on screen: the card sheds lines from the command, saying how many it dropped, instead of clipping the option list off the bottom.
  • A session file that another window wrote its session_exit record into no longer reports that window as a live second writer, so a session whose duplicate window was closed by SIGHUP stops telling the operator to close a session that has already closed.
  • veyyon --resume <id> finds the session under any profile, so the id printed on exit resolves after relaunching under a different one instead of reporting the session as not found.
  • The collab host, guest client and relay socket load when /collab or /join runs instead of during every interactive startup, and a settings domain reads the relay default from @veyyon/wire rather than through the collab protocol module.
  • Argot's dictionary generator, corpus walker and project vocabulary load when a project dictionary is first read instead of during every startup, so a session with argot.enabled off no longer evaluates them.
  • The stats dashboard's aggregator, SQLite layer and embedded client load when /stats first runs instead of during every interactive startup, so a session that never opens the dashboard stops parsing them.
  • The Python, Ruby and Julia eval backends call their kernel and executor modules on every cell instead of holding a copy of each taken when the backend module loaded, so an availability check or executor replaced after startup is the one that runs.
  • A cursor-agent model receives the operator's global, profile and project instruction files again: the assembled prompt was blanked for that api in favour of a channel the server ignores, so every layer reached the model on no channel at all; one prompt is now built for every api and carried to Cursor on the active user turn.
  • A personality named after a property JavaScript objects inherit, such as toString or constructor, is reported as unknown and falls back to the default like any other unrecognized name; the built-in catalog was indexed without an own-property check, so those names resolved to a function, the system prompt build failed silently, and the default was substituted with no warning and any personality/default.md override ignored.
  • A personality spec can no longer spell a prompt tag such as <critical> and have it render as prompt structure; only <personality> was neutralized before, and a project's .veyyon/personalities file, which arrives with a cloned repository and outranks the user's own, is injected into every request.
  • plugin install <name>@<marketplace> --dry-run no longer performs the install: the marketplace branch never read the flag, so it fetched the plugin, wrote the cache and both registries, and reported a completed install; it now resolves the version from the catalog and writes nothing.
  • plugin link <path> no longer fails with EISDIR when the plugin is already installed as a real directory from npm or a marketplace, which is the state a local checkout replaces.
  • The Agent Control Center's read-only transcript viewer expands argot shorthand instead of showing the model's raw §handle text; it parses a subagent's or advisor's persisted transcript directly, and the persisted form keeps the handles.
  • Characters typed in the same terminal read as a paste are no longer discarded; the editor read the pasted payload and the bytes following it but dropped the ones preceding it, so the last thing typed before Cmd+V disappeared.
  • Backspace works at the launch card: the startup gate refused any chunk carrying a control byte, so a character typed by mistake before the composer mounted could not be taken back and the typo was what the session started with.
  • An unattended goal keeps driving after a turn whose post-turn maintenance outlasts the continuation delay, instead of sitting active and idle until someone types.
  • Text typed at the launch card appears there as it is typed, instead of staying invisible until session startup finishes and the composer mounts.
  • Text typed before the launch card paints reaches the composer instead of being destroyed, because the startup tty flush now runs only for the relaunch backlog it was written for.
  • The advisor roster editor reports a failed save instead of clearing the buffer and claiming the write reached disk.
  • The advisor roster editor states why the model list is unavailable when the model registry fails, instead of opening a picker holding nothing.
  • /advisor status names the next move on a text client too — the setting to change or the model role to assign — instead of reporting the state and stopping.
  • Interrupting a turn stops every configured advisor's review as well, instead of leaving one model call per advisor streaming and billing against a turn that was just stopped.
  • A prompt typed at the launch card while the session is still starting is carried into the composer instead of being discarded, and the terminal's own probe replies are still excluded so no escape sequence reaches the draft.
  • A bash working directory on a different Windows drive from the project renders as the absolute path it is, instead of being reported as a path inside the project.
  • Web search keeps trying the next provider when one returns only follow-up suggestions or intermediate search queries, instead of counting that metadata as a result and handing the model a list of questions; a SearXNG "did you mean" on a misspelled query no longer ends the search.
  • Public Web search reports which engines did not answer within the deadline instead of reporting an empty web, so a slow or bot-walled engine no longer surfaces as "returned no renderable search content".
  • The VEYYON_TINY_DEVICE and VEYYON_TINY_DTYPE reference states the providers.tinyModelDevice and providers.tinyModelDtype defaults as the default sentinel each setting actually holds, and names what that sentinel selects, instead of listing CPU and q4 as the stored values.
  • A collapsed ssh result measures its preview in rendered rows at the frame's inner width and shows the newest three lines with a count of what was hidden, instead of slicing the first five newlines and overflowing the frame whenever a remote line wrapped.
  • A terminal resize, theme switch or session switch no longer seals a backgrounded subagent's tool card mid-flight, so the progress it reports afterwards still reaches the card instead of being dropped for the rest of the turn.
  • The composer defect oracle counts only the prompt glyph the frame states, so a transcript row opening with $, > or ! is no longer reported as a second composer prompt.
  • An exported or shared session escapes quotes in every value it places in an HTML attribute, so a link target, link title or image mime type carrying a " renders as text instead of closing the attribute and adding an event handler that runs on the share origin.
  • An exported or shared session escapes the model names in its header, so a model name carrying markup renders as text.
  • The write tool accepts a file holding an indented numeric mapping key, so a docker-compose 80: http, a Kubernetes container port and a dict literal keyed by port number are written instead of being refused as pasted search output.
  • A read, write, search or image target that reaches outside the working directory through a symlink asks for approval even when it carries a selector suffix, so link.env:1-10, db.sqlite:users:42 and archive.zip:dir/file.ts:5-9 are no longer auto-approved where the bare path would have prompted.
  • A server-side compaction failure states the reason once instead of wrapping it in its own prefix, so a host without the compact route reports "Server-side compaction is not available for openai-codex/… (404 Not Found); falling back to local compaction." rather than nesting the message inside itself.
  • A tool status line shortens the paths it was given, so search, ast_edit, debug and set_cwd show ~/project/src instead of printing the home directory into the transcript, and a long path list is truncated rather than pushing the row past the terminal width.
  • The /omfg panel names the rule it saved under ~, truncates it to one row, and shortens the paths embedded in a failure message, instead of printing the home directory in its subheader, footer and error text.
  • The launch status header truncates the command it is starting instead of drawing a row wider than the terminal, and a failed launch collapses to a few lines with a count of the rest until it is expanded, instead of printing every line the process wrote.
  • A conversation /new leaves running in the background keeps its own row in the agent registry instead of being overwritten by the session that replaced it, so it stays listed and its finished turn no longer marks the foreground conversation idle.
  • The /resume picker floats its card in the middle of the screen instead of drawing it against the top edge over a half-blank terminal, most visible right after /new or a profile switch, when the folder holds few enough sessions to make the card short.
  • Clearing memory waits the full deletion retry window before reporting the database files removed, instead of half of it, so a Windows SQLite lock that outlives close() no longer leaves files behind under a success message.
  • Ollama discovery keeps a configured base URL's path, so an endpoint mounted at a subpath behind a reverse proxy is found instead of reporting no models.
  • The write tool and the hashline parser agree on what a numeric-keyed mapping looks like, so a body of true, false or null values is accepted by both instead of one each way.
  • Cancelling an LSP request while a project is still loading returns on every language server, instead of returning on most and throwing AbortError on rust-analyzer alone.
  • Reading a directory the process cannot open reports the permission error instead of rendering it as (empty directory).
  • A commit-analysis or changelog reply that a model writes as text is matched against the shape the caller needs, so a refusal, an error object or a reasoning object no longer crashes the parse or fills a changelog with one bullet per character.
  • Launch daemon teardown and browser process cleanup no longer throw on a host where the native addon cannot load; a daemon falls back to ending its PTY and a browser scan reports no candidates, instead of the failure ending the session (#917).
  • ctrl+g passes a GUI editor the flag that makes it block, so an edit made in VS Code, Cursor, Zed or Sublime is read back into the composer instead of the editor forking and the composer keeping the text it already held.
  • A /guided goal turn survives a model that wraps its JSON in prose, a code fence or a brace run that is not JSON, instead of ending the interview with a parse error.
  • A /guided goal turn shows a spinner while it runs, so the screen between an answer and the next question no longer looks inert.
  • A streaming answer no longer composes a frame one row taller than the viewport on every chunk, which moved the window down to fit and back up on the next frame and shook the screen for as long as the answer kept arriving.
  • Mounting a chat block no longer routes home-anchor slack for rows the content has already taken, which composed a frame taller than the viewport and moved the window on that frame and back on the next.
  • An irc send with await ends as soon as its recipient is terminated or leaves the roster, instead of blocking for the full timeout, or forever at timeoutMs: 0, on a reply that can no longer arrive; a recipient that is merely idle or parked is still woken by the delivery and given the full timeout to answer.
  • A truncated advisor preview, retry reason, and background-task label measure their budget in display columns rather than UTF-16 code units, so a wide or multi-code-unit character is no longer cut in half or counted as one column.
  • The tool approval card, the ast_edit diff preview, and an eval display() value cut long text at a code point rather than a UTF-16 code unit, so an emoji or rare CJK character sitting at the limit is no longer split into an invalid lone surrogate.
  • The Stream First Event Timeout and Stream Idle Timeout settings name the VEYYON_* environment variables that override them, instead of a PI_* prefix that no longer exists.
  • A Kagi search refused for exhausted credits reports that, instead of a generic request failure, on any status code other than 401, 402 and 403.
  • A rendered tool error shortens embedded home-directory paths, so an error card no longer prints the home directory and no longer spends its width budget on the prefix instead of the reason.
  • A job label truncated for display keeps a regional-indicator flag whole instead of emitting half of one, and an empty width budget yields an empty label rather than an ellipsis a column too wide.
  • Brave and Jina web search honor an API key held in the credential store, instead of reporting themselves unconfigured unless the key was also exported as an environment variable.
  • A JSON-RPC header field whose name merely ends in Content-Length, or a server log line that mentions it, no longer sets the frame length and parks the language-server connection on a byte count the stream never reaches.
  • A grep over an archive member removes its extracted scratch directory when extraction fails partway, instead of leaving it behind for the life of the host.
  • Waiting for a language server's project load removes its abort listener, instead of leaving one on the turn's signal for every feature request made during that turn.
  • A path list naming an entry the process is not permitted to stat resolves that entry as present, instead of failing the whole tool call with a permission error.
  • The working-directory boundary selects its targets by value, so a path argument carrying no usable value can no longer suppress the paths it also inspects.
  • An auto-compaction failure names its actual cause when the rejection is not an Error, instead of reporting the literal text compaction failed.
  • A liveness probe of the lspmux server cancels its timeout once the process answers, instead of leaving a timer pending for the rest of the window on every probe.
  • SQLite path detection restarts its scan at the beginning of the string, so a scan that ended abnormally cannot make the next one miss candidates.
  • The write tool rejects content carrying hashline patch markers, unified diff hunks, or read-output display prefixes with an error naming the detection and stating corrective action instead of silently stripping prefixes before writing.
  • Converted LLM message wrappers preserve reference identity across turns so the provider context canonicalizer re-renders only newly appended messages.
  • Memory pipeline SQLite storage (storage.sqlite) manages schema migrations via PRAGMA user_version and dynamically backfills missing columns on legacy databases.
  • The session tree selector formats custom tool arguments using width-aware truncation without double-stringifying string arguments or splitting surrogate pairs.
  • Renderer failure notices sanitize thrown errors, subjects, and fallback descriptions by shortening embedded home directory paths, replacing tabs, and truncating long payloads.
  • The extension inspector origin path truncates Windows backslash paths on directory boundaries and uses standard preview limits.
  • veyyon session declares the sessions alias so the plural command routes to session analysis.
  • Error-formatting call sites use errorMessage so thrown non-Error values and empty-message errors surface readable text, and a reported cause no longer repeats a redundant Error: prefix ahead of its message.
  • AgentSession logs compaction tail elision artifact persistence failures explicitly instead of swallowing them.
  • Quitting no longer hangs when a background session never settles.
  • /new typed while the agent is answering starts the new session without interrupting the answer: the running turn finishes in the background and is flushed to its own transcript, while the composer attaches to a fresh session immediately.
  • /resume onto a session that is still answering re-attaches the running session instead of replaying its transcript as finished text, so its answer keeps streaming into the view and the session being left takes its place in the background.
  • veyyon bench --model @role and veyyon dry-balance --model @role resolve a configured model role instead of failing to find a model named after the alias.
  • The model name segment on the composer status line is preserved against wide working directories and git branch names by ranking it above location shortening in footline degradation.
  • A clipped working directory on the composer status line now carries one ellipsis at the front instead of one at each end, so the directory the session is in stays visible and the visible text reads as a suffix of the real path.
  • The composer status line clips the working directory and the git branch from their own fronts together instead of dropping the branch, never clips a branch short enough to read whole, keeps the path's icon in front of the clip mark, and gives up the context gauge before letting either part fall under its floor.
  • A clip mark on the composer status line is painted in the colour of the text it kept instead of the colour in force before the segment, and a clipped directory or branch opens on a name boundary within four cells of the cut rather than on an orphaned separator.
  • The composer status line no longer paints an empty location zone beside a wide gap: the cells a shed right-group part frees are given back to the working directory and branch, including the cells freed by the shed that ended the fitting, and a token estimate or context gauge is given up before the zone falls under the width at which a name reads.
  • The composer status line keeps the running-subagent count after every other part has gone, instead of giving it up to widen the working directory; at the widths where the count is the whole row the location zone is empty rather than the count being absent.
  • The composer status line's state chips stay on the right edge of the row when no working directory or branch shares it, instead of rendering against the left margin.
  • The composer status line leaves the location zone empty rather than painting a directory fragment with its icon cut off, at the widths where the zone cannot hold an icon, a clip mark and a letter.
  • A click on the composer status line's working directory or branch now shows that name in full, spending the model chip and then the rest of the row's readouts for the room, and only leaves it clipped when the name is longer than the whole row; the click previously paid with the model chip alone, and lost even that room to a context gauge the collapsed row had shed.
  • The composer status line's expansion travels on a 320ms symmetric curve instead of a 180ms front-loaded one, and the room it frees now tracks the widening text frame by frame, so the row no longer steps backward at the start of a click before opening.
  • A click on the composer status line shows the half that was clicked in full rather than widening both, and a readout the click narrows past the width a name reads at is given up whole instead of resting as a fragment.
  • A clipped working directory on the composer status line opens on a directory boundary on Windows, where a path outside the home directory keeps its \ separator; the clip previously had no boundary to find in such a path and always opened mid-name.
  • The composer status line's path budget is counted in terminal cells rather than UTF-16 code units, so a working directory holding wide or astral characters is clamped to the width it paints and is never cut between the halves of one character.
  • The composer footline's click targets — the context gauge, the secrets chip, the goal readout and the path expansion — answer a click in a session whose transcript has not yet overflowed the viewport, instead of staying inert until it does.
  • A stripped working directory keeps the case it has on disk on Windows, instead of being lowercased by the case-insensitive comparison that decided it was under the root.
  • The status line's default-branch lookup no longer raises an unhandled rejection in a directory holding a .git on a host with no git on PATH; the lookup fails to the main fallback instead.
  • The composer status line no longer prints a control character or an escape sequence a name carries: a working directory, git branch, worktree label, multi-repo suffix or provider model name holding a tab, carriage return, bell, newline or escape is sanitized before it reaches the row, where it previously opened a hole in the width arithmetic, overwrote the row's own start, rang the terminal on every repaint, or handed the terminal a sequence of its own.
  • Outbound wire path canonicalization only relativizes paths matching the active session working directory instead of accumulating prior working directory roots, preventing distinct absolute paths in command output from collapsing to the same relative representation.
  • A working-directory change in a live session no longer re-renders earlier messages already sent to the provider, so only messages appended after a set_cwd render against the new directory.
  • Session CPU limits fail closed on unsupported or failed budget groups, lift rate control on removal, refuse a process-creating command before the process exists while leaving launch stop and launch list reachable, escalate over-budget termination from SIGTERM to SIGKILL, and track descendant processes on macOS.
  • Saturated session CPU limits now refuse spawns for MCP servers, extensions, hooks, and custom tools before the process is created.
  • Windows session CPU limits disable Job Object rate control on non-positive or non-finite core counts rather than throttling the process to the minimum rate.
  • A bash command carrying a leading cd, or a relative cwd, is now judged for approval against the directory it will actually run in.
  • Patch failure error rendering shortens absolute paths to avoid displaying home-directory paths and bounds large unmatched hunks with an omitted line count.
  • Filesystem cwd boundary checks expand comma- and whitespace-delimited path arguments matching execution, preventing multi-target reads or searches from bypassing working-directory approval prompts in non-yolo modes.
  • The read tool renderer sanitizes resolved directory paths with shortenPath to avoid displaying unshortened home-directory paths.
  • Stopping a daemon during restart backoff cancels the timer and attributes operator stop without recording a duplicate completion entry, and broker recovery terminates daemons left in restarting state without dead recovery branches.
  • Multi-target structure searches across overlapping paths deduplicate matches so totals, file counts, and paged results are not duplicated or truncated.
  • Acknowledging a completed background job before lifting its watch no longer delivers a duplicate completion notification when retention is zero.
  • A generic tool card with an undrawable image result no longer accumulates duplicate image placeholder rows on rebuild.
  • With Language Servers off, which is the default, the write and edit tools no longer start a language server to inject diagnostics, format the file, or notify the workspace that a file changed, including on the ACP client-bridge write path.
  • A malformed irc send reports its own validation error instead of being reported as an interrupted wait when a peer message arrives in the same batch.
  • A job list snapshot or cancel-only call keeps its own result when an interrupt lands beside it.
  • The composer sits on the viewport bottom on the frame it mounts instead of appearing mid-screen for a moment after the launch card is adopted.
  • The Agent Control Center reports the model an agent is running now instead of the one recorded when it registered.
  • /new and /resume restart the driving session's roster clock, so the Agent Control Center no longer ages the main agent from the conversation that ended.
  • The main agent's roster age advances with its turns instead of freezing at process start.
  • Web search no longer reports "Public Web returned no renderable search content" when one engine serves a bot wall: Startpage's proof-of-work interstitial served at HTTP 200 is now refused as a challenge, and an engine answering with zero results no longer ends the aggregate's wait for a slower engine that has results.
  • Adding an account with a key the provider rejects now leaves the error on screen, instead of remounting the account manager over it so the attempt looked like it silently did nothing; cancelling still returns to the account card.
  • veyyon plugin install --dry-run now resolves the target and fails when it cannot be installed, instead of exiting 0 with "Would install" for an unpublished npm name or a missing git repository, and reports the name and version the target resolves to rather than a 0.0.0-dryrun placeholder (#911 reported by @Crqptx).
  • veyyon plugin uninstall now removes a plugin installed from a local path, which was permanently unremovable because uninstall read only plugins/package.json dependencies while linking registers the plugin in the runtime config and node_modules; the linked directory itself is left untouched.
  • veyyon plugin doctor no longer reports "no plugins installed" in a profile whose plugins were all linked, and names how many linked plugins it found.
  • veyyon plugin config <plugin> now names the missing subcommand instead of reporting "Plugin name required" for a plugin name that was supplied.
  • A completed background job now fills its still-pending originating tool call instead of starting an unrelated recap turn after an interruption, including when zero retention is configured or foreground completion races background delivery.
  • Automatic compaction parks the run, or drains already-queued input once, when every summarizer candidate refuses, instead of reporting that nothing happened and looping.
  • A successful local compaction rescue retries without restoring the failed overflow or truncated assistant turn, and idle compaction stays silent.
  • Agent transcript headers and roster rows share terminal, approval-blocked, and peer-waiting status precedence, so interrupted agents settle red and untyped agents render without a dangling separator.
  • /agents keeps a parked subagent focused, preserves its reconstructed assistant messages, tool calls, and results, reattaches a revived session, and rejects stale revival, removal, scope, and rapid-focus races instead of switching to the wrong transcript.
  • IRC broadcasts no longer wake completed idle peers; direct messages still wake the addressed peer.
  • ImageMagick pixel caches used by proof capture and HD demo scripts stay inside an owned scoped directory that the parent removes after child failure without deleting concurrent or inherited unrelated directories.
  • An indented row inside a tool block keeps its indent when it wraps at a narrow width, instead of continuing at the block's left edge.
  • The finalization reminder counts the files a multi-file edit actually wrote: a call that reports overall failure after writing some of its files is now unverified evidence, and a file a per-file entry skipped is no longer named as affected.
  • A mutated path is XML-escaped before it reaches the hidden finalization reminder, so a file name spelling </system-reminder> cannot end the reminder envelope early, and a relative ast_edit path is resolved against the call's working directory before duplicate paths are collapsed.
  • A colour or title escape sequence a command writes in two pieces no longer leaves part of itself in tool output: the sink holds a sequence its chunk ended inside until the piece that finishes it arrives, and drops one the stream never completes.
  • A launch tool block no longer renders as a bare title with no rows: the header drops the placeholder ellipsis while op is still streaming, and every operation falls back to the result text when the structured detail it renders from is absent.
  • The compaction.remote setting description documents that server-side compaction applies to supported OpenAI, Azure OpenAI, and ChatGPT Codex Responses models.
  • branchSummary.reserveTokens reaches the branch summarizer, which previously used the built-in 16384 reserve whatever the setting said.
  • The ssh tool works again on a profile whose directory nests more than a few levels deep, because its connection multiplexing socket is named from a 16-character digest instead of a 64-character hash that exceeded the 108-byte Unix socket limit, and a path that still cannot fit drops multiplexing with one warning rather than failing the connection.
  • debug reaches the Python debugger on a host that installs python3 and no unsuffixed python, because an adapter may now declare alternate command spellings, while a command written in dap.json is still used exactly as written.
  • A subagent that calls yield with unusable data now fails the run instead of returning success with the system warning as its result, matching how a subagent that never yields at all is already reported.
  • A subagent result that cannot be serialized now fails the run and reports the serialization error, instead of returning success with an unparseable error envelope as its payload.
  • launch recovers from a broker connection that fails while the broker is still binding its socket, instead of caching the rejection for the life of the process and reporting that first error on every later call.
  • bash runs cd - && … again, instead of reading the leading cd as a directory literally named - and rejecting the call with a path the operator never typed.
  • A bash working-directory error shortens the path it reports instead of printing the absolute one, which put the home directory into the tool result and the transcript.
  • search reports why an archive could not be opened or read when the failure is not an Error, instead of the word undefined.
  • A detached daemon that exited while no broker was supervising it is recorded as its own exit, not as a non-detached daemon terminated by the replacement broker.
  • Background conversations abandoned at shutdown before their transcript finished flushing are named in the log, instead of leaving a short file as the only trace.
  • A streaming answer no longer slides the whole conversation up one row per streamed row: the anchor slack now sits below the content and above the composer, so a streamed row lands in the empty space and the composer keeps the viewport bottom.
  • The registry's static-stage snapshot (resolved-models.json) now actually hits on a warm launch. Two defects kept it missing every time: the fingerprint stamped models.db and its SQLite sidecars by mtime, and SQLite moves those files on every connection — including the writer's own — so the launch after every write rebuilt and rewrote the 12 MB file; and authoritativeFreshProviders was serialized as a Set, which JSON turns into {}, so the reader's array guard rejected every restore regardless. The fingerprint now reads a row-content stamp from the cache table, the field serializes as an array, and a relocated registry writes its snapshots beside its own database instead of beside the default profile's. Warm registry init drops from about 183 ms to about 49 ms; test/the-static-model-stage-snapshot-hits-unless-cache-content-changes.test.ts defends each case.
  • Unified search approval preflight now covers every multi-target syntax execution accepts; search also excludes byte-truncated context from editable seen lines, unions ranged and unrestricted text scopes, orders equal-mtime file results by path, suppresses pattern errors from unrelated structure languages, and distinguishes exhausted structure pages from a search with no matches.
  • Unified search now keeps warning-heavy text results within the inline byte budget, preserves semicolon path lists longer than one filename component, excludes matches hidden by generic truncation from editable seen lines, classifies every SSH path-list encoding at the execution approval tier, matches native Unicode tie ordering, and reports exhausted text pages with their totals.
  • Unified search no longer broadens or misroutes malformed calls: files-mode rejects a path owned by other modes while retaining the historical /-means-workspace alias, structure search rejects unsupported ssh:// scopes without an approval prompt for work it cannot execute, immutable internal or fetched sources never receive editable hashline anchors, and the Bash interceptor no longer redirects mutating or otherwise non-equivalent find commands to a read-only file search.
  • The plan-mode extension example now keeps canonical search available both while planning and after restoring normal tools. It previously advertised retired grep/find identities, requested a nonexistent find tool, and dropped search when plan mode ended.
  • Memory summarization now retains canonical search results from session rollouts. It previously allowlisted the retired grep tool name but discarded every result emitted by unified search before Stage 1 summarization.
Removed
  • The /providers account card no longer writes accounts.loadBalancing: its b key and footer chip are gone, Settings → Providers → Accounts is the one writer, and the card reports the stored value.
  • Dropped the Ecosia web search engine; it answered a search with a Cloudflare challenge rather than results, and the Public Web aggregate now fans out to Startpage, Google, DuckDuckGo and Mojeek.
  • A launch from your home directory no longer prints the three-line notice about relocating to a scratch directory; the relocation is unchanged, and /cwd and the status line state the session's directory.
  • The tools.unifiedRuntime experiment and the runtime tool it gated. The unified tool never shipped enabled, duplicated the eval and launch prompts at 2,368 tokens, and the experiment was abandoned in favour of keeping eval and launch as separate tools.
Breaking
  • Veyyon source checkouts, Docker images, and published packages now require Bun 1.4.0 or newer.
  • Code a repository carries no longer runs because you opened the directory. A project plugin registry (.veyyon/plugins/installed_plugins.json) and an extension or hook file inside the working tree are withheld until the operator records a decision, per file, by sha-256. Both loaded during startup, before the approval rung, the working-directory boundary or the secret-use boundary could apply: a plugin registry names install directories that supply extensions, hooks, custom tools, slash commands and MCP servers, and an MCP server names a command to spawn and can put a ${ENV_VAR} credential in an HTTP header, so cd into a clone was the whole exploit. A project that was working through either route needs veyyon trust once. Paths outside the project root — profile extensions, installed plugins, a path you configured — are unaffected, and nothing prompts: a session that cannot ask loads nothing and reports which file and which surface it refused.
  • A stdio MCP server no longer inherits the environment veyyon was started with. It used to be spawned with { ...Bun.env, ...config.env }, so an MCP package — third-party code installed once from a registry and updated without being read — could read every provider key, cloud credential and CI token exported in that shell straight out of environ, without making a single tool call and with nothing in the product bounding it. A server now receives a baseline of what a program needs in order to run (PATH, HOME, temp, locale, certificate and proxy settings, and the directories version managers use to resolve a command; on Windows also PATHEXT, SystemRoot, ComSpec and the ProgramFiles variants, matched without regard to case) plus whatever env sets. An ambient variable a server legitimately needs is named in the new envPassthrough, and inheritEnv: true restores full inheritance for one server while logging a warning on every spawn. A server that was silently relying on an ambient variable needs that variable named.
  • The hook event session.compacting is now session_compacting. Its old spelling collided with the session settings root, so a hook subscribing to it was read as configuration under session and dropped. A hook file naming the old event is rejected with the new name in the error rather than silently never firing.
  • prompts/all-registries.ts no longer exports assertEvalPromptOverridesClaimed. The refusal it performed is assertEvalPromptOverrideIdsExist in prompts/eval-overrides.ts, which reads the generated id space instead of the loaded registries.
  • The welcome hero no longer animates in, so InteractiveMode.playWelcomeIntro, the suppressWelcomeIntro init option, the InteractiveModeInitOptions type and the setup wizard's playWelcomeIntro option are gone, and gradientLogo and gradientEscape take no shine argument.
Added
  • The HD recorder resolves every binary a take will need before it records one frame: docker, bun for the scene check, and ffmpeg and python3 for the publish chain, with ImageMagick accepted under either of its two names and bun looked for at ~/.bun/bin/bun when a non-login ssh shell does not carry it. A publish tool first called after the recording is a take lost to a PATH difference; a rehearsal, which publishes nothing, needs only docker.
  • bun scripts/verify-scene.ts <scene> checks a capture scene without recording it: every string the scene waits for must be produced by the prompt it submits, the product's own source, the sandbox seed, or a line the scene types, and anything else is declared in the scene with a # needle-source: line. A guard nothing produces does not fail fast — it waits out its timeout and the publish step then leaves the previous take's frame under that name. The HD recorder runs it as a preflight, refuses an endpoint that is not on the recording host, refuses a model row the server does not serve, and writes <scene>-model.txt beside the frames naming the row, endpoint and host the take was driven by. Two guards in the hero scene were already stale: the todo board carries no count in its header, and "Status: complete" is only drawn in a goal details panel the scene never opens.
  • veyyon trust and /trust decide what a project may run: bare reports, approve records the files exactly as they are now, --deny/deny remembers a refusal so the next launch does not ask again, --forget/forget drops the decision, and a named path decides one file — which is how a refusal that names a file the scan does not list gets answered without leaving the session. Decisions live in <agent dir>/project-trust.json, keyed by the symlink-resolved project root, so a symlink to a project is not a second identity; a store from another version, or one whose records are malformed, is discarded rather than half-believed.
  • /secret clear everywhere empties all three vaults in one command. Clearing was per vault and the only way to be sure nothing was left was to run clear global, clear profile and clear project and read three reports, so the question people actually ask — "is any of this still stored" — had no command. The new form names every scope in one report, including the ones that held nothing, and revokes every placeholder it removed in a single notice to the model. everywhere, all, everything and every all parse, and only on clear: they are refused on add, scope, rm and discard, where "all of them" is not a destination.
  • proof/zoom.py holds a recording on one measured region and eases back out, so a row whose subject is a small block of text survives the downsample from the 2560-wide capture to the published 1920.
Changed
  • The Subagents block above the composer is one row per running agent again — a mark, the agent's id, its spawn description and the model it resolved to — with the house rail as its left edge and no tree connectors. It had been rebuilt as a table of lanes with an id column, a model column against the right margin and a live activity column resolving recovery over tool over description, and the table said less than the short list it replaced: three padded columns read as a grid to scan, the activity column drew whatever text a tool call happened to carry, and a bun -e command with a real newline in it put the tail of that command outside the block. Every cell folds newlines to spaces before it is bounded, because a bounded width states nothing about how many lines a string occupies, and the row never draws a task's prompt.
  • Light travels down the rail of the Subagents block, and only the rows whose agent is inside a tool are lit, so the sweep is a count of what is working rather than a decoration on the block.
  • Every tool result block hangs its output from the same rail and draws no tree connectors. Grep, ast-grep, glob, the file list, web search, the IRC renderer and the diagnostics list each drew ├─, and └─ to nest rows that are not a hierarchy, which put a second vertical edge inside a block that already had one; nesting is two spaces of indent instead, and an IRC message body no longer carries a quote glyph of its own either.
  • A block's header row sits on the rail rather than at column zero, so one left edge runs from a block's title to its last row of output. The title used to start two cells left of the rail beneath it, and a status icon at the top of the spinner ramp is a full cell where the rail is an eighth of one, so the two stacked read as a chip balanced on a wire.
  • The rail's travelling light advances by elapsed time instead of by a count of the repaints a block managed to get. A tool printing output holds the loop, several setInterval callbacks land late or coalesce, and the highlight stalled and then jumped — the hitch was worst exactly when the terminal was busiest. Every rail also reads one monotonic clock, so two blocks on screen carry the same head instead of the board's rail crawling at a divisor of its own.
  • The memory backend's start finishes behind the first frame instead of in front of it: a session hands it to AgentSession.deferStartupWork, and the first turn awaits it, so every tool call and subagent spawn still observes an installed per-session state.
  • A session no longer builds every prompt registry in order to validate an environment variable: the eval-override refusal reads the generated id space at prompts/ids.generated.ts, which takes prompt assembly from 718 reachable modules to 528 and accepts an id owned by a sibling package whatever the import order was.
  • The launch hero is a still card. The sun used to bloom open and the wordmark reveal behind a 33 ms timer for 2.2 seconds before the screen settled, and display.transitions no longer governs it; overlays and the tool rail still read that setting.
  • The launch card is painted before the session is built rather than after it: an interactive launch reaches the first frame in 3.7ms of paint, where the plugin-root preload, extension and skill discovery, the model registry, the MCP connections and the interactive mode's own mount all used to run against a blank terminal. The mode adopts that screen and that card, whose model line and recent session fill in when the session resolves them, and the theme ground goes on with the card.
  • The memory LLM has one owner. loadMnemopiConfig built a second, unsanitised remote client from the mnemopi.llm* settings that every session overwrote, and the paths that load a config without resolving it (dispose, diagnostics, the stats memories) kept it -- a client with no credential resolver and no provider-text obfuscation. A config carries the request (config.llm); the client is built only in resolveMnemopiProviderOptions. The memory_edit schema is derived from the store's own operation list instead of restating it.
  • The advisor's usage-limit remedy and the api-key resolver ask AIError.isUsageLimit(error) rather than re-deriving a quota verdict from a status and a message. Both had their own copy of the expression, and the resolver's copy read one predicate while rotateSessionCredential read another, so a bare 429 with no body could rotate on one side of the same failure and not the other.
  • The container scan behind the re-root hint reads one round of directories per depth instead of one directory at a time, so it costs the height of a tree rather than the sum of its directories. It runs during startup as part of the system prompt, where it measured 169ms of a 215ms prompt build; on this workspace with a warm cache the scan is 46.3ms before and 16.1ms after, and the whole predicate answers in 23.2ms including the git check-ignore call it batches. The project-marker stats are one round as well, and the repository marker's answer is reused by the container question rather than asked for twice.
  • A discovered config naming an environment variable that is not set now says so, naming the file, the entry field and the variable. ${VAR} expansion re-emitted the literal ${VAR} for an unset variable, so an unresolved reference was indistinguishable from config text: the eight providers that expand a config (native, Claude, Claude plugins, Cursor, Gemini, OpenCode, SSH, Windsurf) passed it on and reported nothing, and an MCP server with a mistyped or unexported variable failed to start with no line anywhere naming the cause. Expansion now reports what it could not resolve to a sink the caller supplies, and the parameter is required, so a consumer states what happens to an unresolved reference rather than inheriting silence: discovery turns it into a warning, and the MCP connect guard stays the enforcement point that refuses a structural field, reading the same grammar from the expansion instead of its own copy. No value is ever quoted in the warning.
  • Config values (models.yml apiKey and headers, MCP env and headers) accept ${NAME} / $NAME for an environment reference and literal:<text> for verbatim text. A bare value shaped like an environment variable name (upper case, digits, underscores) is still read as a reference, and now fails closed when the variable is unset or empty; every other bare value keeps its env-then-literal behaviour, so keys such as sk_live_... are unaffected. A key that is genuinely upper-case text rather than a variable name is written literal:MY_KEY.
  • The pi.registerProvider API example states the credential grammar it depends on. It showed apiKey: "GOOGLE_CLOUD_PROJECT", which now reads that environment variable and resolves to nothing when it is unset, so an extension author copying the example got a provider with no credential.
  • Republishing a whole session no longer holds a second copy of the transcript, and no longer reads the file back to learn nothing. A rewrite (compaction, elision, a title-change fallback, a recovered write fault) built the body by string concatenation and handed the filesystem one flattened string, then re-read the file first in case another window had appended to it: a 253MiB transcript of 118,358 entries spent 747ms, 1056MiB of peak resident memory, and 604ms of that in one unbroken stretch with the event loop stopped, 246ms and 322MiB of it on a read whose usual answer is that nothing changed. The body is now produced in chunks of about a megabyte by a factory the storage backend can ask for twice (the Windows EPERM fallback writes the same body a second time), each chunk written as it is produced, and the read-back is skipped while the file is still the same inode at the same length this session published. The same rewrite is 509ms, 44MiB, and no pause longer than 3ms; fork() on the same transcript is 515ms and 158MiB against 558ms and 612MiB. A second writer still changes both the inode and the length, so its entries are still read back and kept.
  • Restoring a session reads its externalized payloads under a bound, and a session with none pays almost nothing to find that out. The load walked the whole transcript through Promise.all over every array element and every object key, awaiting at each node: 2,000 ordinary tool entries with no externalized payload spent 17.9ms and about 27MiB of churn to discover there was nothing to read, now 1.7ms and 5MiB. A transcript with 200 externalized 512KiB payloads opened all 200 files at once and held 200 decoded buffers beside the 200 strings they decode into, peaking 122MiB above the transcript it produced; the restore now collects every reference in one synchronous walk and reads them eight at a time, which is 30.8ms against 50.7ms and a peak 100MiB above a 100MiB payload, with the event loop free to run while the reads are in flight.
  • A diff no longer rebuilds the two files it was handed in order to look up bracket context. generateDiffString split both sources into lines, and the boundary lookup joined each line array back into a source because it was not given one, so every diff copied both whole files a second time: 18MB of copies for one preview pass on a 9MB file, and a streamed edit preview does that pass again each time arguments arrive. The lookup now receives the text each side already holds.
  • A streamed edit preview no longer re-reads and re-parses its whole target on every chunk the model types. The replace mode's preview read the file again each pass, and both diff generators looked up their bracket-context rows by scanning the entire source twice per pass — once for the file on disk, once for the file the edit would produce — which the native parse cache cannot answer above 4MiB because it retains nothing that large. Streaming a one-line replacement against an 11.7MiB, 100,000-line file spent 1.9s per pass, so two seconds of 30Hz arguments landed 4 previews, read 46.8MiB, burned 7.8s of CPU, and showed a preview 2.1s behind what had been typed (3.8s at the tail), with the args-complete pass blocking the event loop for a further 1.9s. The streaming pass now reads through a cache keyed by modification time and size, the args-complete pass still reads fresh, and a source over the size the parse cache retains renders without off-window boundary rows instead of paying a whole-source scan per redraw. The same two seconds now land 49 previews, read the file once, spend 2.4s of CPU, and stay 36ms behind the stream with a worst case of 103ms; the final pass costs 36ms. A read window on a file that large stops paying the same scan.
  • The MCP loader no longer imports a type it stopped using when it started forwarding discover options through one object. No behavior change; the workspace lint gate was failing on it.
  • The first frame no longer waits for a hardware probe. Building the system prompt looked up the GPU name, and on a cold cache that lookup spawns lspci or nvidia-smi: 224-557ms measured on one workstation, spent before anything is drawn, for a prompt line no frame displays. The probe now runs unwaited and writes its cache for the next launch, so exactly one launch per machine omits the GPU row and every launch after it has the name from its first build. The answer a launch starts with is the answer it keeps: a probe landing mid-session does not add the row to a later build, because that row lives in the cached prompt prefix and re-anchoring the prefix costs more than the line is worth. scripts/bench-startup.ts measures the boot path and docs/internal/startup-budget.md records the baseline it was measured against.
  • A session that died on an uncaught exception is logged as an error, and a terminal that closed with nothing in flight is not logged as a warning. The session_exit record carries two facts — the kind of teardown and how many tool calls it orphaned — and its severity was chosen from the first one alone, so warn covered a crash and a closed window equally: across 19 local log files, 23 exits were recorded at warn, 17 of them a sighup with no pending work and 4 of them fatal. The level is now error for an unhandled throw or rejection, warn when tool calls were left without results, and debug for a signal, a process.exit or a normal dispose that lost nothing. sessionExitLogLevel in session/exit-diagnostics.ts owns the ladder.
  • The anchored HUD stops animating when the mode it belongs to stops. Its motion frame was unref()ed, which keeps a timer from holding the process open and does nothing to stop it firing, and nothing disarmed it on teardown — so a stopped mode went on stepping its rails, rendering both blocks, and reading the settings singleton for as long as the process lived. Teardown disarms it, and a torn-down mode refuses to arm a new one, which matters because every board write runs the arm site.
  • The working directory leaves the cached prompt prefix. The base system prompt is what the provider caches, and it stated the directory verbatim — "Today is <date>, and the current working directory is '<path>'" — so a re-root that changed nothing else still discarded the cached prefix for the whole conversation to restate one path. Measured on this repository, moving from the root to packages/utils altered exactly one line of a 92,921-character prompt; across 19 local log files, 210 of 232 recorded invalidations were a cwd-change, about 85,000 characters re-read each time. The date and the directory now arrive as a session-state message with the turn, restated only when they change, the way recalled memories already do. The rebuild on re-root is unchanged, because the rules, skills and workspace tree really are directory-derived — what changed is that a move which alters nothing but the path rebuilds to byte-identical bytes and records no invalidation, while a move to a project with different rules still does. The "not a project root" paragraph also stops naming the path, since the message already carries it. Two directory-derived facts stay in the prefix and still invalidate on purpose: the workspace tree, which is a picture of the directory and is off by default, and the active-repo-context block.
  • A discovery failure from software nobody started is no longer a warning. Model discovery runs against every provider veyyon knows about, three of which are local runtimes it probes on loopback without being told to, so a machine that does not run llama.cpp, LM Studio or Ollama collected a warning per refresh for each of them: 110 of 137 model discovery failed for provider records across 19 local log files were a refused connection to a port with nothing behind it, and the 27 records that named something actionable — an Anthropic 404, six xAI OAuth 403s, an aborted Devin request, one llama.cpp 502 — were four fifths buried. A provider with a stored credential, an explicitly configured base URL, or an endpoint that is not on this machine still warns with every field it carried before, and so does an unconfigured one whose endpoint answers at all, because something is listening and is broken. A loopback port that refuses the connection is recorded at debug. The three implicit local runtimes are one table rather than three near-identical blocks, so a fourth is exercised by the same tests the moment it lands.
  • Image generation is disabled by default. Enable Generate Image in the Tools settings to expose generate_image.
  • The Todos board above the composer keeps the tree list it has always been — a header naming the phase the plan is on, one row per phase with its tally, and the tasks of the phase being worked nested under connectors — and gains a rail as its left edge. Every row starts on block.rail, which is the one arrangement the house rail animation can find, so light travels down the block while the plan is being worked and the rail is flat while it is not. The stages already finished are not drawn: the header's phase n/total states how far the plan has come, and a column of closed tallies made the block read as one undifferentiated chunk.
  • The task in flight carries one small square alternating between and , at the row it belongs to. A cell from the density ramp fills the whole cell and a terminal cell is half as wide as it is tall, so a ramp cell at the task indent read as a rectangle switching on and off, louder than the row it marked. Every other row keeps the checkbox vocabulary and is still.
  • The board moves only while the agent is streaming, compacting or running post-prompt work, and stops on the frame a turn ends or an interrupt lands. A task marked in progress persists across the turn boundary, so a board keyed on task state alone moved for as long as the operator sat reading a plan nobody was working on, and the anchored clock repainted two regions to draw the same thing. todoBoardMarkerAnimates and todoBoardRailTravels are the one place that is decided, for the mark, the rail, and the clock alike.
  • An open plan's transcript card is one line naming the header, the done count, the phase and the task the write moved, because the anchored board already draws the list and the two surfaces were drawing it twice.
  • A pending task a detached subagent is working on takes the accent and the in-flight mark, which is what states someone other than the main agent is on it.
  • The /secret help is grouped by the question being asked rather than by declaration order. One heading covered eleven verbs, three of which delete something — a secret, a whole vault, and a vault file that cannot be read — so choosing between rm, clear and discard meant reading all eleven lines. There are now four groups: storing a credential, seeing what you have, changing one secret, and removing secrets.
  • Every documentation page a user reads lives in one tree. Twenty-nine topic files sat flat under docs/ beside a handbook that covered the same subjects, so a reader looking for the settings catalog, the RPC surface, the theme schema or the keybinding defaults found a page that was in neither the handbook's table of contents nor its search index, and a contributor adding a page had two plausible homes and no rule for choosing. The flat files were reference material and their handbook counterparts were guides, so nothing was merged away: each one moved beside the guide it belongs to (reference/settings.md, reference/rpc.md, architecture/secrets.md, context/context-files.md), every one is in SUMMARY.md, and docs/README.md is the map of which tree a page belongs in. docs/internal/ stays internal, and the runnable extension, hook and marketplace examples moved to packages/coding-agent/examples/ because they are code.
  • There is one way to capture a picture of the product, and the documentation describes one. Two capture paths shipped side by side — a recorder driving a real terminal on a private display, and a tape renderer with its own baseline block — and each was documented as the only authority, so a before-and-after pair could be assembled from one arm of each and still satisfy the words in the repository. The tape path is gone: its tapes, its drivers, its container install, and the gallery --screenshot flag that fed it. A settings differential is now recorded from the same scene twice, seeded through SCENE_SETTINGS, and scripts/there-is-only-one-capture-path.test.ts fails on a tape file, a path named after the tool, a mention of it in any tracked file, or its reappearance in the recorder image.
  • Every extension path the operator named on the command line, in config.yml, or through the SDK is loaded as a named path rather than as repository code. The project-trust gate withheld any extension whose resolved path fell inside the working tree, which caught --extension ./my-ext.ts and the extensions config list as well as the files a clone ships, so a path the operator supplied had to be approved through veyyon trust before it would load. Named paths are now passed to the gate at every load site (the CLI, the SDK's two loadExtensions calls, the tool registry, and the three task entry points), and a subagent inherits the named subset explicitly instead of re-deriving it. test/security/a-path-the-operator-named-is-not-repository-code.test.ts pins the distinction, and a load site that forgets to pass its named paths gates more rather than less.
  • The repository rules and the documentation they point at are split by what they are. AGENTS.md states the rules and names the script that enforces each one; the mechanism and the history behind them moved to docs/internal/bun-surface.md (the Bun surface, its counts, the worker-hosting contract) and docs/internal/repo-gates.md (the two workflows, why the Rust gate sits in the fast one, why the changelog gate runs on push). The handbook lost its self-referential openers and its anthropomorphic voice: a setting sets, a command prints, a check rejects. docs/handbook/src/reference/settings-reference.md is generated, so its wording is fixed in scripts/gen-settings-reference.ts and regenerated.
  • A launch no longer builds a schema library it will not use. The theme reader, the config reader, the usage report and the five provider discovery readers each declared validators at module scope, so every session spent 362ms constructing them before a prompt was drawn, for files and requests most sessions never touch: importing main.ts measured 1411ms and now measures ~1262ms. The theme file is checked by validateThemeJson, which returns the missing tokens and the problems as separate lists — the reader used to recover the missing-token list with a regular expression over a validator's prose — and derives the required-token list from the ThemeColor and ThemeBg tables, so a new token is required without anyone maintaining a second list. test/startup/a-launch-does-not-load-a-schema-library-it-will-not-use.test.ts times a launch path's second import of the library and fails when a new module puts one back.
  • colors.toolText is gone from the bundled themes. Twenty-three theme files declared it, fourteen of them as an empty string, and nothing read it: it was not in ThemeColor, so the validator never required it and no renderer ever asked for it. A custom theme that sets it is unaffected, because an unknown color token still passes.
  • Every tool block's call row, result rows and renderer-less output hang from the same rail at the transcript's inset column.
  • An inline tool renderer's own leading indent is removed before its rows are framed, so every tool's title starts at one column.
  • The receipt row and the anchored boards start on the rail at the transcript's inset column instead of one cell inside it.
  • The Subagents block plays one rail sweep for the whole block instead of flashing the rail beside whichever agent last reported.
  • The rail's travelling light advances at four rows a second instead of eight.
  • A streaming edit or write block carries the rail light on its newest row rather than at a position taken from the clock.
  • An overlay's first frame is its settled frame: a card no longer unfolds row by row and no highlight sweeps across it as it opens.
Fixed
  • Memory credentials resolve through the config-value grammar. mnemopi.llmApiKey and mnemopi.embeddingApiKey were handed to Mnemopi as raw setting text, so ${VAR} reached the memory host as the credential and !command sent the command text; the failure arrived as a 401 naming nothing. A named variable that is unset now sends no credential at all.
  • A read that clips a wide line no longer blocks an insertion beside it. The read tool applies a per-line column cap, and a clipped row was recorded as neither seen nor anything else, so the edit tool refused every hunk anchored there — including INS.PRE, which leaves that row byte-identical. Adding one line above a multi-kilobyte row meant reading the whole row back with :raw first. The clipped rows are now recorded as clipped, which grants exactly one thing: INS.PRE and INS.POST beside them. SWAP, DEL and the .BLK forms on a clipped row are still refused, and a line the read never rendered at all is still refused for every form.
  • The anchored todos board's in-flight glyph stops one ink level below a full cell. It ran the status spinner's own density ramp, which peaks on once a cycle; in a dense status row a full cell is not the largest ink present, but on the board it is the largest ink any row draws, so at the top of the ramp the pulse read as a block appearing rather than as a cell breathing. The board's ramp is derived from the active one by dropping its top level, · : ░ ▒ ▓ ▒ ░ :, so a theme that overrides spinnerFrames is carried with no second setting to keep aligned, and a preset whose frames are not a density ramp — ascii's | / - \ — is left alone. The status line keeps the full ramp.
  • The collab guest allowlist no longer names a command that does not exist. theme was listed and is not a builtin, and help was listed as its own entry when it is an alias of /welcome, so a guest typing /help was checked against a name the registry resolves elsewhere. The allowlist is checked against the builtin registry, and test/collab/a-guest-runs-only-commands-that-exist.test.ts fails on an entry that names a missing command or an alias.
  • An MCP server whose config still holds an unresolved ${VAR} in command, args, cwd, url or envPassthrough is no longer started. An unset variable with no default used to stay in the value as literal text, so it reached the spawn as a program name or an argument, or a URL as a hostname, and the failure that followed named the variable's text rather than the field. The connection is refused first, with the field and the variable named and no value quoted.
  • A project-trust decision is written through the shared atomic-write helper, so a store cannot be left half-written by a crash or a full disk. It used to write in place, which is the one file whose corruption withholds every extension, hook and MCP server a project supplies.
  • The HD recorder resolves its own working directory and proves it can write there before it records. A container that could not write the output directory failed at the publish step, an hour into a take, with the frames already discarded. WORK_DIR selects the directory and a write probe runs in the container during preflight.
  • A config value that names an environment variable is no longer replaced by the variable's own name when the variable is unset. apiKey: GITHUB_TOKN or a CI secret that never reached the job used to resolve to the string GITHUB_TOKN, which veyyon then sent as the credential and the far end answered with its opinion of a bad token, mentioning neither the variable nor the typo. The value now resolves to nothing: a provider key is not installed, an MCP connection is refused before any request is made, and a warning names the variable and the setting it belongs to without quoting any value.
  • Stopping an MCP stdio server now ends every process it started. A server run through a wrapper (npx, uvx, docker run, a repository script) was left behind when the wrapper was killed, so each reload and each failed handshake leaked one server process. Teardown now signals the whole tree, escalates once if the tree ignores the polite signal, and is bounded so a reload cannot hang on it.
  • An MCP credential that cannot be presented refuses the connection instead of connecting without it. A revoked credential, a refresh token the auth broker holds and redacts locally, and a credential store that could not be read all ended at one log line, after which the connect went ahead with no Authorization header at all — so the operator saw the server's answer to an anonymous request (an HTTP 401, a provider error page, a lockout after enough of them) and nothing about the credential or the command that fixes it. Each state now names itself and the action: /mcp reauth <name> for a rejected credential, the same through the broker for a broker-held refresh token, /mcp reconnect <name> for a store that failed. Nothing is sent, so the far side gets no failed-auth attempt to count. A refresh that fails while the access token is still valid keeps connecting, and a server with no stored credential still connects as configured.
  • A short-lived MCP credential that rotates is re-read instead of re-sent. A !command header or env value — !op read op://vault/mcp/token, !gcloud auth print-access-token — was cached under the command text, which is byte-identical before and after the secret behind it changes, so once a value was cached the product kept sending it: the server answered 401, the transport retried with the same header, and only restarting the process helped. Worse, the auth-retry hook was installed only for servers with a stored OAuth credential, so a server authenticated purely by a command had no refresh path at all. A 401 or 403 now re-runs that server's commands and retries with what they print, /mcp reconnect <name> re-reads one server's credentials, and /mcp reload re-reads every configured server's; a command used outside MCP config, such as a provider apiKey, keeps its cached value. An automatic reconnect after a dropped transport deliberately does not re-read, because a lost connection is no evidence about a credential and a password-manager command re-run per reconnect is an unlock prompt per reconnect. Two endpoints answering 401 at the same moment is one rotation and costs one execution of the command, and a failing command's 30-second back-off is not shortened by any of this.
  • A misspelled per-tool approval policy blocks the tool instead of silently unblocking it. tools.approval.<tool> accepted allow, deny and prompt and returned "unconfigured" for everything else, so a hand-edited bash: denyy was indistinguishable from no policy at all — and on yolo, where an unconfigured tool is allowed, the typo auto-approved the tool it was written to stop. Any value present under that key and not one of the three now denies that tool, on every rung and under the /yolo session bypass; deny rather than prompt, because the bypass lifts a prompt. An absent key, an empty record, and a key whose value is undefined are still unconfigured, so a clean install is unaffected, and only the named tool is affected — the rest of the record still applies. A warning at startup names the setting, the value found, the fact that the tool is denied, and the three values that work, so a fail-closed policy is not a silent one.
  • The context gauge stops ignoring the provider count the running turn already produced. The in-flight estimate is kept only until a step of the turn reports real usage, and which steps count as "this turn" was decided by comparing an index against the count of everything the prompt submitted. A turn submits more than the question — the session-state line carrying the date and directory, recalled memories — so that count landed past the turn's own assistant steps and every provider figure they carried was rejected: a step reporting a 9,000-token prompt was reported as the 68-token turn-start estimate, and the gauge stayed there for the rest of a long tool turn. The boundary is now where the turn begins, and what the prompt already counted is identified by the message itself rather than by how many there were, so the tail is neither skipped nor counted twice.
  • A bounded range read touches the file once. read path:50000-50019 on a 3.5MiB, 100,000-line source read 3.08x the file's bytes: the line streamer scanned it for the window, bracket context read and split all of it, and the snapshot tag read and normalized all of it again. The window, the context rows and the tag now come from one materialization, which measures 1.08x. A file whose bytes are not already LF-only keeps the streamed window and shares only the text, because byte accounting counts source bytes and a CRLF line carries a byte the normalized one does not; a leading BOM is now detected from the bytes, since Bun.file().text() drops it and the streamer displays it. Wall time is unchanged: 93% of a large range read is tree-sitter parsing the whole file for bracket context, which is a separate cost.
  • A session booted against another profile's agent directory now discovers that profile's MCP servers. discoverAndLoadMCPTools copied three of its caller's discover options by hand and dropped agentDir, so the headless and SDK path connected the process-active profile's mcp.json instead of the one the caller named. The options type now extends the discover options and the loader forwards them by rest, so a field cannot go missing the same way again.
  • A goal set before the first turn is no longer deleted with the session file. A session whose journal held only the goal record plus a model pick counted as "draft-only metadata" and was removed on close once its draft was gone, which is the ordinary shape of a session where someone sets an objective and closes the window: the objective was the only copy and it went with the file. Whether a journal is droppable is now a question about the list rather than about one entry — a goal that is still the last mode change keeps the file, a goal the operator dropped leaves mode_change("none") behind and is selector state again, and a plan-mode entry written by plan.defaultOnStartup is dropped exactly as before.
  • A resume that declines to restore a goal says so, and turning Goal Mode off no longer destroys the objective. With goal.enabled off, a session carrying a goal recorded mode_change("none") on the next reconcile: not a suppression but a deletion, so turning the setting back on restored nothing and the session came back with no goal and nothing on screen or in the log saying why. The record now stays on the branch, inert, and the operator is told which objective is stored and inactive. A stored record the shape check cannot read is still cleared — nothing could restore it — and that clearing is reported too. Both records name which stored goal the decision was about, so a log read after the fact identifies the objective rather than only the mode entry.
  • An active goal keeps driving after a provider hiccup the session recovered from. A retry resumes the killed turn with a fresh agent_start, and goal mode reset its "did this turn call anything" evidence on every one, so a continuation that ran tools and then lost the transport came back as a retried attempt that only talked and was read as the model having nothing left to do: the goal stood down mid-work, the footline still said Goal, and the session sat idle until someone typed a message. Tool calls a killed attempt already made now survive into the retry of the same turn, auto_retry_start being the only notice the mode gets that the work continues, since the dead attempt's own agent_end is superseded by the recovery. A turn the mode does see end in error — retries spent — no longer decides anything either: it used to latch the stand-down permanently. Consecutive dead turns are tolerated up to three, then goal mode stops driving and says so, so a provider that is genuinely gone cannot spin the goal forever.
  • Why a turn was aborted has one name, and only an operator interrupt pauses an active goal. Every abort routes through one method whose goal-facing reason defaulted to "the operator stopped this", so a site that aborted a turn to do its own work and did not spell the option out paused the goal exactly as an Esc would; plan mode's silent approval abort was such a site. The two readings are now the exported GoalAbortReason, both seams take that type, the pause is decided in one place rather than in a fast-path condition that could not add a case, and a third reason cannot be added without classifying it — the sweep that exercises them is keyed by the union itself.
  • A verification stamp on an internal doc survives a documentation path rename. The freshness gate compares the doc's last commit date against its stamp date, so folding the flat topic pages into the handbook — an edit that rewrote link targets and nothing else — turned eight verified pages into "re-verify and re-stamp", for a change no verification covers: where a sibling page lives says nothing about whether this page matches the code. A stale stamp is now cleared only when the doc's text differs from the snapshot it was stamped against once every *.md path token is folded away, the snapshot being the commit that WROTE the stamp rather than the code commit the stamp names. Any prose edit still fails, a dropped path reference still fails, a stamp dated before the commit that wrote it is refused the exemption, and every page that keeps its stamp this way is printed by name.
  • veyyon prompt answers in the format it was asked for. --json was read fifth in a chain of early returns, so five of the six views the command has returned their text table before reaching it: --prompts --json, --prompt <id> --json, --tools --json, --section <id> --json and --statement <id> --json all printed padded columns and exited 0, and a consumer that asked for JSON got a parse error with nothing saying the flag had been dropped. Which view an invocation means is now decided in one place, separately from which format it is rendered in, so every view has a JSON form: the prompt list carries each id with its template path, the tool table carries the per-tool token split beside the prompt's own cost, and a refusal — an unknown prompt, section or statement id — carries its message and its non-zero exit as a parseable object. A rule this configuration leaves out reports present: false with the condition that would include it, rather than an error, because a rule that is off is not a failure.
  • A chosen web search provider is the only provider web_search uses. Picking one — through providers.webSearch or a per-call provider argument — hoisted it to the front of the full chain and left the other twelve behind it, so an engine that answered with nothing handed the query to a different engine: choosing DuckDuckGo could send the query to a keyed provider the operator was deliberately not spending, and choosing a keyed provider could fall through to a credential-free one that returns bot-challenge pages. A per-call argument for an engine with no credential skipped the choice entirely and ran the whole auto chain. auto is now the one value that ranges over the roster, one owner (selectSearchProviders) decides the list for both routes, and a configuration that cannot be satisfied — a provider chosen and excluded, or every provider excluded — is refused with a message naming providers.webSearch and providers.webSearchExclude rather than widened into a search nobody asked for. The zero-available message names the chosen provider and the credential it wants.
  • An eval-only prompt override reaches the model instead of only the inspection commands. VEYYON_EVAL_PROMPTS replaced text in the aggregate prompt registry, but a module that sends a prompt imports its row table directly, so the override announced itself and changed nothing a model was sent: with tools/bash replaced, veyyon prompt --tools still reported the shipped 971-token description. A benchmark arm built on that measures its own control while the results table names it a treatment. Every row table under src/prompts/ now applies the override where text is read, and the same command reports 4 tokens for the replaced description and 12757 rather than 13724 for the whole tool set.
  • A prompt override naming an id no registry holds is refused at prompt assembly, where every registry in the build is known, and reported with the nearest registered ids. The check used to live inside each registry, which cannot tell a typo from an id a sibling owns: @veyyon/ai's registry is constructed first and holds no tool descriptions, so a valid tools/bash override killed the process at startup. src/prompts/all-registries.ts is now the single owner of which registries a model can be sent from, and veyyon prompt --prompts reads the same list. The refusal names each unknown id on its own line and explains what an id is in the same words the bench runner uses for the same mistake, from @veyyon/utils.
  • sanitizeStatusText strips DCS, SOS, PM, APC, and 8-bit ANSI escape sequences via @veyyon/utils instead of delegating to node:util's stripVTControlCharacters, preventing string sequence payloads from leaking into status lines under Bun 1.4.
  • A setting the system prompt depends on rebuilds that prompt whoever writes it. Two owners decided that: the settings screen asked the prompt-gate registry, and the session asked a private table of eight paths that never restated six of the registry's live gates. So writing personality, tools.format, inlineToolDescriptors, includeModelInPrompt, tui.renderMermaid or tools.intentTracing from anywhere but the settings screen — a slash command, an SDK or ACP host, a plugin — changed the configuration and left the model reading a prompt that described the previous one, with nothing logged; and flipping one of the five paths both lists held rebuilt the prompt twice for one change. The trigger now lives once, with the prompt, in the session's effective-setting listener, and reads the registry for which settings reach the model. The session's own table keeps only the three that gate no prompt text at all (async.enabled, subagent.isolation.mode, subagent.maxNestedSpawnDepth, which decide the task tool's description and schema), and a failed rebuild is logged as a warning rather than at debug, because the settings screen is no longer there to report it.
  • Minimized shell output no longer carries the tail of an escape sequence as text. The minimizer's strip_ansi knew CSI and OSC, and every other escape fell to the rule that drops the escape byte and keeps what follows — so a command whose progress bar parks the cursor with ESC 7 handed the model 7…8, reset contributed a stray c, an editor's charset select contributed (B, and a sixel image or a kitty graphic arrived as its entire payload in the middle of a line. It now reads the same four grammars the TypeScript half reads, from the same cross-language corpus, and a truncated sequence still keeps every byte after the escape so a capture cut at a buffer boundary loses nothing.
  • A session now subscribes to settings changes unconditionally. The subscription was an optional call on a method Settings always has, left behind from a week when two suites built a session around a stand-in that lacked it: a real gap would have taken the prompt rebuild, the todo-reminder reset on disable and every live CPU-limit change with it, and said nothing. Those suites hand over a real Settings, so the only thing the optional call could still do was hide the next one.
  • read accepts a line selector on history://, so history://Scout:120-160 pages a transcript instead of answering Unknown agent: Scout:120-160 while listing Scout among the known agents. history was missing from the selector allowlist, so the whole <id>:<selector> string reached the protocol handler as an agent id; the error then pointed at the agent rather than at the selector, which reads as a lost transcript. history, issue, memory, pr and veyyon were also missing from the internal-URL prefix list, so those five were measured against the cwd boundary as filenames and had their backslashes rewritten as path separators. Both tables are now checked against the router itself.
  • A board that arrives already finished draws nothing rather than playing the exit sweep for rows it never showed. The clearing animation was armed whenever the incoming plan was complete and the previous one was empty, which is exactly the shape of a resumed session and of any single call that hands the HUD a closed plan, so the anchored region swept a strike across tasks and ran light down a rail that had never been on screen. The sweep now requires a board that was open on screen and closed in this update.
  • veyyon:// serves a page that moved. The resolver matched the exact path and nothing else, so a reference to a page by its old location — in a rule file, a saved session, a comment, an issue — answered "not found" for a page that is still shipped, from a new directory. An exact path still wins; a bare name that exactly one page carries resolves to it; and a name several pages carry is refused with both candidates named, because guessing between features/secrets.md and architecture/secrets.md is worse than saying which two exist.
  • The generated settings reference renders the pipes and placeholders its cells contain. A table cell is split on | before inline code is parsed, so the bashInterceptor.patterns default — which carries (cat|head|tail) — rendered as a dozen stray columns with most of its text dropped, and a <name> placeholder in a description was read as an HTML tag and deleted, leaving ~/.veyyon/personalities/.md as the instruction for where a personality file goes. Both are escaped where the cell is written, and the handbook parity check compares what the renderer prints rather than what the source spells.
  • veyyon acp run directly points at the log directory that exists. Its stderr notice, and the comments in the auth-broker CLI and the browser-open fallback, all named ~/.veyyon/logs/, which is the pre-migration bare-root path; logs are per profile, at ~/.veyyon/profiles/<name>/logs/, so a client integrator following the message found nothing there.
  • A misuse of the tab API inside a browser cell names the API instead of failing as a TypeError several frames deep. tab.$$(".row") answered tab.$$ is not a function and tab.hover(".btn") answered tab.hover is not a function, neither of which says what the facade does have; a call that omitted its argument reached the implementation and crashed on selector.trim() of undefined, which names a property of the argument rather than the argument. The facade is now wrapped at both call sites — the worker's tab API and the cmux run facade — so an unknown member reports the members that exist, with the closest known replacement spelled out for the two that were reached for by name ($$tab.observe() or tab.page.$$, hovertab.page.hover), and a missing, blank or non-string required argument reports which argument of which method is missing. Neither method was added: a new tool surface owes its own proof, and the guard points at what is already there. Language probes (then, constructor, toString, …) still answer undefined, so the object stays awaitable and inspectable.
  • A subagent that sends its whole yield result as a JSON string gets the result recorded instead of a refusal it cannot act on. result: "{\"data\": {…}}" answered result must be an object containing either data or error, which names no shape to send: one recorded child re-sent the identical stringified payload five times, rewording the prose around it and adding fields that were never the problem, and its parent got nothing. 38 sessions hit that refusal. The string is now parsed the way the argument-repair pass already parses a stringified argument object — yield is the one tool that sets lenientArgValidation, so it never reached that pass — and a result that still is not an object is refused with a message naming what arrived (a string, an array, nothing) and both accepted shapes. That refusal is also bounded now: only the empty-result branch counted against the retry budget, so a caller that kept sending a string could retry forever while its parent waited; the fourth consecutive one aborts the child.
  • Neither anchored block above the composer wraps any more. Both clamped every row to one cell inside the width they were handed, and both were handed the terminal's full column count — but they are mounted in a Text carrying a one-cell margin on each side, and that component soft-wraps its content two cells earlier than the terminal does. Every row was therefore two cells too wide, and the tail of it landed on a line of its own at the margin, outside the block's rail: a capture of two live lanes at 131 columns shows each lane's model badge alone on the line beneath it. Both call sites now derive their budget from the same margin the mount resolves, so tight layout is followed rather than guessed at. The widths were not the bug — both blocks obeyed the bound they were given, which is why a sweep of every column count from 1 to 220 could not see it — so the regression suite drives the real interactive mode and asserts the mounted block renders exactly the rows it emitted.
  • A model can now create the session's initial persistent goal directly from an ordinary task prompt. When goal support is enabled, the goal tool remains available before entry, while paused, and after completion; creating a goal persists goal mode, updates the status UI, and arms continuation without requiring the operator to run /goal set first. Plan and vibe modes still keep the goal tool out of their restricted tool sets.
  • Dropping a todo task is no longer counted as doing it. The closing tally counted every TERMINAL status as done, so op: "drop" on one of six tasks answered Dropped: Add error handling. Next: … Overall: 2/6 done, 4 open. on one line, claiming two completions where the board held one completion and one abandonment. The arithmetic was right — closed and open are complements — but the word was not, and a model reading its own board back could not tell work it finished from work it gave up on. A tally now reads Overall: 1/6 done, 1 dropped, 4 open., says nothing about dropped work when there is none, and admits only completed to the done side, so a terminal status added later lands in dropped rather than being absorbed into done.
  • A finished working loader is unmounted instead of merely stopped, so its row cannot outlive the work it was announcing. Clearing the loader dropped the reference and left the component mounted in the status container, which kept drawing ▌ <task> · 0:00 [esc] — a frozen clock and an interrupt hint for something that had already finished — on every subsequent frame. A stopped-but-mounted chrome row is byte-identical frame after frame, which is exactly what the renderer treats as settled content, so once the viewport scrolled past it the row committed into the terminal's own scrollback and stayed there permanently, wedged between two finished tool cards.
  • /secret list says what the composer chip is counting. The chip counts what the obfuscator will substitute and the list read the vault, so a session masking ten auto-detected environment values showed 10 masked beside "No active secrets. Nothing is being substituted right now." — both true of the same session, and no command in the product could name any of the ten. Values detected in the environment or declared in secrets.yml now carry the variable name or the file path as a label, the list reports them from the same counter the chip reads, and a value that arrived with no label at all is counted and reported as such rather than silently dropped from the total. A label is not a name: it makes a value findable and grants no #NAME# expansion. The empty-vault answer no longer claims nothing is being substituted while something is.
  • Every link in the documentation resolves. Folding twenty-nine flat topic pages into the handbook moved each one two directories deeper without rewriting the links inside it, so 76 internal links pointed at paths that no longer existed — a page about MCP configuration reaching for ../../../mcp-config.md, the advisor page citing source files three levels above the repository — and three anchors named headings the merged reference page never had. Each link is repointed at the page it means, and bun scripts/check-doc-links.ts reports none dead.
  • A wrapped row of a streaming diff keeps its gutter, so a line too long for the block still reads as one row of the diff.
  • A streaming multi-file edit preview is bounded as one block rather than per file, so the preview stays inside the window it is budgeted for.
  • The Todos board draws nothing rather than overflowing its mount when the terminal is too narrow for one task row.
  • The eval tool's trailing rows — the JSON tree, the timeout line, the notice and the warning — hang from the rail with the rest of the block.
Removed
  • veyyon gallery no longer takes --screenshot, --out, --font or --font-size. The flags rendered the gallery into a PNG through a second capture stack — a headless browser driving a tape recorder — that produced frames at a different terminal, font and colour configuration from the one every other proof uses, so two frames of the same surface could not be compared. veyyon gallery prints the gallery; a picture of a real screen comes from the recorder described in the handbook's verification page, and that is the only capture path.
  • The system prompt no longer computes a date it does not render. The date moved into the per-turn session-state message when the working directory left the cached prefix, and the date and dateTime values were still assembled for the prompt template on every build, where no section reads them. test/session/the-session-state-block-states-the-host-local-day.test.ts states which surface owns the date now: the block states the host's local calendar day, west and east of UTC, and one instant in two zones produces two different days.
Changed
  • The todo board is back to the tree list it was before 1.1.0: one status glyph per task, open work first, the phase in parentheses after the content, and the withheld count on the last row. The panel form shipped in 1.1.0 is reverted — Roman-numeral phase headings, per-phase fractions, the header progress gauge, the indented rail body and the fourteen-frame entrance are all gone. The board is static again: it is drawn once when the result lands and never repaints, so nothing on it moves after a write and no interval is armed for it.
Fixed
  • A todo call that names no operation is refused by validation instead of by the executor. op was plainly optional for one release so a Claude/Cursor TodoWrite payload (which carries todos and no op) would validate, and that made the schema lie about every other shape: {"task":"Scaffold"} and {"operation":"start","task":"Scaffold"} both validated CLEAN — a missing optional field is legal, and an undeclared key is not refused for an ArkType-authored tool — after which the executor answered Missing op; pass op explicitly, naming a field the call had just been told it could leave out. The repair layer reported the same call clean, so all three layers declined to act and a model that believed it had named its operation retried the identical payload. op is now required by a schema-level narrow that admits its absence only alongside todos, so the compatibility whole-board write keeps working and every other shape is refused where the repair loop and the model-facing error path can act on it. A narrow rather than a required property, and rather than a union of the two shapes, because a top-level union converts to anyOf and the Anthropic tool-schema builder reads properties off the root, so a union would advertise the tool as an object with no fields at all.
  • operation and action are repaired onto op for any tool that declares one (todo, goal). A model reaching for the operation field writes the whole word; the value used to sit in the call untouched, because an undeclared key on an ArkType-authored tool is not refused, and the tool then reported the field missing.
  • A todo call carrying an empty todos list is refused instead of guessed at. An empty container is not a read: with merge: false it says "replace my board with nothing", which is destructive, and with merge: true it says nothing at all. It used to be inferred as an init and could clear the board. The refusal names the operation that does mean it — An empty "todos" list cannot initialize or clear todos. Pass op explicitly: op "rm" clears the board — and the recorded board is returned unchanged.
  • An unrecognized op reaching applyOpsToPhases returns the board and names the op instead of returning undefined phases and crashing the next read of it. The tool schema cannot produce one, but the /todo slash command and any extension build ops themselves and never pass through it.
Breaking
  • BorderedLoader is renamed ComposerLoader and 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 imports BorderedLoader from @veyyon/coding-agent must import ComposerLoader instead; the constructor and the signal, onAbort, handleInput and dispose members are unchanged.
  • DynamicBorder is 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 add on its own opens the hidden field, /secret from-env <VAR> reads it out of the environment, and a bare /secret prints 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 lst stored the string lst and 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. Behind add a 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. add already 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-x verbatim, 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 --flag is gone from /secret, and every argument is a plain word. --from-env, --ttl, --scope, --limit and --name are 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 is 30m|12h|7d|2w|never or 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 PROFILE removes the secret named PROFILE and /secret extend NEVER 7d extends the one named NEVER -- 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 after add, 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 for add under another command and that word is then the credential itself.
  • from-env is a command of its own rather than a modifier on add. It sits beside add in 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 switches secrets.enabled on, and that opt-in read subcommand === "add", so a client -- whose only way in is from-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 add on 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, names from-env and the exact line to run, and repeats NEITHER word after add, because nothing distinguishes a name followed by a credential from a credential whose first word looks like a name. add stays 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 --flag is gone from /stats, /mcp and /ssh too, 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 8080 is told to write 8080. 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, and run hands 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: /stats reads one thing, and past its two positionals /ssh add reads only the literals user and key, neither of which is a run of digits.
  • /mcp and /ssh no longer take a scope on any surface, and project or user written 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.db now 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-less token, a misspelled tokn 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.
  • 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 showed Acceptance criteria:, then 1..., 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 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.
  • 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+o mark 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 setup could be read and not pressed. ← back, → skip step and esc leave setup (or ctrl+c leave setup when 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 /btw and /omfg transcript 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 /tree session 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 add wizard 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 continue on a text field, navigate plus enter select on a list, and esc cancel on the first step against esc back on 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. hitTestModalChrome reports hover-shortcut with 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-model no 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 classifies subagent.modelByDepth.
  • The transcript no longer blanks itself mid-stream. TranscriptContainer splices 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, with tui.scrollbackRebuild on (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.
Added
  • /stats now 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 /stats slash command" — and nothing in the product called either one: there was no stats entry in the declarations and none in the registry, so /stats answered Unknown command, Usage: /stats [<port>] named a command that did not exist, and the dashboard was reachable only as veyyon stats from 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 /pause screen 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 /copy picker 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;2 truecolor 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 over remove, 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 vaultless clear refuses 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 list still 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.loadBalancing is 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 carries on hold for <duration> · c lifts the hold, and c clears 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.tabAt was 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 --force and install.ps1 -Force install 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 as subagent.model. A row outranks subagent.model for a spawn at exactly that depth; depths without a row resolve as before (subagent.model, then the agent definition's model:, then the session's live model), and a row whose chain matches no available model refuses the spawn and names subagent.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 todo result reports the plan in one line of arithmetic instead of three, and lists only work that is still OPEN. Remaining items: 1. sat directly above Overall: 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 reads 5/6 done · 1 open · phase 2/3 Validation (2/3) followed by the open rows and a … N more open tail. 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 ahead inside the phase's own parentheses, on the results where it is true. The reported case drops from eleven lines to three.
  • A failed todo update 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/2 from 1/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/3 sat 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 line on add, rm and discard was false in the same change that added it; the footer lines and the sentence an operator is refused with now read on from-env, rm, clear, scope and discard from 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 /usage bars, /account and the account card's usage windows, /context, the tiny-model download row, and veyyon usage, veyyon tiny-models and veyyon grievances push each 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 through subCellBar now, 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: ascii gets # 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 SettleValue on the shared clock under the settle spring, 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 with display.transitions: off gets 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 /usage report, which is one string presented into the transcript once.
  • /resume, /tree, the branch-from-message card and /history cross-fade the pointer band like every other picker. Those four paint their own rows rather than building a SelectList, 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. renderOutputBlock drew 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 against outputBlockContentWidth counts the same rows; at the terminal edge it is the block's right-hand air that is dropped, never a column of text. bash-interactive keeps 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 outputBlockContentWidth is 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-interactive keeps 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. applyModalReveal runs 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 add wizard, 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 through setOnRequestRender built no fade at all, so the seam existed and the real host never reached it; and the /mcp add wizard 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 Agents section carrying an Agent Roster row, an Agent Delegation row beside it, and a roster screen headed Agents whose per-agent page was headed Agent: <name>, so one thing wore two names on one screen. The section is Subagents now, the rows are Subagent Roster and Subagent Delegation, and the screens read Subagents and Subagent: <name>. No setting key changed, so nothing in a config file moves.
  • Max Nested Spawn Depth sits in the Subagents section directly under Subagent 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, and Subagents, the door to what that level may spawn. Unset means the level above; the agent's own page unset means the blanket Subagent 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 → Enabled IS the depth limit, level by level, and the per-agent Max Nested Spawn Depth number is gone from every screen: a ceiling edited on one screen and read on another was the whole defect. A subagent.agents.<name>.maxNestedSpawnDepth already 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 blanket Max Nested Spawn Depth keeps 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 Depth and Show Resolved Model Badge sat in a separate Models section 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 the Subagents section now, and the Models section is gone. No setting key changed.
  • The subagent roster edits the model and the effort it shows. Its first two rows are Model and Effort, both labelled "every subagent", and each opens the same chain picker and the same effort list the tab rows open, writing subagent.model and subagent.thinkingLevel. The per-subagent page previously printed "Change it in Models · Subagent Model and Subagent Effort" and changed nothing; it carries its own Model and Effort rows now, which write that subagent's own row (see above).
  • Batch Delegation, Models by Depth and Show Resolved Model Badge are 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 Inherit for 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 enter preset, 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_COLS instead 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 selectedBg toward 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 transitionsEnabled and never call it, and spliceAtColumns in the theme no longer names a local escape over 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: spinnerFrame is 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 dim between 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, and display.transitions: off arms 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 ? left instead of 100% left when 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 words 100% left, in the one moment it knew least, while /context answered "usage is unavailable" about the same session. The unknown now travels: the status line's breakdown reports null, the gauge renders the ? left its own formatter could already spell but nothing could reach, and a collab host sends the null on the wire (where ContextUsage has always been nullable) rather than flattening it to zero for every guest. A real zero still reads 100% left, because that is the number. SegmentContext.contextTokens went with it: the 47k/170k readout 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 the none sentinel 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 secrets in a session whose /secret list answered one active secret, and both numbers were right: a session builds its protection from secrets.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 reads 1 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, in liveSecrets, 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, both write paths, all five ask prompts, both ast-edit paths, inspect-image and the search results — and on titanium that resolves to #202329 against a #1e2127 ground: 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-cosmos at one level and dark-lunar at one. renderOutputBlock now resolves the requested colour against the ground actually on screen and falls back to dim when the two are within twelve levels, so the repair lands once for every renderer and every theme rather than in the todo board alone; dim is the fallback because that is the colour every settled block already draws. A theme whose dim is 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 isTranscriptBlockFinalized is 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 a TodoStatus — the valid words are completed, in_progress, pending and abandoned — so it fell through to the satisfies never branch that exists to catch exactly this and rendered as pending. A settled gallery snapshot also no longer races a tick: for a success or error state 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 --scope or as the plain word scope. This covers /mcp add, /mcp remove, /ssh add, /ssh remove and /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 bare Unknown 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 add and /mcp remove also had the words written into their conditions (token === "project" || token === "user") beside a map that already listed them, which had drifted: it missed scope and transport, 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 to project, and wrote <cwd>/.veyyon/mcp.json through getMCPConfigPath("project", …); loadAllMCPConfigs reads no project-level source, so the file it wrote is loaded by nothing and the client was told Added 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's mcp.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 list no 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 for wipe, purge, empty and reset, fell through the command grammar to add and 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 flipped secrets.enabled on if the vault had been empty; /secret clear --all stored the literal clear --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 ModalShell the 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 importing MODAL_SIZING_MEDIUM, sizingForArea and computeModalDims and 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.
  • clampLow from @veyyon/utils now does the block separator's width clamp, and the subagent settings reader uses the shared isRecord. 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.paintGround defaults to auto, 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 #090401 between a #1c1f26 page and a #231310 band. 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 /context panel and the compaction trigger count a @file mention and a $ python cell. Both roles reach the provider — a mention as a developer message 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 read 61% left while 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 update writes the same provisional record, and reuses the hash it took of the staged download instead of re-reading the installed file.
  • install.sh --uninstall and install.ps1 -Uninstall reclaim 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 --uninstall exits 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 and install.sh --uninstall && ... read it as a failure.
  • veyyon update reclaims 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 update says what it did when no theme has been loaded. The success line and "Already up to date" read theme.status.success off a binding that holds undefined until initTheme() assigns it, so a caller that drives the update flow without a theme — an SDK embedder, or a test child — died with Cannot 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-k3 declares low, high, max and cloudflare-ai-gateway/moonshotai/kimi-k3 declares max alone, 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 like cursor-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; and auto's own row description names the levels in scope rather than the vocabulary's low–xhigh, since a description is as visible as a label.
  • The composer chips answer a click in a session that never scrolls. escape interrupt, ctrl+b background and alt+up dequeue were 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.
  • The hook selector is a floating card and answers the pointer. It is the surface behind every ask question, every extension ui.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. The outline option 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.input and ui.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+q and ctrl+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 find whose 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 -printf formatter, -delete, -exec and the permission matcher rather than at the one site the crash was first seen.
Removed
  • PluginSelectorComponent is 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-drift rule 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, the commit.nudgeAfterFiles setting, and the rule's dedicated suites go with it; a persisted commit.nudgeAfterFiles key loads harmlessly (unknown keys are preserved verbatim, not errors).
Fixed
  • A recursive delete through a variable nobody set no longer stops a yolo session. rm -rf "$BUILD_DIR", rm -rf "$CARGO_TARGET_DIR", rm -rf "$WORKTREE" and rm -rf "$checkout" were all critical, the one verdict /yolo cannot lift and no standing grant can cover, so the most ordinary cleanup an agent writes ended an unattended run — and the reason given was that the variable MIGHT hold /. It does not: an unset name expands to nothing, so the guard was refusing a command on a guess about a value that does not exist, at the only severity that cannot be overridden. A floor that fires there is a floor an operator switches off, and then nothing is left for the real rm -rf /. An unsettled expansion now earns destroys only where no assumption is needed — the EMPTY reading, which is what an unset variable already is, so rm -rf "$OUT"/* (the July 2026 incident, dir expanded to nothing), rm -rf "$D"/ and rm -rf "$D/lib" all still stop even yolo — while a bare rm -rf "$D", catastrophic only if the name turns out to be / or the home directory, drops to dangerous: still a prompt at every rung below yolo, no longer a claim to be as certain as rm -rf /. A word that spells a protected component itself keeps the floor whatever the prefix expands to, so rm -rf "$D/.ssh" is refused exactly as rm -rf ~/.ssh is.
  • Two words the guard reported as "unknown" carry opposite evidence, and reading them as one shape is a fail-open hole. A variable NOBODY SET expands to nothing. A variable whose value the scan READ and declined to paste is going to become a real path: rm -rf $V with V="/*" globs to every top-level entry, ${NOPE:-/} carries / in its own text, cd / && rm -rf $PWD names a variable the shell maintains and which is / by the time it runs, and ~user names another account's home directory. Each of those was measured deleting the root with no prompt at any rung before the guard read the environment at all, so ExpandedWord now records WHY a word is unknown and every one of them keeps the destroys floor. Only the genuinely-unset case is treated as speculative.
  • A recursive delete through a variable is judged by the path it can become, instead of being refused for holding a variable at all. rm -rf "$DST/facet" was critical, which is the one verdict /yolo cannot lift and no standing grant can cover, so on a long unattended run every variable-shaped cleanup stopped the agent dead — and the reason given, "an expansion whose value is not knowable from the command text", was true of an ordinary staging command whose worst possible reading is /facet, a top-level path the literal spelling rm -rf /facet has always been allowed to delete. An unsettled expansion is now instantiated with the three values that make a path dangerous (empty, the root, the home directory) and each concrete result goes through the same classifier a literal path does, so the refusal names the reading it is about: "rm would recursively remove a protected system directory (/) when the expansion this command line does not settle is empty". Every incident shape is still refused, including the July 2026 one: the bare word reads as /, "$dir"/* reads as /, "$dir"/lib reads as /lib, and "$D/.ssh" reads as the credentials directory. A value that CLIMBS is the residual this accepts, and it is named where the rule is written.
  • A glob is judged as the directory it reads, which closes the literal half of the same hole. rm -rf /*, rm -rf ~/*, rm -rf /var/* and rm -rf ~/.config/* were all allowed, because the text /var/* equals no protected root, is not an ancestor of the home directory, and sits under none of the protected directories — so each of them destroys a directory whose glob-free spelling has always been refused. A component holding a glob is now dropped together with everything below it and the directory the glob reads is judged instead. A glob inside an ordinary directory (rm -rf ./dist/*, rm -rf /var/log/nginx/*) is untouched.
  • rm -rf "" no longer reports the working directory as its target. A word an expansion collapsed to nothing was resolved against the working directory like any relative path, so a cleanup run from the home directory was refused as a delete of the home directory itself.
  • The re-root nudge waits for a habit and then says it once. cwd-reroot fired on the FIRST call naming a path outside the working directory — one glance at one file in another project — and its own body then told the reader to ignore it if the read was a one-off, which is a paragraph of advice arriving before the behavior it is about exists. It repeated after a gap of eight messages on top of that, so a session that reads across two projects heard it several times about work that had already moved on. It now declares warmupMatches: 3 and repeatMode: per-compact: silent until three separate calls have reached outside the working directory, and then silent until the transcript is replaced.
  • A rule may declare warmupMatches in its frontmatter: how many distinct streams it has to match in before it fires at all, default 1 (the old behavior, unchanged for every rule that says nothing). The unit is the stream rather than the match, which is the whole difficulty — one tool call is re-matched on every delta it streams, so a warm-up counted in matches clears inside the first call and the rule fires exactly as early as it did before, with nothing reporting it. A count is set aside when the reminder is claimed and restored if that claim is released undelivered, so an aborted turn costs neither the reminder nor the evidence for it; delivery starts the count over.
  • veyyon update replaces the binary that is running, instead of whichever copy of the name PATH resolves first. Measured end to end: a 1.0.47 binary installed under a sandbox home reported New version available: 1.0.48, Checksum verified and ✓ Updated to 1.0.48, yet stayed at 1.0.47 — while a different install nobody had named was overwritten with the 1.0.48 release asset. Both halves are silent, and the machine that produces them is ordinary: the installer prints a warning by name whenever it installs somewhere that is not first on PATH. The target now comes from the running executable whenever this process is the shipped binary, and an install with nothing on PATH at all no longer fails with "Could not resolve veyyon binary path in PATH". A source checkout still resolves its launcher through PATH, because bun sits at the executable path there and a checkout updates by advancing itself.
Removed
  • buildRuleFromMarkdown in capability/rule.ts, a second copy of the rule-frontmatter parser that nothing imported. Every provider, the TTSR CLI and the generated-rule path all go through the one in discovery/helpers.ts, so the copy's only remaining effect was to look like the place a new frontmatter field belongs: a field added there parses in nothing and the rule silently ships without it. parseRuleConditionAndScope, which helpers.ts does import, stays.
Fixed
  • Publishing a release no longer reports a correct deployment as a failure. The last step of a release asks veyyon.dev whether the new version's card is live, and it looked for <h2 id="1.0.47"> while the site writes <h2 id="v1-0-47">, so it could not pass on any release: 1.0.47 was published, the page was already serving its card, and the run retried twelve times against a site that was right, failed, and filed a release-train issue saying the release had stalled. The anchor has one owner now — the changelog generator exports it and the check imports it — and the check's own tests build every fixture from the real renderer instead of restating the format, which is how both halves came to be wrong together. The link the CLI prints after veyyon update and from the rollback picker is pinned to that same anchor, since it ships in the binary and cannot import the site tooling. Nothing in the CLI itself changed in this version.
Breaking
  • The orchestration magic keyword is orchestratez, not orchestrate. orchestrate is an ordinary English verb, and the notice it attached tells the model to drive the work as a multi-phase parallel subagent run and to override any tendency to do it inline, so "orchestrate the release", "please orchestrate this migration yourself", and even "do not orchestrate anything, just fix the one file" each changed how the turn ran. The notice does not display, so nothing on screen said why a one-file fix turned into a fan-out. A magic keyword has to be a token nobody types by accident: ultrathink is not a word and workflowz is deliberately misspelled for exactly this reason, and this one now carries the same z. The editor glow follows the trigger, so the ordinary verb no longer glows either, which is what makes the affordance visible before you send. Settings → Interaction → Magic Keywords → Orchestrate Keyword (magicKeywords.orchestrate) is unchanged and still governs it.
  • /thinking is now /effort; /thinking remains as an alias. One axis, one name: the two commands always set the same session reasoning effort, but having the concept's primary spelling differ from every surrounding surface — the settings row is "Default Effort", the status line says "effort", the schema key is defaultEffort — kept two words in circulation for one knob. Scripts, muscle memory, and ACP clients typing /thinking are unaffected; the alias is permanent. What ACP clients see changes in one place: the advertised command list names effort, with the same [level] hint.
  • argot-load-nudge ships OFF. It sits in the new experimental/ rule section, and everything in that section is off until named in ttsr.experimentalRules, so a session that used to receive the nudge no longer does until you opt in. Turn it back on in Settings → Rules → Rules → Built-in · Experimental, or by adding argot-load-nudge to ttsr.experimentalRules. The rule was already gated by the Argot master toggle, so nothing about it was reaching a project with Argot off; what changes is that a project with Argot ON now has to ask for it. A rule injects text into a live session on the model's behalf, which is the operator's context being spent, so an unproven one shipping on was the wrong default to have picked.
  • /providers opens the account manager instead of the onboarding wizard. It was an ALIAS of /setup, so the name a user reaches for to inspect their accounts opened first-run provider setup: one row per provider carrying a bare logged in tag. The credential store has always held several credentials per provider, so that screen could not answer any of the questions actually being asked of it: which of three Anthropic accounts is spending this session's tokens, whether one of them is failing, or how much of each account's quota is left. /setup is unchanged and keeps its providers subcommand, so onboarding is exactly where it was; only the name that suggested account management now leads there. There is deliberately no singular /provider.
  • Goal model budgets are now default-off and controlled only in Settings → Tasks → Modes → Model Goal Budgets. /goal budget and the model-facing token_budget argument were removed, so a model cannot raise its own allowance or turn the policy on.
  • The install script no longer builds from source, and --source (-Source on Windows) is no longer an option. It cloned this repository into ~/.veyyon/src and built there, which left a second divergent copy of the product on the machine, so a curl install now downloads a verified release binary or fails. --ref names a published release tag only; it used to imply a source install for anything else. To run an unreleased ref, or to work on Veyyon, clone the repository yourself into a directory you choose and run bun run setup in that checkout. An unsupported platform, a musl system, or a tag with no published release now says exactly that and hands over those commands instead of offering a flag that clones. Uninstall still finds a ~/.veyyon/src an older installer left behind, and still moves it aside rather than deleting it when it holds uncommitted edits or unpushed branches.
  • veyyon update guidance for a source install no longer tells you to re-run the installer with --source. A checkout you own updates with git pull && bun install, and the rollback refusal explains the fast-forward constraint without naming the removed flag.
  • Every mid-session prompt-cache invalidation is now attributed. refreshBaseSystemPrompt took an optional reason that defaulted to unspecified, and the callers that omitted it were the frequent ones: a cwd re-root, a secrets refresh, a memory clear, and memory startup. A measured session recorded four consecutive unspecified entries, each a roughly 32k-character prompt rebuild, so the record proved the prefix cache had been discarded and could not say by what. The reason is a required parameter now. The last hole was one layer down: ToolSession.refreshBaseSystemPrompt still declared no reason, so argot_load and argot_unload rebuilt the prompt from inside a tool call, with the whole conversation already behind the prefix, and recorded reason: undefined. That signature requires a reason too, and both tools name themselves. A model switch also stopped reporting itself as edit-mode-change: the edit variant is not a prompt gate and never re-resolves outside a model switch, so the label sent readers chasing a settings flip nobody performed; it now names the inputs that actually moved.
  • Tool approval is a real gate. tools.approvalMode defaulted to yolo, which opts out of ALL permission, so the ladder, the interactive prompt, the per-tool tools.approval policies and the working-directory and credential boundaries all existed and none of them ever fired. The default is auto: every tier still runs unasked, so the common loop is unchanged, but the guards are back on, and a per-tool policy, a path outside the working directory, a call carrying a real credential, or a tool's own critical call now stops and asks. An operator who wants a stricter rung says so once, in onboarding, in /settings, or for one session with /permissions. The ladder itself was reshaped to four rungs an operator can hold in their head: Ask everything (ask, every call asks, reads included), Ask commands only (ask-command, reads and edits run, anything that executes asks), Auto (auto, every tier runs with the guards still on, and the rung a fresh install starts on), and Yolo (yolo, nothing asks except blatantly destructive commands and an explicit deny). ask no longer auto-approves reads, and the legacy names keep working: always-ask is ask, and write and auto-edit are both ask-command.
  • /mcp has no project scope. --scope is gone from /mcp add, /mcp remove and /mcp smithery-search, the wizard's "Configuration Scope" step is gone, and every read and write the command makes lands in the active profile's <agentDir>/mcp.json. project used to be the DEFAULT for /mcp add, so a quick add wrote <cwd>/.veyyon/mcp.json, and server lookup also resolved <cwd>/mcp.json and <cwd>/.mcp.json. Discovery had already stopped loading all three, which left the worst possible split: /mcp add wrote a file no session would ever read, while /mcp test and /mcp reauth would still CONNECT to a server a repository declared and /mcp enable would write enabled: true back into that repository file. Typing /mcp test is not consent to reach a server you never configured. /mcp enable <name> on a server that only exists in a repository file now says it is not configured, names the file that IS read, and states that a repository's mcp.json, .mcp.json and .veyyon/mcp.json are never loaded. Servers discovered from other tools' configs are unaffected, and /mcp list still names the file each one came from.
  • The mcp.enableProjectConfig setting ("MCP Project Config", *Load .mcp.json/mcp.json from project root*) is removed, along with the enableProjectConfig option on loadAllMCPConfigs, discoverAndConnect and discoverAndLoadMCPTools. It filtered MCP servers whose source level was project, and no provider emits one any more, so the toggle was a switch an operator could flip that did nothing. commands.enableOpencodeProject is removed for the same reason: .opencode/commands/ is no longer scanned, so the row governed nothing. commands.enableClaudeProject ("Claude Project Commands", *Load commands from .claude/commands/*) is removed on the same grounds, and it was the worst of the three: the value was read on every command load, returned from the toggle reader, and then dropped, because the only caller destructures enableUser alone. A repository's .claude/commands/ is repo-authored content and is not loaded at all, so the row promised a gate over a branch that does not exist. A stale key in an existing config.yml is ignored rather than an error.
  • A bare slash command that has subcommands opens a picker instead of silently running one of them. /account ran status, /permissions ran status, /usage ran show, /session ran info, /mcp and /ssh ran help, /shake ran elide, /memory ran view, and /collab started hosting a session, in every case with the chosen verb declared alongside the others as though it were an equal. Nine commands each taught you a different rule about what its bare form meant, and the one that started hosting was the least recoverable of them. Bare invocation now lists the subcommands in a card you navigate with the arrows, click, or dismiss with escape, and choosing one runs exactly what typing it would have run. Outside a terminal, in ACP and --print mode, the same list is printed. A subcommand that takes an argument prefills the composer with /cmd sub rather than running with an empty one. The commands whose bare form is not a subcommand keep it: /yolo, /fast and /browser flip a switch, /setup opens the wizard, /goal enters goal mode, /todo shows the list, /secret opens the masked field, /compact compacts, and /plugins lists. Those nine declare bareAction: "distinct", and each has to give a written reason in the enforcement test, because the dispatcher cannot tell an honest switch from a hidden default dressed as one. The guard sits in the dispatcher rather than in each handler, so a new command cannot reintroduce this: it either opts out on purpose or it gets the picker.
  • /logout and /login open the account card, and the dedicated provider-then-account logout picker is gone. Logging out is choosing an ACCOUNT, and that picker could show a label and a bullet: an operator holding a personal Max plan and a Team seat on one email address picked from two identical-looking names, with the destructive key as the first thing offered. The card is the only surface that says what each account is (its plan, its quota, whether it is serving this session, whether it is the one you chose), so /logout lands there with x armed twice, and /logout <provider> lands on that provider. /login with no provider lands there too, because the card's sidebar already lists every provider a login is possible for and shows the accounts you have while you choose. The two refusals the card cannot state are unchanged and still name where the auth actually comes from: a provider with nothing stored says so and names the env variable or config key that is authenticating it, and a bare /logout with nothing stored anywhere says to remove it at that source. /login <provider> is untouched: it runs the sign-in immediately and now ends on the card, focused on the provider it just added.
  • The provider picker signs in and nothing else. It carried a second logout mode that filtered the list to providers holding a credential, and a second hosting mode that drew itself as a floating card; with the account card owning logout, both were unreachable, and an unreachable branch in a component this small is a mode someone will wire up again by accident. What remains is the embedded sign-in list the setup wizard shows, which is the only thing that ever mounts it. A disabled provider's stored credential is still removable, which was the one guarantee the logout mode carried on its own: the card lists every provider the credential store reports rather than every provider still enabled, and that is now pinned as a test on the card.
  • compaction.modelFallbackStrategy gains any-model, and auto (still the default) no longer reaches a provider you did not name. The old auto ended with "the largest context window you have credentials for", so a session running one provider could summarize on another provider's key, unattended, on every threshold crossing: a Cursor session reported Auto-compaction failed: Summarization failed: 402 You have depleted your monthly included credits from a Hugging Face account it had never been asked to spend, and the message named no model, so nothing on screen said which account had been billed. auto now stops at the chain you configured, the same-provider compaction sibling the model's catalog row recommends, the interactive model, and the model roles you assigned. any-model is the old behavior, opt-in, for anyone who would rather compaction never fail than never touch an unnamed account. configured-only is unchanged.
Added
  • Every account row says what state its credential is in, on the one axis you can act on: valid, expiring, expired, rate limited, or refused by the provider on its last refresh. The card and the inline /account status block print the same sentence about it from one owner, each ending in its own remedy (press a on the card, /providers in text), and a row whose access token has run out with no refresh token stored wears the failure mark and needs attention instead of reading as a working account. The renewable form of the same expiry says nothing at all, because it renews itself on the next request and a warning under a working account is how a reader learns to ignore warnings.
  • r in the account card refreshes THE SELECTED ACCOUNT. The health probe is a sequential network round-trip per credential, so asking about the row under the cursor on a nine-account provider meant waiting behind eight accounts you had not asked about. From the add entry, which holds no credential, it still refreshes the provider's accounts, and the footer chip says which of the two it is about to do. Answers from earlier probes are kept for the life of the card, so refreshing one row no longer blanks the health mark and the usage bars on every row that had already reported, and neither does renaming an account.
  • The footline can report how many secrets are live in this session, as a secrets segment carried by every preset. It counts distinct VALUES the obfuscator will expand, read from that expansion authority after its own expiry sweep rather than from the vault file, so the count and the substitution can never disagree, and two placeholders sharing one value count once. It names the nearest deadline (2 secrets · 12m) only inside the final hour of a lease, because a deadline eight hours out is not a thing anyone can act on.
  • A second account for the same provider can be named as it is stored, right after the login that created it, rather than only from the account card later. The first account for a provider is not asked about, because there is nothing yet to tell it apart from. Esc leaves it unnamed and does not undo the login.
  • The one moment Account Load Balancing costs you something is the moment it says so. With the setting off, an account that hits its quota waits for its own window while its idle siblings sit there unused, and that trade is the point of the default, but it was made silently: the session simply stalled, and a setting nobody is told about is one they cannot revisit. Quota exhaustion that could have moved to an idle sibling and did not now warns once with the account that is out, when its window returns, how many other accounts are idle, and where the toggle is. Once per exhausted window, so a turn that retries into the same wall states it once, and not at all when nothing could have served (one credential, or every sibling already blocked) or when the setting is already on.
  • Settings → Appearance → Status Line → Composer Footline (statusLine.enabled), ON by default: the switch for the quiet metadata row under the composer (profile, model, account, secrets, mode, path, git, the context gauge, MCP boot health, the live draft token count). It ships on because that row is the only standing answer to which directory this window is pointed at, which branch, which model and mode are live, and how much context is left; an operator who wants a composer that carries nothing turns it off in one keystroke. It is read on every render rather than captured at startup, so toggling it lands on the next frame with the settings screen still open, and while it is off the work behind the row (git status, usage windows, the account inventory) is not done at all. Two things stay outside the switch: while the view is proxied onto an agent the row still renders the agent's name and esc to go back, because Esc changes meaning in that state and the badge is the only persistent thing that says so, and statusLine.sessionAccent keeps its settings row because it colors the editor border and the working-message accent rather than this line. The knobs that only describe the row (the preset, the thinking-level spelling) hide while it is off.
  • The footline can name the account that is spending, once a provider holds more than one. statusLine.showAccount ships OFF and Settings → Appearance → Status Line → Show Serving Account turns it on, behind that tab's Advanced fold and hidden entirely while the footline itself is off, because a knob for a row that is not on screen governs nothing. Load balancing is off by default, so exactly one of several stored accounts drains while the others sit idle, and the account identity reached the status line only as a cache key for the usage segment: ⏱ 5h 71% was rendered with no owner, three Anthropic logins, one percentage, and no way to tell whose quota was at 71 without opening /account. Turned on, the account segment reads as work (or as [email protected], whichever the account is called), sits beside the model in every preset including minimal, and is silent for a provider that stores one credential, so a single-account setup pays no width for it. It names what is SERVING rather than what was picked, because those differ exactly when a chosen account was blocked or revoked and traffic moved, which is the moment the line has to be right; it follows the operator's own name for an account, and a rename lands on the next render rather than whenever a cache happens to expire. Counted per provider, not per store, so one login each at four providers still says nothing. Off is the default because the chip holds a permanent slot on the one line that is always on screen to answer a question only an operator who moves between accounts asks, and /account answers that on demand for everybody else. The footline sheds segments from the right as the terminal narrows, so the width it holds is width the model, the mode and the context percentage are competing for, and turning it off skips the work behind it as well: the resolver stops walking the credential inventory rather than computing a label nothing draws.
  • Settings → Providers → Accounts → Account Load Balancing (accounts.loadBalancing), OFF by default. When one account hits its quota or rate limit, load balancing lets the session continue on another account of the same provider; with it off the session waits for that account's own window instead. Off is the honest default: every account here belongs to a real subscription with a real bill and a real terms-of-service, and spreading one session's work across them is a decision only the operator can make, which is how a work account otherwise ends up serving personal traffic with no way to un-spend it. The gate covers exhaustion only. A revoked or disabled credential still fails over, because a dead credential cannot serve the request whatever the setting says, and that move now announces itself: a notice names the account that could not authenticate, the reason, and the account that took over. The notice fires from the request that actually served rather than from the rotation that predicted it, so with several healthy siblings it cannot name the wrong one. The toggle is reachable from /settings and from b on the account card.
  • The account you choose for a provider is remembered for good, and by every profile. Pressing enter on the card used to write a session pin, which meant the choice was gone on the next veyyon and invisible to a second terminal — while the credentials it was made from live in one machine-wide store that every profile reads. The choice is now stored beside them in an auth_provider_selection table, keyed by the account's IDENTITY rather than its row id, so it survives a token refresh and a logout followed by a fresh login to the same account. It is per provider on purpose: several providers serve one session at once, so choosing an Anthropic account leaves Codex and Gemini exactly where they were. Switching also drops the session's sticky routing record in the same call, so the card can never show one account while another is still serving, and a selection naming an account that is no longer stored is deliberately kept rather than forgotten, because a re-login rewrites the row under the same identity and dropping the choice in between would silently move the operator somewhere else.
  • A subagent that sends a bare acknowledgement over irc is now told to say something the peer can act on, or nothing. Waking an idle peer costs it a whole turn, so Ack, Understood, On it and Quick update: still working on it spend a turn to convey nothing, and two agents trading them is how an IRC loop sustains itself. The new irc-signal rule is scoped to the irc tool, so it never fires on an edit or a file read, and it stays silent on a message that names a file, a location, or a decision.
  • The compaction loader now names a server-side pass while it runs: "Compacting context... (openai remote compaction)" for /compact and the same suffix on the auto-compaction loader. A remote pass is one provider round trip with no local summarizer behind it, and the only on-screen difference from a local pass used to be nothing at all, so a silent minute read as a summarizer grinding through the history on the operator's dime. The label asks willCompactRemotely(session) (modes/components/compaction-summary-message.ts), which reads the same two primitives the engine's #tryServerSideCompaction gate reads — the compaction.remote setting plus a session model whose capability data resolves a server-compaction transport — so the indicator cannot disagree with the engine about which path a pass will take.
  • backgroundAfter on the bash tool, the number of seconds one call may hold the foreground before it converts to a background job. It overrides Settings → Shell → Bash → Auto-Background After in both directions, so a model that already knows a command is slow can hand the turn back early instead of waiting out the shared default, and one that needs output inline (a pipe into the next command) can buy more time for that call alone. Asking for it is the opt-in: the wall-clock timer arms even with Bash Auto-Background switched off. backgroundAfter: 0 backgrounds immediately.
  • ttsr.experimentalRules, the list of experimental rules you have turned on. It is deliberately a second list rather than a value in ttsr.disabledRules: that setting stores exceptions-to-on, so a rule that ships OFF cannot be expressed in it at all, and keeping them apart means an opt-in left behind after a rule graduates to stable grants nothing rather than silently suppressing a rule that came back. A name in both lists stays off, because the safe reading of a contradiction about injecting text into a live session is "do not". It has no settings row of its own on purpose — the rule list is where a rule is turned on or off, and a second control holding the same names would be a way for the two to disagree.
  • ctrl+r in the model picker (/model, /models, /switch, alt+p) reloads the catalog from your providers and models.dev, and the card now says so along its bottom edge. The list is whatever the model cache holds, and that cache stays fresh for two hours, so a model a provider published this morning was simply missing with nothing on screen explaining the absence; the only cure that shipped was veyyon models refresh from a shell you had to leave the session to reach. The key refetches with the one strategy that ignores a fresh cache, since the cache-respecting default would redraw the same list and read as a broken button. A press while a reload is in flight is ignored rather than queueing a second fetch, and a reload that fails puts the provider's reason on the status line instead of leaving the card saying it is still working.
  • A bundled test-scope rule, which asks for a narrower test selection when a command runs a whole suite. Most of a session's wall clock goes to testing, and the expensive variable is breadth rather than frequency: measured in this repository, one test file returns in 0.31s, two package buckets in 4.8s, and 180 files in 15.0s, before the sandbox's own per-invocation overhead. A narrow run after every small edit is cheap and is good discipline; running everything each time is what burns the session. The rule fires when bun, npm, pnpm, yarn, deno, cargo, go or pytest is invoked with flags or nothing, and goes quiet the moment a path, package or filter narrows the run, so it costs you nothing once you are already scoping. It sees breadth and not frequency, because a rule matches the argument buffer of the current tool call and carries nothing across turns, which makes "you have run this four times in a row" inexpressible and "this command names no target" exact. It is advisory and does not interrupt, and it repeats after a ten-message gap rather than on every call. A bundled rule is injected only when it matches, so a rule you never trip costs no tokens.
  • Per-session CPU limits (session.cpuLimitCores, default off; session.cpuLimitKill, default off). Every process a session spawns (bash commands plain and PTY, MCP stdio servers, custom tool/command/extension exec calls, launch background processes, eval kernels, and the shared service workers) joins a per-session budget group: a cgroup v2 cpu.max quota on Linux (direct delegated write, or a systemd-run --user transient service when only the user manager is reachable), a Job Object hard CPU rate cap on Windows. A once-per-second watcher reads group usage and refuses new commands while the group stays saturated, with an error naming the budget, the measured usage, and the fix; with session.cpuLimitKill on, a sustained breach also SIGTERMs the group, and the kill is reported as a budget action rather than a crash. On macOS, where no per-group quota exists, enforcement is policy-only (refuse, renice, optional kill) and the settings row and a startup warning say so; on a host with no working backend the limit warns at startup instead of silently doing nothing. The harness's own compute is never capped.
  • /cpu-limit, which changes one session's CPU budget without touching the saved one. session.cpuLimitCores is a per-profile setting: it is chosen once and every session that profile starts inherits it, which is right for a default and wrong for the moment the default is in the way. A build that needs the whole machine, run from a profile capped at two cores, had one answer, which was to open /settings, raise the cap for every future session too, and remember to put it back. /cpu-limit <cores> sets a different budget for this session, /cpu-limit remove (also off, none, 0) lifts the cap entirely, /cpu-limit kill on|off switches the over-budget action, and /cpu-limit reset drops the session's override so the saved value is inherited again. Every branch writes a runtime override and never the config, and reset drops the override rather than writing the saved number back, so a later change to the profile wins instead of being shadowed by a copy of what it used to say. /cpu-limit status reports the budget, whether it came from this session or the profile, and what the limiter is actually enforcing on this host, since a report built from the setting alone says "2 cores" on a machine where nothing can enforce it; the bare form lists the subcommands like every other command that has them. It is text-mode drivable, because a headless client whose profile caps CPU otherwise has commands refused with an error naming a budget it cannot reach.
  • A /providers account manager card: one row per stored CREDENTIAL rather than one per provider, grouped by provider in a sidebar. Each row carries the account's label, the identity that tells it apart from its siblings, the plan the provider reports, the origin badge that distinguishes a real login from an env var aliasing the provider, one bar per usage window, and the upstream reason verbatim under a failed credential, because invalid_grant: refresh token revoked is the only string that tells you the grant died on the provider's side rather than locally. enter uses the selected account for ITS provider, n names it, r re-probes, u opens usage, x logs out behind a second-press confirm, a adds another account for the selected provider, and esc unwinds a rename, then an armed logout, then the card. Health and usage arrive as their probes land rather than blocking the first frame, and the selection is keyed by credential id rather than row index, so a probe landing under the cursor cannot move x onto an account you did not choose.
  • Switching accounts is per provider, and /account reports what that means. Several providers serve one session at once (the main model, subagent roles, web search), so there is no single "current account" to switch, and moving between PROVIDERS stays a model choice in /models. /account (or /account status) prints one block per provider the session has actually routed to, with the account serving it and that account's remaining quota; a provider you hold credentials for but have not used is absent rather than padding the list. /account manager opens the card, /account switch <provider> opens it focused on one provider, and /account name <text>, refresh, usage, logout and add reach the same actions without it. /account is text-mode drivable, so ACP and RPC clients get the status block too.
  • An account can be named, and the name outlives the credential it was given to. /account name work labels the account this session is using, and every surface then reads work instead of an address. Names live in their own auth_account_names table keyed by the account's IDENTITY (email, account uuid, org) rather than by its sqlite row id, which buys two properties worth having: a rename cannot rewrite, reorder or truncate the token bytes it is named after, so the one credential operation a user performs casually and repeatedly is incapable of costing them a login; and the name survives both a token refresh and a logout followed by a fresh login to the same account, because a label that silently went blank after re-authenticating would be indistinguishable from a bug. An account with no name shows its own identity and says how to set one.
  • A rate-limit rotation is reported as a rotation, never as your own choice. Pinning an account for a session writes user intent to its own key, kept apart from the routing record that selection rewrites on every resolve and clears outright on an auth failure: one row for both facts meant a rotation silently overwrote the choice and left nothing to compare against. So when the account you pinned hits its limit and a sibling takes over, both the card and /account status name the account you asked for, the reason it stopped serving, what replaced it, and how long until it unblocks, rather than presenting the substitute as though you had picked it. The pin is kept rather than consumed, so traffic returns to your account once the window resets with no second action.
  • A commit-drift rule reminds the agent to land finished work as its own commit once several of the files it edited itself are sitting uncommitted. The advice "commit often" is already in most projects' context files; what the model cannot see cheaply is the number, so the reminder names the count and the exact repo-relative paths. It counts only what the session changed, never what was already dirty in the tree, because a count taken from git status would push the model to commit another lane's in-flight work. It stays silent outside a repository and during a rebase, merge, or cherry-pick, and it says to run the covering gate first, to stage its own paths by name rather than git add -A, and never to push. HEAD moving clears the count, so obeying the reminder silences it. Settings → Interaction → Git → Commit Nudge Threshold sets the file count (default 4, 0 turns it off); it is hidden and inert while git integration is off. The body is gated on real drift, so the rule costs nothing at all until there is something to report.
  • Settings → Context → Rules (TTSR) → Rules lists every rule the project loads and toggles each one on or off. The row was a text box holding a comma-separated list of names, which required knowing a rule's exact name before you could turn it off and offered no way to find out what the names were. The list is discovered rather than read from the stored value, because the setting stores only exceptions and would describe an empty list on a stock install while thirty rules ran. Each row shows whether it is on, how it reaches the model (on match, always, on request), and where it came from. Storage is unchanged, so a rule shipped in a later release still arrives on rather than waiting to be opted in, and the screen says so when ttsr.enabled or the built-in master switch has already made every row inert.
  • Retry limits can differ per backend, because the cost of a retry does. The loop ran one global policy for everything, ten attempts at 500ms base backoff, which is right for a token API where a failed attempt costs a second and badly wrong for a backend that runs its own agent loop remotely: a Cursor or Devin turn can occupy the remote agent for minutes before it fails, so ten attempts turned one bad turn into an unexplained span of silence. cursor and devin now ship with limits sized for that (3 attempts at 2s, and 2 attempts at 3s), and retry.perProvider lets you override any backend yourself. It is keyed exactly like Retry Fallback Chains, so there is one selector vocabulary rather than two: a model selector (provider/model-id), a provider wildcard (provider/*), or a bare provider name, mapping to any of maxRetries, baseDelayMs, and maxDelayMs. Anything you leave out falls through to the global retry.* settings, so a backend nobody has characterized behaves exactly as it did before. The most specific matching key wins regardless of the order you wrote your config in, and your entry always outranks the shipped default.
  • Retries say why they are happening and what they cost. The status line read Retrying (1/10) in 5s…, which tells you nothing you can act on: a stalled provider, a rate limit and a malformed tool call all looked identical. It now names the reason (timed out, usage limit, provider hiccup), and names the source of the attempt budget when a per-provider policy rather than your own setting produced it, so a limit you never configured is traceable instead of arbitrary. A turn that recovered through retries also leaves one durable line behind (Recovered after 3 retries (12s waiting) · timed out) where it previously left nothing at all. That absence was the real problem: a recovered turn was indistinguishable from a merely slow one, so the wait had no attribution and the honest conclusion from the outside was that the tool was broken.
  • The cache marker now shows the worst case instead of hiding it. detectCacheInvalidation returned nothing whenever a turn reported no cache write, which is correct for an implicit best-effort cache (those drop cacheRead to zero as routine propagation noise and self-heal next turn) and exactly wrong for an explicit, prefix-controlled one. There, reading nothing AND writing nothing means the markers were sent and had no effect: nothing is cached for the next turn either, so the same full-rate cost recurs until something changes. So the benign event — a cold re-write, where the prefix moved and the cache still works — drew a marker, while the actual defect drew none. On Anthropic, Bedrock, and the OpenAI generations that accept explicit breakpoints, that state now reads ⊘ cache rejected · 51K tokens · model-change, distinct from ⊘ cache miss, because a miss re-reads the prompt once and a rejection recurs every turn; calling them the same thing tells you to shrug at both. Which kind of cache a turn used is answered by the same catalog predicate the request builder uses to decide whether to send a breakpoint, so the two cannot drift.
  • Two settings under Context → Prompt cache govern what happens when a turn asks the provider to cache a prefix and the provider caches nothing. Report Cache Rejections is on by default, so the miss is recorded rather than surfacing only as a larger bill. Block On Cache Rejection is off by default: turning it on fails the request after a rejection instead of continuing to pay full input rate. Blocking is opt-in because the verdict is proven against provider usage reporting, so a provider that changed what it reports would stop your session rather than cost you money. The blocking toggle is hidden while reporting is off, since a run that stopped for a reason nothing was going to tell you about is worse than one that quietly overpays. A non-boolean value in a hand-edited config cannot enable blocking: both settings are read as === true, because "false" is a truthy string and would otherwise have switched hard blocking on for a config that says it is off.
  • providerCacheKeyDiscards() reports every mid-session discard of the inherited provider prompt-cache key, by cause. A discard is a full re-prefill of the conversation and it was the unrecorded half of that cost: a measured session read 0 cached tokens and rewrote 67,528 immediately after an auto-thinking reclassification, an event that appeared nowhere in the existing invalidation record. Causes are thinking-level-change, model-change, tool-signature-change, system-prompt-change, new-session, auto-thinking-enter, and session-switch.
  • The secret vault can rename a stored credential in place. The value, the creation time and the expiry are carried across untouched, so relabelling a secret cannot quietly move the moment it dies. A rename onto a name that is already taken is refused, naming both secrets: /secret add overwrites a same-named entry on purpose, because that is how a credential is rotated, but a rename carries no new value, so landing on an occupied name could only destroy the credential already there in exchange for nothing. Remove the occupant first if that is really what you want.
  • /secret rm takes an optional --scope profile|project|global, so a copy that is not the one in effect can be reached. Without it, removal still takes the narrowest match, which is the entry currently in effect and the one you mean almost every time. A name held in two scopes previously had its outer copy stranded: every rm took the inner one, and there was no spelling that reached the other, so the only route to a shadowed credential was to remove the copies in front of it first. The option was refused before this rather than ignored, which was the right call while nothing implemented it, because an accepted-and-ignored --scope prints a removal that took a different entry than the one you named.
  • /secret list names the stored copies it is not spending. A credential held in more than one scope resolves to the narrowest, and the table shows one row per name because one row per name is what the agent can spend, so the copies underneath were mentioned nowhere: still on disk, still decryptable, and live the instant the copy in front of them was removed. The list now prints a sentence per hidden copy naming the scope that holds it, the scope actually being spent, and the command that removes it, while the table and the count keep meaning exactly what they meant before. Giving a shadowed copy its own row was the other option and would have put an entry in the spendable list that cannot be spent. This closes the discovery order that made shadowing dangerous: you learned a second copy existed from the removal that promoted it, at the moment it started being spent, rather than from the list you consulted first.
  • /secret manages what it stored with verbs, and every verb runs on every surface. rename, value, scope and copy join list, rm, extend, log, discard and help, so relabelling a credential, correcting its value, moving it to another vault and putting its placeholder on the clipboard are one line each, in a terminal, in an ACP client and under -p alike. The words are reserved, so /secret list runs instead of storing the text list, and /secret -- <value> stores a credential that begins with one of them. /secret copy hands over #NAME# and never the value, because the placeholder is the thing you paste into a prompt. /secret scope refuses a move whose destination already holds that name, since a move is an add followed by a remove and running it into a collision would overwrite one credential and then delete the other, losing both; the refusal names the exact rm that clears the way.
  • /secret log --name <name> shows only the uses of one credential, which is the question worth asking immediately before a revoke: what stops working. The log is read whole, then narrowed, then limited, so --limit 20 --name X means the last twenty uses OF X rather than X's uses among the last twenty records of everything.
  • /permissions [rung] (alias /approval) sets the approval rung for one session without touching the saved default. /permissions alone reports the rung in force and where it came from (session, saved, or default), and /permissions reset drops the override. This is the second half of the split the ladder needed: the persisted default is chosen once, and a session that needs more or less rope says so without editing config.
  • Onboarding asks how much the agent may do on its own, before the subagent and appearance steps. It writes the persisted tools.approvalMode, seeded from whatever is already configured so re-running veyyon setup never proposes a downgrade you did not ask for.
  • The status line always names the approval rung. It rendered a mode label only for plan, prewalk, goal, vibe and loop, plus the /yolo bypass marker, so in an ordinary session the one piece of state that decides whether the next command asks was on screen nowhere. Ask all, Ask cmds, Auto (warning colour) and Yolo (error colour) now sit in the mode segment. The /yolo bypass still outranks and replaces it, and an active plan session shows Plan alone rather than naming a rung plan has already capped.
  • An interactive approval prompt can be answered for the whole session. The dialog offered only Approve and Deny, which made ask and ask-command unusable rather than merely safe: a run that edits twenty files asked twenty times. Approve for session and Deny for session remember the answer per tool until you exit. The memory is never written to settings, so tomorrow asks again, and it is consulted only after policy resolution, so it can dismiss a prompt and never lift a settings deny or a plan-mode block.
  • The Comms view of the Agent Control Center says more about each message. A summary line above the stream counts the traffic and the undelivered legs, so "did anything not arrive" is answerable without scrolling. A reply is marked with a return arrow on its head line, naming the agent being answered when that is not simply the recipient; replyTo was recorded on every answered message and shown nowhere. A delivery that woke an idle agent or revived a parked one says woke or revived, because that is the difference between "they were listening" and "your message is why they are running". f narrows the stream to one agent's traffic and cycles back to all.
  • Compaction and handoff now persist a versioned continuity record beside generated prose. The active objective and original user contract retain their exact source entry IDs when persisted, while goal and todo state, blockers, changed paths, verification evidence, and checkpoint state survive in-place summaries, replacement sessions, and reopen.
  • Successful edit, write, and ast_edit calls now create bounded verification evidence. If the model tries to finish without a later successful bash, eval, debug, or browser result, Veyyon gives it one targeted continuation turn to run and report a check.
  • Finished non-isolated subagents now stay revivable for five minutes by default before their live session is parked, regardless of model or provider. subagent.idleTtlMs overrides that lifetime, including 0 to keep sessions live until exit. Parking retains the transcript for later revival.
  • A parked subagent is now closed for good once it has been quiet long enough, instead of sitting in every roster for the rest of the session. Parking already released the session; this second stage drops the revivable reference too, and the transcript is untouched and stays readable through history://. Settings → Subagents → Auto Close owns it: Close Parked Subagents is on by default, Close After is five minutes, and Close After (Waiting) is thirty. The asymmetry is the point. An agent whose last message said it was waiting on another agent stopped on purpose to let a peer finish, so it is the one you are most likely to message next, and closing it on the ordinary timer would drop exactly that peer. Turning the switch off is a real off switch rather than a very long timer: both budgets resolve to zero, nothing closes, and the two duration rows disappear rather than sitting there claiming a schedule that does not run. A waiting budget below the quiet one is floored at it, and a quiet budget of zero disables the waiting case with it, so no configuration can close only the agents worth keeping.
  • Settings → Subagents → Limits → Idle TTL is on the settings screen. The setting decided when a finished subagent releases its session, it carried a label, a group and a description, and docs/settings-reference.md listed it, but the row rendered nowhere: a numeric setting that declares no option list is treated as deliberately schema-only, so stage one of the park/close lifecycle was configurable from a config file and unreachable from /settings while stage two beside it was not.
  • Settings → Resources, a tab of its own for every limit on what a session may consume, sitting fourth in the sidebar between Interaction and Context. It ships with four groups: CPU (Session CPU Limit, Kill Over-Budget Commands), Memory (Session Memory Limit), Disk (Session Write Budget, Kill Over-Budget Writers) and Processes (Max Processes). Every limit on the tab is scoped to the session TREE, meaning this session, every subagent under it at any depth, and every process any of them spawned, all sharing one budget group, so a cap cannot be multiplied by delegating work to subagents.
  • Settings → Resources → Memory → Session Memory Limit (session.memoryLimitGb, default 0 = unlimited), a resident-memory ceiling for the session tree in gigabytes, written to the session budget group's cgroup v2 memory.max. It has no kill companion on purpose: a group at memory.max is reclaimed by the kernel and then a process inside it is OOM-killed, whichever one the kernel picks, so the enforcement is the kill and there is nothing to opt into. The row says that plainly rather than implying a polite refusal.
  • Settings → Resources → Disk → Session Write Budget (session.writeBudgetGb, default 0 = off), the cumulative gigabytes the session tree may write to disk before further writes are refused, with Kill Over-Budget Writers (session.writeBudgetKill, default off) deciding whether the over-budget group is also sent SIGTERM. The kill row is hidden entirely while the budget is 0, since a kill policy for a budget that does not exist is a control with nothing behind it.
  • Settings → Resources → Processes → Max Processes (session.maxProcesses, default 0 = off), a hard cap on how many processes may be alive at once across the session tree, held by the kernel where it can be (cgroup v2 pids.max on Linux, a Job Object process limit on Windows) and refused at spawn time elsewhere.
  • The three new resource limits are enforced by the same per-session budget group the CPU limit already uses, rather than by a second mechanism each. The write budget is metered from two sources at once, because neither sees the whole tree: spawned processes are read from the group's cgroup v2 io.stat where the io controller is delegated and from write_bytes in /proc/<pid>/io where it is not (per pid high water marks, so a finished command's bytes survive its exit), while veyyon's own write and edit tools are counted at the single callback both commit through, since the harness process is deliberately never a member of its own group and no kernel counter will ever attribute a byte of theirs to it. write_bytes and not wchar: a disk budget means storage reached, so a program rewriting one page in a loop does not burn it. A write that would cross the budget is refused BEFORE it happens, so one oversized write cannot blow through the wall by any amount, and a write that fails is charged nothing. The process cap is written to pids.max and ALSO refused in policy at spawn time, because a fork failing with EAGAIN does not name the budget that stopped it. The memory cap is written to memory.max and has no policy stand-in: where it cannot be written it fails closed, refusing new commands rather than running them unbounded, and says which controller is missing. Every limit that cannot be enforced on the host is reported once, naming the setting and its value, and a repeat of the same condition stays quiet; only changing that limit lets it speak again.
  • veyyon prompt --tools prices what the tool definitions cost, which no other view could show. The section and statement tables measure the system prompt and nothing else, yet every active tool also ships a description and a parameter schema on every request: in this repository that is 13,277 tokens against the prompt's 23,403, so the answer to "why does a session start expensive" was the smaller half with nothing saying it was partial, and the largest tool descriptions appeared in no table at all. Each row is priced from the bytes the provider receives - the rendered description and the wire schema - split into those two halves, because a description is prose that can be shortened and a schema shrinks only by dropping parameters. The row set is the tool set the configuration loads, so a tool added later is priced without anyone remembering to list it.
Changed
  • A per-compact rule states how many transcript replacements it waits out before it may say the same thing again, as repeatCompactions in its frontmatter (default 1, which is what per-compact meant on its own). Five session paths re-arm such a rule — compaction, a history rewrite, a rewind, a shake, a restore — and a rule whose subject is a standing STATE rather than an event matches again the instant it is re-armed, so it repeated as often as the transcript was replaced. commit-drift and test-scope are both that shape and both now carry a period of three; a reminder heard on every replacement is one the reader learns to skip, which costs more than the reminder is worth.
  • test-scope ships OFF, in the experimental/ rule section, so it is silent until named in ttsr.experimentalRules (Settings → Rules → Rules → Built-in · Experimental). Its advice is sound and its subject is standing rather than occasional: every whole-suite command matches it, on a session whose whole job is running suites. That is the operator's context being spent on the author's behalf, so it is opt-in.
  • Every settings group heading now adds information its own rows do not. Eight groups carried the name of one of the rows inside them (Session instrumentation over a Session instrumentation row, LSP over LSP, Bash over Bash, Magic Keywords over Magic Keywords, Auto-Learn over Auto-Learn, Read Summaries over Read Summaries), which spends the one line above a section restating the line below it. The master switch in each is now named for what it does (Enable LSP, Enable Bash, Enable Magic Keywords, Enable Auto-Learn, Enable Read Summaries), matching Enable Advisor and Enable Prewalk, and the instrumentation row is Instrumentation Level, which is what it holds. Interrupt Mode existed on two tabs and settings search is global, so the TTSR one is Rule Interrupt Mode; providers.tinyModel is Session Title Model and subagent.agents is Agent Roster, both of which name the thing rather than the mechanism. Four headings that were sentence case (Prompt cache, Session instrumentation, Stream interrupts (TTSR)) are Title Case like the rest. A guard asserts no group repeats one of its labels, every label is unique across the whole schema, every heading is Title Case, and the declared tab groups and the rendered ones agree in both directions, so a half-finished rename cannot land.
  • Account load balancing is ON by default. When one account hits its quota, the session continues on another account of the same provider instead of waiting out the window. Signing an account in is the decision to use it, and the previous default turned that into a hard stop with a working credential one row away: the failure an operator actually saw was Cursor: #11 is out of quota until 7:45:11 PM. 1 other account is idle; turn on Account Load Balancing in /settings (Providers) to use them, immediately followed by the turn dying on Provider requested 1800000ms wait, exceeds retry.maxDelayMs. A product that can finish the request and instead explains how you could permit it is not being careful. Every move still announces itself and names both accounts, so nothing about it is silent, and accounts.loadBalancing false restores the old behaviour for anyone who wants one account walled off.
  • The ask dialog validates a question and an option with the shared isRecord guard instead of spelling the same three-clause predicate inline. No user-facing effect: the accepted and rejected shapes are identical, and every rejection message is unchanged. The inline spelling is what the repository's type-guard lock exists to prevent, because a hand-written copy of a guard is where the null clause or the array clause eventually goes missing.
  • The claude-trace capture helper mints its own throwaway MITM certificate instead of carrying one in the source. The pair was a self-signed local debug key, used with NODE_TLS_REJECT_UNAUTHORIZED=0 against a loopback proxy, so it had no trust value and nothing was ever protected by it; what it did do was put a -----BEGIN PRIVATE KEY----- literal in a file every secret scanner reads, and a finding that is always noise is how a reader learns to skip the report that matters. openssl now mints a one-day pair on first use, in a temp directory removed in the same call, and the pair is generated once per process because nothing here wants a stable identity. Without openssl on PATH the helper says exactly that instead of failing somewhere inside the TLS handshake.
  • A collapsed bash or eval output card spends its window on anomalies instead of on a build's progress wall. That window is a viewport-sized TAIL, so 40 Compiling … rows filled all of it and the one line worth reading (a warning, a failing assertion, a summary) had already scrolled out of the card by the time the command finished. A run of four or more consecutive same-shape lines is now counted away to its newest line plus … +39 earlier, before the window is measured. The rule is shape-only and knows nothing about any one tool: a Capitalized leading word, a lowercase word ending in a colon, or a bracketed counter, with digits normalized so [1/47] and [2/47] are one shape. Distinct diagnostics are never counted away, since a warning is the thing being looked for, and a directory listing is untouched because -rw-r--r-- matches none of the three shapes. Full visibility is unchanged and unconditional: ctrl+o expands to every line, and the raw artifact still holds the whole capture. The per-tool shell minimizer could not do this job, because its filters rebuild whole buffers and only ever see a sealed capture, while this window is painted from the live stream.
  • Every surface that names the account spending this session now separates an observation from a prediction. Before the session's first request, and whenever the account you chose or last used cannot serve right now, the honest answer is what the NEXT request will do: the footline says next [email protected] instead of as [email protected], the account card tags the row serves next instead of serving, and divergence prose reads you chose X; it cannot serve, so the next request uses Y. Reporting a guess in the present tense is how a rate-limited account came to be described as the one currently spending your tokens.
  • In a terminal /secret add <value> is a synonym for the bare value form: it takes no name, and the name is still asked afterwards. A name parsed off that line would write a live credential into the vault's plaintext metadata, which is the defect the verbless grammar was built to prevent.
  • /secret's options footer is one text for both surfaces. It names which subcommands read each option, and there were two versions of it while a terminal had no verbs to point at, so the shared line on add, rm and discard was hidden from the surface that could not type discard. Every verb parses everywhere now, so a second footer would be a second answer to what --scope applies to. It gains --name <name> on log, to show only that secret's uses.
  • The /secret argument dropdown and the ACP command listing are the same eleven verbs in the same order, both derived from the parser's own spelling table, so a client's listing cannot drift from what a terminal offers. A verb cannot be typeable and unoffered, or offered and unparseable, and the second spellings (remove, delete, name, replace, move, renew, audit) still run while staying out of a list whose job is to say how small the set is. help is declared as a verb of its own because a client with no field has no other way to ask what the command does.
  • The masked /secret field says the naming question is still coming: Paste the secret value here. You can name it afterwards., with the value, not a name · hidden as you type, stored encrypted on the legend row beside the keys. An operator with no reason to believe a label is coming answers a hidden field with a name, which stores the name as the credential.
  • A desktop toast is now reserved for the one event that cannot continue without you: completion.notify defaults to off, ask.notify stays on. Both shipped on, so a finished turn announced itself at the end of every turn, dozens per session, describing what was already on the screen in front of whoever was watching it. An unanswered question is different in kind, the turn has stopped until you answer, and the window-focus gate withholds even that one while the terminal has focus.
  • The account card's balancing toggle says what it did. b repainted a two word chip, which is a thin receipt for a change that is written to the profile config and outlives the session: the operator was left to infer both what the new state does and that it persists. The press now also states it, from the value that was actually stored rather than the value it tried to write, so a refused write cannot announce a saved change. On reads "an exhausted account moves to another account of the same provider", off reads "an exhausted account waits for its own quota window", and both end with "Saved for this profile." The card's scope line separates the two scopes it was blurring, since the credentials themselves are shared by every profile and session on the machine while the balancing setting belongs to this profile.
  • Seven tools ship fewer worked examples, and the whole tool payload is now measured by a test. A density pass followed on the four heaviest prompts that are mostly prose: irc.md said the same thing twice (a "do not narrate" bullet and a "never send a progress report" list), and read.md, bash.md and eval.md each restated a fact they had already stated (read's range padding, bash's merged stderr and truncation artifact, eval's incremental-cell rule). No grammar rule, prohibition or refusal contract was removed from any of them, and the total fell again to 47.4 kB. test/tools/tool-description-contract.test.ts now pins the rules each pruned tool must still advertise (irc, ast_grep, ast_edit, debug, glob alongside the tools already listed), asserted against the rendered description a provider receives rather than the template on disk. It also builds irc for the first time: the fixture had no agent registry, so every rule that suite claimed to protect for irc was in fact unverified. launch carried seven examples, ast_edit six, ast_grep five, irc six, glob four; most of the extras differed from a sibling only in one argument value, which teaches nothing the schema does not already state, and every byte of them is re-sent on every request. Each of those tools keeps two examples chosen for the shapes that are genuinely easy to get wrong; eval drops the third Python cell and debug the raw-repl call. todo drops six of its eight, since its ops table already documents every op. Measured on a real session boot, the active set fell from 53.6 kB to 46.3 kB of description plus rendered examples, around 1.8k tokens off every request. test/tools/tool-prompt-budget.test.ts records what each tool is allowed to spend and fails in both directions: over its ceiling, or more than 400 bytes under one (the row has gone slack and should be lowered), or an active tool with no ceiling at all, or a ceiling left behind for a tool that no longer loads. The ceilings are measured through createAgentSession, so a new tool cannot arrive unmeasured and a stale row cannot quietly re-permit the growth someone just removed.
  • Source comments and internal docs no longer quote, credit or date a chat message. Rationale that read as "user report 2026-07-22", "the operator asked for", or a pasted line of conversation now states the constraint itself — what the code must do and why — and where the quotation carried nothing else, the prose is gone. A repository is published; a conversation is not, and a comment that cites who asked also stops explaining anything to whoever reads it next. scripts/no-attribution-in-the-tree.test.ts derives its scope from git ls-files at run time and fails on the constructions, so they cannot come back one comment at a time. Comment prose only: no code, string, identifier or behaviour changed.
  • Subagent model and effort have one owner. Settings → Subagents → Agents → <agent> carried its own Model and Effort rows, and they outranked the blanket Subagent Model and Subagent Effort settings, so two screens answered one question and disagreed on screen: the per-agent Model row printed an inherited value with an effort suffix (… · high · subagent.model) while the Effort row directly beneath it said inherit. The per-agent rows are gone, not hidden — a hidden layer that outranks the visible setting is exactly the drift this area exists to remove — and the agent editor now shows what the agent will run as a read-only line, naming where to change it. subagent.agents.<name>.model and .thinkingLevel are no longer read; a config that still carries one is named once per field with the setting that replaced it, rather than being silently honored or silently dropped. The legacy task.agentModelOverrides map is likewise consumed and reported instead of being folded into a per-agent row nothing reads: several agents could name several models, so there is no honest way to fold it into one blanket value. Precedence is now three layers: blanket subagent.model > the agent file's own model: frontmatter > inherit the main model.
  • Settings effort rows narrow to the model, like /effort already did. Subagent Effort shipped a fixed ladder of every level Veyyon knows, while /effort and /thinking ask the model which efforts it actually exposes — so a Cursor row that routes effort through separate model ids was told "no effort selectable" in one place and offered xhigh in another. The row is built from configuredThinkingLevelOptions for the model in scope now, and a model that exposes no selectable effort offers Inherit alone, saying so.
  • Bash Auto-Background ships ON, moving a foreground command to a background job after 5 minutes rather than never. It shipped off with a 1-minute threshold, which is the pairing that made it feel broken from either side: nobody had it on, and the first thing it did to anyone who turned it on was take a routine 90-second test run away from them. Five minutes is past almost every command you wait on deliberately and short of the ones that hold a turn open long enough to cost the prompt cache. The threshold row and the master toggle now sit together in the schema instead of being declared in two different domain files.
  • Settings → Subagents → Agents shows what each agent is for. The list gave one row per agent carrying its name, whether it is on, and which model it resolves to, which is everything except the thing the screen exists to help you decide. The highlighted agent's own description now prints under the list, wrapped, the same way the setup wizard's agent step already did it. It goes under the list rather than beside the name because a description is a full sentence: inline it arrives cut off, and wrapping it into the row costs three rows per agent, so six agents would no longer fit on screen at once.
  • scout covers reconnaissance, not just lookup. It described itself as exploratory research and broad pattern searches, and its procedure stopped at locating code and noting dependencies, so the questions people actually send it, which call sites a change has to touch, where the seams are, what it would collide with, and where the plan is likely to break, all read as outside its lane and went to a more expensive agent. Those are now what it is for, and the last two steps of its procedure ask for them.
  • Agent selection routes on cost, not on job title. The roster the model reads gives it one description line per agent and nothing else, no cost and no context size, and the instruction above it read "spawn the one whose description covers the task". task is always enabled and described itself as a "General-purpose subagent with full capabilities for delegated multi-step tasks", which covers every task by construction, so the rule resolved to task every time and disabling an agent did not remove the work it had been doing: the work rerouted to task, at the session model, with a larger prompt, and nothing said the cheaper path was gone. The roster is now a cost ladder. Each description states how much is unknown and how large a change the lane carries, task is described as the expensive end-to-end lane for a vague outcome the agent must discover, build, test and review alone, and the instruction says to take the cheapest lane that can carry the work and never to substitute a wider lane for a disabled one. The system prompt bullet was reworded to the same axis so the two surfaces cannot say different things.
  • sonic means a contained change, not a stupid one, and it has its own prompt. It rendered agents/task.md byte for byte, so a sonic worker was told it had "FULL access to all tools" and nothing else, and its description ("Low-reasoning agent for strictly mechanical updates or data collection only") ruled out the ordinary case where the outcome is clear but a detail still has to be worked out. It now describes work whose outcome you already know, at any number of files, and its own prompt tells it to resolve open details and ask the spawner back when something is genuinely ambiguous, while refusing to redesign the approach or start a large multi-stage build it should have handed back. scout, reviewer, librarian and designer lost the "specialist" and "MUST be used for" framing for the same reason.
  • The task AGENT is now called deep. Its name collided with the task TOOL, so "use task" named both the thing you call and one of the six things you may pass it, and every sentence about delegation had to disambiguate which was meant. The name was also a leftover from when it was the general-purpose default, rather than what it has become: the most expensive lane, for a vague outcome that has to be discovered, built and verified over several stages. deep says that, and it collides with nothing. The tool keeps its name. task still resolves as an alias everywhere a name is accepted, so an existing subagent.agents.task.* block, a project agent that overrides it, and a spawn that names task all keep working. The alias resolves at lookup and is never written back, so nothing rewrites your config. Default-agent resolution follows the alias too, which is what stops a roster still carrying the old name from reporting that no default agent exists.
  • Bundled rules ship in section directories, and the rule list is a section index you drill into. All thirty-one shipped loose in one directory and rendered as one flat alphabetical list, which is a wall you scroll rather than a screen you read, and there was no way for a rule to say "not ready". The directory a rule ships in IS its section — workflow/, go/, rust/, typescript/, experimental/ — so the file tree is the reviewable artifact and moving a file changes behaviour; there is no section frontmatter field to disagree with it and no parsing of ts-/rs-/go- name prefixes. Settings → Rules → Rules now opens on five rows, one per section, each naming how many rules it holds and how many are off, and Enter opens one; Escape inside a section steps back to the index rather than closing the list. Rules your project supplies come first, the bundled sections follow in bundle-declared order, and experimental is last so a reader scanning downward meets the opt-in rules only after everything that is actually running.
  • The model identifier no longer ships inside the system prompt. includeModelInPrompt defaults off, so Model: is gone from the <workstation> block. It sat in project, the largest cached block at 14,198 tokens, and it changed on every model switch, so /model re-prefilled that entire block to update one line the model rarely needs and cannot act on. What is bought is stability rather than bytes: the prompt is the same size, but nothing in it changes when you switch models, so the cached prefix survives the switch. Model-conditional prompt policy is unaffected, because it reads a coarse task-policy token rather than the printed name. Settings → Model → Include Model In Prompt turns it back on if you want the agent able to recite its own model name, at the cost of a full prompt-cache invalidation every time you switch.
  • The browser and lsp tools ship off. Both were registered on every session whether or not the work involved a page or a language server, which spent 8,340 bytes of tool description on capabilities most sessions never reach, and the LSP tool additionally starts a server process. Settings → Tools → Browser and Settings → Editing → LSP turn them on, and a session that turns one on gets it in full. The prose follows the tool rather than being deleted for everyone: the verify step now tells the model to drive the real interface and look at the result, which is true whatever it is driving, and the sentence naming the browser tool is a separate statement gated on that tool being present, the way the three LSP statements already were.
  • The terminal and web renderers of the github tool read one GitHub check-run vocabulary from @veyyon/utils, instead of each holding its own conclusion tables. The two had already drifted: the terminal side knew a queued/requested/waiting group and the web side did not, so the same queued job read as pending in one view and as an unknown state in the other. A conclusion added now reaches both views.
  • Rules have their own section in the /settings sidebar. Every rule the project loads, plus the six knobs that govern them, sat inside Context as one group below prompt caching and session instrumentation, so the screen that answers "which rules are running here" was three groups down a tab named after something else and reached only by scrolling past settings about token budgets. Rules is now its own sidebar entry between Context and Memory, and it opens on the rule list: Built-in Rules and the per-rule on/off table under a Rules heading, with the stream-interrupt behaviour (TTSR, context mode, interrupt mode, repeat mode, repeat gap) under Stream interrupts (TTSR) beneath it. Nothing about storage changed — the same keys hold the same values, so an existing config is unaffected and a rule you turned off stays off.
  • Delegation routing no longer promotes unmatched work to a wider agent. There are only agent types, each with its own use case, and the enabled set is configuration. Both prose surfaces used to invent a two-tier taxonomy on top of that: the system prompt branched on whether task was enabled and told the model to treat it as the catch-all destination whenever no other type matched, and the task tool description repeated the same rule. That inverted the cost control it looked like, because disabling a cheap narrow type did not remove its work, it handed that work to the widest and most expensive agent. The rule now reads the other way: spawn a type only when its description covers the slice, and when none covers it, do the work inline. Settings → Subagents → Agent Delegation describes the same policy.
  • Settings → Appearance → Status Line loses the *Status Line Separator* and *Transparent Status Line* rows. Both configured the filled powerline bar the editor's top border used to carry: seven separator styles, a theme background fill, and the end caps that bridge it into the terminal. That bar had zero production callers and is gone, and the composer footline that replaced it joins segments with its own fixed · and paints no background at all, so between them the two rows offered eleven choices and changed nothing on screen — the separator row even ran a live preview callback into a renderer that could not act on it. The stored keys survive so a config file naming them still loads, but neither surfaces a control. statusLine.preset still selects segments as before. *Session Accent* stays; its description no longer mentions the status-line gap that went with the bar.
  • Seven tool descriptions stopped paying twice for rules the system prompt or their own <critical> block already state. The tool schemas are the largest single block in a request (a measured body was 144,343 bytes: 84,520 of tool schemas against 58,002 of system prompt), so a rule written down twice is billed on every turn of every session. bash stated the "use launch, not bash" routing rule three times in one description and repeated the specialized-tool routing that tool-policy/specialized-grep, specialized-read, specialized-glob and bash-cwd already state unconditionally whenever those tools are active; set_cwd restated the <working-directory> block from the project prompt, which renders unconditionally, almost verbatim; read explained its context padding three ways and repeated "parallelize independent calls" from tool-policy/general; task stated "pick the specialist whose role fits" three times; launch and eval each stated one rule twice. Every surviving statement keeps the rule and the failure mode; only the second and third copies went, and packages/coding-agent/test/tools/tool-description-contract.test.ts fails if any kept rule leaves the rendered description. Nineteen advertised schemas measured 63,953 bytes before and 62,164 after, 1,789 fewer bytes on every request. Serialization order was checked per provider and left alone: Anthropic, OpenAI Responses, OpenAI Chat Completions and Google all already serialize the system prompt ahead of the tool schemas, and Anthropic's cache prefix is ordered tools then system then messages by the provider regardless of JSON field order, so there is nothing here for us to reorder.
  • The smart poll wait is two rungs, 30s then 4m, instead of five rungs climbing from 5s to 5m. The old top rung landed exactly on the provider's five-minute prompt-cache boundary, so timer drift and request-preparation latency were enough to turn the next heartbeat into a full uncached prompt, and the bottom rungs spent model turns on 5s and 10s "still running" frames that told the operator nothing. A blocked poll still gets its early checkpoint at 30s, a tight loop backs off once to 4m, and 4m leaves a full minute of headroom before the cache expires. Settings → Tools → Execution → Max Poll Time described the old ladder verbatim and now describes this one; fixed values are unchanged and still wait exactly as long as they say.
  • The volatile environment facts moved to the end of the project prompt block, which costs nothing and stops a model switch from throwing away the prompt cache. <workstation> carries Model: and Terminal:, and it used to sit FIRST, ahead of the context files. A provider's prefix cache matches on a byte-identical prefix, so switching model changed byte one of that block and invalidated everything behind it: on a bare directory that is the user's whole AGENTS.md, 5,396 tokens re-prefilled purely to report a different model name, and inside a project it is far more. Measured re-prefill cost on the Kimi path is 3-5s, with a tail past 25s. Not one token was removed and no wording changed; the volatile lines simply now come after the stable ones, which is the order a cache can actually use.
  • The bash and set_cwd tool descriptions dropped text that carried no instruction, saving about 215 tokens on every single turn. Tool definitions are 66% of everything Veyyon sends, and their descriptions are 78% of that, so prose in a tool description is not documentation, it is a per-request cost paid forever. bash had its core rule twice: the <critical> block restated "not full GNU Bash" and re-listed the same loops, heredocs, nested substitutions and multi-stage pipelines already enumerated a few lines above, so the restatement is now a single imperative and the enumeration is read once. set_cwd lost a paragraph arguing why re-rooting is cheaper, which is a justification the model does not act on, and a sentence explaining where a user sets session.workdir in /settings, which is user documentation that was sitting in a model-facing prompt. Every rule both tools stated is still stated, once.
  • Compaction now has one strategy: in-place summary. Existing handoff and other legacy strategy values migrate to summary, automatic and manual compaction use the same path, and /compact handoff now points to the independent /handoff session-transfer command instead of changing compaction behavior. Settings no longer offer handoff as a compaction type.
  • In-place compaction now spends its summary budget on user intent, current progress, blockers, decisions, and only evidence needed to avoid rediscovery. It no longer asks the summarizer to inventory every command, successful gate, repository field, process, and bookkeeping action. Repeated compactions prefer newer source conversation over inherited summary prose and discard stale or unsupported claims.
  • Loaded user and project instruction files now state their authority before their contents: the current user message wins first, then applicable AGENTS.md and related context files override conflicting generic Veyyon prompt defaults, retrieved context, and historical summaries. More specific files still override broader files.
  • Ordered compaction-model preferences now persist as an ordered YAML list, and clearing the picker removes the setting instead of saving an undefined value. The selected list and configured-only fallback policy survive save and reload unchanged.
  • Subagent model, availability, delegation, and reasoning policy now resolve through the singular subagent.* settings owner. Parent effort inheritance and one-turn structured-yield repair use the same resolved policy in task, eval, and Vibe execution.
  • The job tool reads the <task-result> envelope through @veyyon/wire instead of its own copy of the pattern, so the terminal and the HTML/collab renderer cannot disagree about what a settled subagent job says. Both surfaces stripped the envelope with byte-identical private copies, and the copy that stops matching does not throw: it shows the reader <task-result id=… agent=… status=… where the other shows the answer.
  • The launch tool description no longer restates its own parameter schema. Every field it listed - what ready.log is, what logs defaults to, which signals send takes, what persist does - is already sent in the schema beside it, so the model was reading each fact twice and paying for it twice. What is left is what the schema cannot state: that start blocks for the whole readiness timeout, that an exit arrives on its own so wait is not a poll loop, that the broker stops non-persistent processes when the last veyyon exits. 684 description tokens down to 519, while gaining the routing rule it was missing. bash pays for its own new routing line the same way, ending 45 tokens below where it started.
  • The /secret argument dropdown offers only manager. It used to complete the verbs and then the names of your stored secrets, which under the new grammar would have been a trap: accepting /secret rm GITHUB_TOKEN no longer revokes anything, it stores that text as a credential and reports success. Picking a secret by name now happens in the manager, where the list is on screen instead of recalled.
  • Every notice that tells you how to fix a secrets problem names a route you can actually take from where you are. The unreadable-vault warnings said to run /secret discard --scope <scope> and re-add the secrets with /secret add; a secret nearing its deadline said to extend it with /secret extend NAME --ttl 7d; an expired one said to store it again with /secret add NAME --from-env <VAR>; a refused expansion said to check the vault with /secret list. In a terminal all of those are now credential text, so advice meant to rescue you would have stored itself as a secret, under a generated name, and reported success. Each one names the manager or the --from-env form first and keeps the verb form for clients with no terminal, since these are raised by the vault loader and cannot know which surface will print them.
Fixed
  • The composer footline divides two states from each other more strongly than it divides a state from its own values. The mode segment glued its states together with a bare space, so a session running /yolo under a goal read ! YOLO Goal 12K/50K 25%: the boundary between the approval bypass and the goal was spelled exactly like the space inside the goal's own token readout, and three facts read as one phrase. Independent states are now joined with · and segments keep their wider · , so the hierarchy is visible at any number of simultaneous states. The separator glyph comes from the active symbol preset instead of being hardcoded, so the ascii preset — which exists for terminals that cannot draw · — no longer prints one anyway.
  • A variable the command line itself assigns is knowable from the command text, and the bash guard now reads it that way. DST=/srv/app; mkdir -p "$DST"; rm -rf "$DST/build" was judged as a recursive delete of an expansion "whose value is not knowable from the command text": inline assignments were applied to a copy of the environment that was discarded at the end of each ; segment, so the delete never saw them. That verdict is critical, the one floor /yolo cannot lift and no standing grant can cover, so the mode whose promise is that it does not prompt stopped and asked, and a headless run failed the call outright. Values assigned earlier in the same line are now carried forward, through the bare form, a wrapper word (env, sudo -E), and the declaration builtins (export, declare, typeset, readonly, local). Ambiguity still fails closed: a value holding an expansion this guard cannot resolve, a name the shell maintains itself (PWD, so cd /; rm -rf "$PWD" stays critical), a name rebound by something the scan cannot read (read DST, a subshell, a script file), and a name whose ambient value contradicts the assignment (the conditional-assignment case, [ -z "$DST" ] && DST=/srv) are all still unknown. A prefix assignment does not settle the name for its own command, matching the order the shell expands in. Separately, a wrapper's own flags are now stepped over while scanning for assignments, so sudo -E FOO=/ rm -rf "$FOO" no longer reads past the flag to the ambient value.
  • A subagent whose recovery gave up no longer reports itself rate-limited. The parent's row carried one rate-limited badge for every terminal recovery failure, and that state is set whenever the retry ladder ends unsuccessfully: attempts exhausted, a continuation out of allowance, a continuation the operator cancelled, a continued turn that came back empty. A quota window was one possibility out of several, and the detail row directly beneath the badge already named the recovery, so the same frame said two different things about one failure. The badge now reads retries gave up or continuation gave up, matching that row, and the recovery's identity travels on the result a background task returns, so a settled task no longer reports every continuation as a retry.
  • A continued turn that comes back empty no longer leaves the recovery countdown running forever. The wait taken before continuing a turn the transport killed inside an unreplayable tool batch is announced through the retry ladder's event, so the countdown, a parent HUD's retry state, the turn's retry trace and every hook, extension, collab and SDK consumer stay open until an end arrives. The allowance that says a continuation is waiting was cleared by ANY turn that came back, while the end event beside it also required that turn to be non-empty, so an empty completion cleared the one and skipped the other: the next real turn then had nothing left to close the wait with, and a prompt that accepts an empty completion as terminal (the autolearn nudge does) settled with a countdown still running on an idle session and nothing to cancel. An empty turn is no longer read as evidence the transport recovered, so the empty-stop ladder's own retry can still land a real turn and close the wait; a prompt that accepts the empty turn as terminal closes it as unsuccessful; and the empty-stop cap now clears the allowance it just answered, so a later agent-initiated turn cannot report a second, successful recovery for a continuation that had already given up.
  • The yolo rung stops asking about commands that are dangerous without being destructive. Every rung's own copy has always described it the same way, "only blatantly destructive commands (rm -rf / and its expansions) and an explicit deny policy still stop a call", and the code was broader than all three places that say so: the floor was every entry in the bash guard's pattern table, so a curl -fsSL https://…/install.sh | sh typed by the operator, in their own repository, stopped and asked with the reason "Critical pattern detected". A floor that catches an install is not a floor, it is the auto rung wearing yolo's label. The table now records what each shape does rather than only that it matched, and the floor is the destructive half: a delete running as root, a formatted filesystem, a raw device written over, a system account file overwritten, a fork bomb, plus the expansion-aware delete rules that were already there. The other half (a script fetched from the network and piped into a shell, a host shutdown or kill -9 1, a shell wired to a network socket) still stops on plan, ask, ask-command and auto exactly as before, so nothing about the default rung loosens; it is yolo, and the /yolo bypass whose whole promise is no prompts, that stop asking. A line that trips both halves is reported as the destructive one, so the floor cannot be talked out of a mkfs by a curl on the same line.
  • Each flagged bash shape now says what it would do. Nineteen patterns shared the reason "Critical pattern detected", which names the mechanism and not the risk, so the approval prompt told the operator that something in a list had matched and nothing about what: the same six words appeared over a fork bomb, a reboot and an install script. The prompt now reads "Formats a filesystem", "Runs a script fetched from the network", "Overwrites a system account file", and so on, from the table entry that matched.
  • The subagent HUD, browser guests, extensions, hooks and the SDK now learn which recovery a child is waiting on, instead of being told every wait is a retry. The parent's HUD badge and detail line read continuing 1/2 in 3s for a batch that cannot be resent, and its terminal line says continuation gave up rather than auto-retry gave up; the wait's kind rides the same event out to the collab wire, AutoRetryStartEvent / AutoRetryEndEvent, the custom-tool session reasons and the SDK. The parent does not see the child's operator notice, so retrying there was not a contradiction on one screen like the host countdown was, just the one thing that had not happened. The subagent's terminal recovery line also stops saying 1 attempts.
  • The recovery countdown and the summary after it now name the recovery that actually ran. A continuation of an unreplayable tool batch shares the retry ladder's event on purpose, so it inherits the ladder's wait, its cancel key and every consumer already wired for it, but it was also inheriting the ladder's words: a countdown reading Retrying (1/2) sat on the same screen as the notice saying the batch was being continued with the calls that never ran, and the line left behind afterwards claimed Recovered after 1 retry on a turn whose whole point was that the batch could not be resent. The event now carries which recovery is waiting, and the countdown, the summary and the cancelled-wait error read Continuing, 1 continuation and Continuation failed after 1 attempt. That last line also stops saying 1 attempts.
  • The pause before continuing a turn the transport killed inside an unreplayable tool batch is now announced and can be cancelled. It was neither. retry.enabled routes such a failure to a continuation precisely because the retry ladder refuses it, so the ladder never ran and never created the gate isRetrying reports: nothing was emitted, so the countdown, a subagent HUD's retry state and every hook, extension, collab and SDK consumer saw an idle session; escape did not reach the retry cancel because that is gated on isRetrying; and the wait sat inside the post-prompt scheduler, where cancelling the retry could not have reached it anyway. Seconds of silence with no way out. The wait now emits the same auto_retry_start the ladder does, carrying the continuation's own attempt, its ceiling and the delay it is about to take, holds the retry gate while it runs, and ends on the retry cancel with an auto_retry_end that says it did not succeed. A continued turn that lands closes it the same way: the success-side end event was gated on the retry ladder's attempt counter, which a continuation never touches, and a start with no end leaves every one of those consumers showing a retry in progress on a turn that already came back. One helper now owns creating that gate, because a second promise would orphan the first and hang the prompt waiting on it.
  • Continuing a turn the transport killed inside an unreplayable tool batch now waits out the retry ladder's backoff first. The continuation borrows the retry budget but fired immediately, so a provider failing instantly produced back-to-back requests carrying the largest context the session holds, at the moment the transport had just proved it was failing, which is the whole reason backoff exists. It waits the same exponential, jittered delay a retry attempt would, counted on its own attempts so a session that also retried does not inherit that ladder's position. retry.maxDelayMs clamps the wait rather than refusing the attempt: the ladder refuses because that wait is one the provider asked for and can be hours, while this one is ours and is bounded by the ceiling anyway, so refusing on it would turn an operator's ceiling into an off switch for the recovery. The delay is cancelled by anything that ends the turn, so typing during the pause still takes precedence. The backoff formula and the ceiling rule now live with the retry policy they read, next to the resolution they belong to, instead of in the session that calls them.
  • A new prompt restores the allowance for continuing a turn the transport killed inside an unreplayable tool batch. That allowance was cleared only by a turn that came back, so a turn which died for good left it spent: one bad turn disarmed the recovery for the rest of the session, and the operator's next prompt died the old silent way, parked with no continuation and nothing saying why. It is per incident, like every other turn-recovery allowance, and a prompt is a new incident. Continuations re-request on a path a prompt does not take, so a provider dying on every attempt still settles at the cap instead of looping. The reset that fires when a turn lands stays, and is what covers a second transport death inside one prompt, which spans many requests and has no new prompt to key off.
  • A tool call whose arguments were still streaming when the transport died now counts as work the turn has left to do. Such a call has its block deleted, because partial arguments are unsafe to run and an unpaired tool_use breaks replay, so nothing in the transcript ever pairs against its id and no result for it can exist. The continuation looked for an outstanding call by searching the results, which for this shape can only ever answer no, and the id was added to the candidate set anyway: a line that looked like coverage and could not change the outcome. So a batch whose only unfinished work was a call the provider cut off mid-arguments went quiet on a fault the classifier calls transient, with a ledger telling the model to reconstruct those arguments and nothing left to send it. The call is now counted directly from incompleteToolCalls, which is where its identity lives.
  • A tool call that already carries a real result no longer counts as work left to do. A provider stream that dies partway through a tool batch it may not replay continues the turn on the calls that never ran, and the agent loop pairs every streamed call with a never-ran placeholder even when a real result for that same call id is already in context. The continuation asked only whether some placeholder existed anywhere in the batch, so a batch whose one call had genuinely finished continued twice on nothing, spending two more requests and warning the operator about a replay it did not need. The question is now asked per call id: an id counts as never-ran only when it holds a placeholder and no real result, so a mixed batch (some calls interrupted, some never dispatched) still continues on exactly the calls that are outstanding.
  • The turn-level form of a partial-completion ledger expires too. A batch that leaves no placeholder to hang the ledger on, because every call was dispatched out of band or no call's arguments ever finished, sends the whole ledger as a synthetic user message instead. That form stores no ledger data to re-render from, so the ledger-expiry rule could not see it and twenty-nine lines and about two thousand characters of orders about calls the model had already reissued rode on every later request, exactly the case the placeholder-borne form had just been fixed for. It is recognized by the headline its own renderer writes and dropped whole once an assistant turn has answered it. A user message that is not synthetic keeps its text even when it quotes a ledger, because an operator pasting one back to ask about it is content, not an expired instruction.
  • A tool batch's partial-completion ledger stops being sent once the model has answered the batch. The ledger is a standing instruction rather than a record: it names every call in a cut-short batch and tells the model that only the calls marked "never ran" need retrying, and it rides on one placeholder result that lives in the transcript. A turn the session retries never showed the problem, because a retried context drops the whole dead turn; a turn the session continues stays in history on purpose, because the continuation answered from it, so its ledger travelled on every later request for the rest of the session and again after a resume. On the reported 75-call batch that is twenty-nine lines and about two thousand characters of orders about calls the model had already reissued, pointing at results sitting in the same context, which is a standing invitation to redo the work. The ledger is now removed from the outbound copy of that placeholder as soon as an assistant turn follows it, at the single conversion seam both live and resumed requests pass through, so the two cannot disagree about it. The placeholder's own sentence stays, because "this call was not executed" is a record; the stored transcript keeps the full ledger and still renders it; and the text that is removed is re-rendered from the ledger data by the one function that wrote it, so the removal cannot drift from what was written.
  • A transport fault that kills a tool batch the session may not replay now continues the turn instead of parking it. A provider stream that dies partway through a batch is ordinarily retried, and a retry is refused when any call in the batch may already have run: a Cursor exec-channel call is dispatched through the caller's handler INSIDE the provider stream, before its block is even synthesized, so it may have finished, may still be running, and may have applied half its work. Replay safety was then the end of the road. A reported turn on Cursor read Stream closed with error code NGHTTP2_INTERNAL_ERROR followed by Partial completion ledger for this tool batch (75 calls): 0 ran, 21 interrupted, 54 never ran, on a fault the classifier itself calls transient, and the session simply stopped: the only way forward was to notice and type something. Retry and continuation answer different questions. Retry re-sends the turn, which is what replay safety forbids; continuation sends the turn already in context, which is complete and valid on its own, since the failed assistant message kept its calls, every call the loop never dispatched is paired with a never-ran placeholder, and the ledger names which ones need reissuing. Nothing is duplicated because nothing is resent. The bar is deliberately narrow: the failure has to be one the retry ladder would have taken but for replay safety, at least one call has to have genuinely never run, retry has to be enabled, and each continuation is charged against retry.maxRetries and cleared by a turn that comes back, so a provider dying on every attempt cannot loop. The operator is told once, naming the reason, rather than being left to work out why a batch of 75 calls went quiet.
  • A todo call the provider stream killed before it ran no longer reports itself as a failed todo update. The same reported turn carried three identical Warning: Todo update failed: Tool call was not executed because the provider stream ended with an error before the tool could run lines, one per todo call in the dead batch. Nothing had failed and no progress was stale: the calls never happened, which the error card and the batch ledger each already say once, so three copies of a non-event buried the one message naming the real cause. The warning now consults toolResultNeverRan, the single rule for "nothing happened", so a placeholder is silent while a real todo failure still warns, and so does a call an interrupt caught mid-flight, whose side effects are real and partial.
  • Yolo stops prompting for work done inside a throwaway container. critical is the one strength /yolo cannot lift, and both halves of the bash guard judged a container's own command as if it ran on this machine: the text patterns matched the remote-fetch-then-execute shape inside the container's script, and the word scan follows a sh -c string, so the container's root was judged against the host's protected roots. docker run --rm fedora:latest sh -c 'curl -fsSL .../install.sh | sh && ...' prompted, and so did docker run --rm alpine rm -rf /, for a filesystem that belongs to the image and lives for the length of the command. A docker, podman or nerdctl run whose flags leave this host out of reach is now exempt from the critical floor, and only that segment of the line is: a delete beside it on the host is judged exactly as before. The exemption is a whitelist of flags that grant nothing, so it is refused for a volume, a bind mount, --volumes-from, --privileged, --device, --cap-add, --security-opt, --sysctl, --group-add, --runtime, a host or peer namespace (--network host, --net=host, --network container:web, --pid host, --ipc host, --uts host, --userns host, --cgroupns host), a short cluster carrying v, an expansion standing where a flag goes, an image name it cannot resolve, docker exec, and docker compose run, and it is refused for any flag the whitelist has never heard of, which is what keeps the next escape from being exempt by default.
  • A second session opened on the same transcript no longer deletes the first session's turns. SessionManager appends through a writer handle but republishes the WHOLE file on every rewrite (compaction, an elision, a recovered error, a title-change fallback), and the body it published held only the entries that manager knew about, so every line the other process had appended since it read the file was dropped. Two terminals reach that in three steps: --continue twice, or /resume a session another instance still has open, then let either side compact. The loss was silent both ways and irreversible, because the process losing its turns is not the process writing the file, and a later reader saw a transcript that simply never contained them. A publish now reads the file back first and carries every line whose entry id was never this manager's, so the two histories merge instead of overwriting each other, and the operator is told once that a second session is writing the same file. The publish that runs on the way out (flushSync, exit, and the fallback an append takes when the file is not current) re-reads too, through a synchronous read the file and memory backends answer directly, because a tail deleted on that path is deleted for good: the process is leaving and no later rewrite will carry it back. Parallel appends make the transcript a tree, which it already is: both turns are on disk, and the branch keys navigate between them. A fork or a branch leaves the other writer's lines behind rather than copying them into its own file.
  • A session whose file was republished by another window keeps writing where readers can see it. The other half of the same defect: an append goes through a writer handle, and a full-file publish is a temp write plus a rename, so every handle open on that path was left addressing an inode nothing can reach. The appends reported success, the entries stayed in memory and rendered on screen, and no reader ever saw them; on exit the last handle closed and they were gone. A session now checks, before each append, that the path still names the file it wrote (the backend's own dev:ino, not the length, because the ordinary case is the other window republishing our history byte for byte) and hands the entry to the merging rewrite when it does not. A whole turn's worth of entries landing in that window is carried by the same rewrite, each exactly once. A title change is checked the same way, and it is the case that hid best: naming a session patches a fixed-width slot by PATH and appends the entry recording it through the handle, so the new title reached the file every reader saw while the entry behind it went into the inode nothing can reach.
  • Quitting a window with an empty composer no longer deletes a conversation another window is having. A session holding nothing but a draft materializes its file only so the draft has something to attach to, and the file is dropped again on close once the draft is gone, which is the rule that stops empty sessions accumulating. It decided the session was empty from the entries THIS manager held, and a draft-only session's file is the newest one in its directory, which makes it exactly the file another window resumes. So typing a few words, clearing them and quitting deleted the transcript and the artifacts directory of a real conversation still in progress next door, from a process that had never held a turn of it and so could not report the loss. The drop now asks the question a publish asks: a line whose entry id was never this manager's keeps the session, and the operator is told the file has a second writer. The session /move leaves behind is dropped by the same rule for the same reason and now asks the same question, through one owner rather than two copies of it. A session nothing else wrote is still dropped, on both paths.
  • A transcript that fails to write once now resumes writing as soon as the disk lets it. A failed write latched in #diskFailure and every later persist refused on the strength of that latch: the append that failed threw nothing (the writer rejects asynchronously), the NEXT append threw the earlier ENOSPC at a caller doing nothing wrong, the disk chain refused to run any queued work, and close() rethrew instead of publishing. So a disk that filled and was emptied, a network project that dropped for a second, or a session directory whose permissions were fixed while the window stayed open all produced the same outcome: the transcript ended at the fault, every turn after it lived in memory only, and the file stayed short a conversation with a healthy disk underneath it. Nobody was told either, because the one report was a logger.error and the default transports are file-only with no console transport a TUI could use anyway. A fault is a moment, not a property of the session: every path that persists (the append, the title change, flush, the synchronous exit flush, close) now gives a latched fault one attempt, and one attempt is enough because a full-file publish writes every entry the session holds. The fault reaches the operator through the channel a surface renders, once per episode, where an episode ends at the next successful publish, so a broken disk does not put a notice on screen per entry and a second, unrelated fault is not swallowed by the first one's flag. A caller asking for durability while the disk is still broken is still told, with the error from THIS attempt rather than the first one.
  • A blob store that cannot be written no longer kills the turn or empties the transcript. A large text block or image is externalized to <agentDir>/blobs on persist so the JSONL line stays small, and the write threw straight out of the persist path: fs.mkdirSync and fs.writeFileSync had no guard anywhere between them and appendMessage. So a blobs directory that could not be written (a full data root, one that is read-only, one whose path is occupied by something that is not a directory, a data root on a different filesystem than the session) threw out of the call that records an assistant message, the turn died with an EEXIST/ENOSPC naming a directory the operator never typed, the entry was never handed to the transcript subscribers, and on a session's FIRST turn the whole file including its header was left at zero lines. It did not recover either: the entry was skipped rather than deferred, so healing the directory persisted later turns and never that one. Externalizing is a size optimization, not a correctness requirement, because the content it moves is the content already in memory, so a blob store that refuses the bytes now keeps them inline. The line is large and the conversation is intact, the reason is logged once per store rather than once per oversized payload in an entry, and the next entry externalizes again as soon as the directory works. putSync itself is unchanged and still throws, because SessionManager.putBlobSync hands a ref to a caller who asked for one.
  • One unreadable line in a session file no longer hides the turns around it. Entries form a tree keyed by parentId and the branch walk climbs from a leaf to the header, so the record after a dropped one is an orphan and that climb stops there: on a four-turn transcript with one half-written line, the conversation came back holding two turns, and the two before the gap were loaded, still on disk, and unreachable. The loader has always dropped a record it cannot read rather than refusing the whole session, which is right, and it said Skipped 1 malformed record while the real cost was every turn on the far side of the break. A record whose parent is not in the file is now re-linked to the record in front of it, which is the order the file was written in, so a break costs the records that cannot be read and nothing else. It reaches both parse paths (the in-memory one and the separate streaming loop for a file over 8 MiB), it covers an orphan that arrives with nothing dropped at all (a publish cut off mid-write, a foreign writer's tail that was truncated), the repair is published on the next write so the same file is not re-diagnosed forever, and the operator is told what was re-linked as well as what was lost. A transcript that is legitimately a tree, which is what two windows appending at once produce, keeps its shape: only a parent that is absent is repaired.
  • A stored payload the blob store no longer has costs that payload, and no longer every turn of the session. A large text block or an image is externalized on persist and the JSONL line keeps a blobtext:sha256:… / blob:sha256:… reference, which the load path resolves back to the bytes. When the blob is gone (a veyyon gc --blobs --apply whose reference scan never saw this transcript, a home directory restored without its blobs, a transcript carried off another machine or another --agent-dir) the load keeps the reference, which is right, because restoring the store restores the payload. What was wrong is that the reference then travelled: the request carried data: "blob:sha256:…" inside an image block, and a hash is not base64, so the provider refuses the request and refuses it again on every later turn, because the reference lives in history. A blobtext: reference reached the model as text, so the model read a hash where its own earlier output had been. Nobody was told either: the one report was a logger.warn and the default transports are file-only. The reference is now replaced, at the same provider-context seam the image policy resolves at, by a sentence naming what is unavailable, so the turn runs with the loss described instead of dying on it. A reference that survives inside providerPayload, the native history a Responses-style provider replays, drops the payload rather than the turn: replay is an optimization over the ordinary conversion, which carries the sentence. The transcript itself is untouched, so the payload comes back the moment the blob store does, and the operator is told once per load how many payloads are missing and what that costs.
  • Both session load paths are now one loop, so a rule about a damaged record cannot reach one and miss the other. A session under 8 MiB is read as one string and a larger one is streamed line by line, and that is the only difference between them: how a line arrives. The JSON parse, the shape check, the line and byte cursor a drop is reported at, the orphan re-link above and both operator notices were written twice, once per path, which is how the re-link landed in one copy while the other kept losing the turns past a gap. They share one record loop now, and a suite drives one byte-identical fixture (a physical title slot, damaged records, an orphan, a sibling pair) through both paths and compares the entries, the title and the notices down to the quoted byte offset, which is the part reading the two functions cannot confirm: one skips the title slot by starting its cursor past it, the other by stepping over it.
  • A compaction whose keep marker names a record that is gone no longer drops every turn it kept. A compaction summarizes the old span and keeps the most recent pre-compaction entries verbatim, naming the first of them by id, and the rebuild resolved that id with a private walk that only asked "have I seen it yet". The field has three cases and that walk answered "keep nothing" to all three: an ordinary id, the keep-nothing sentinel a compaction of one unbreakable oversized turn writes on purpose, and an id that resolves to no entry at all. Two producers reach the third with no operator mistake: the loader drops a record it cannot parse rather than refusing the session, so one half-written line removes the exact record the marker names, and the v1 migration left the field unset whenever the old numeric index pointed at the session header. The cost was silent and total, on every rebuild, resume and fork: every kept turn vanished from the model's context and from the transcript while the summary made the session look whole, and the prune and shake passes went on rewriting those same entries, because they read the field through the shared reader, which treats an unresolvable id as "the whole branch is live". The rebuild now reads it through that same reader, so a lost record costs that record: the span is re-expanded, which overlaps the summary by a few turns and loses nothing, and the re-expansion is logged rather than silent. The keep-nothing sentinel still keeps nothing.
  • The context a session reports now follows a pass that rewrote its history. Removing bytes from the prompt is the point of the per-turn prune, the redundant-result dedup, /shake and the image drop, and the number the operator reads (and every maintenance decision measures) is either the snapshot of the prompt as submitted, while a turn is in flight, or the provider's own prompt count for the last response it sent. Neither described the session after a rewrite: the dead-end rescue re-anchored the snapshot after each of its tiers and the automatic dedup site did too, while four rewrite owners did not, and nothing anywhere retired a provider count that had been computed over bytes the rewrite removed. So /shake between turns left the gauge exactly where it was, and mid-turn the same figure fed the post-compaction headroom and retry-fit checks, which declared a dead end over context that was already gone. The re-anchor now belongs to the rewrite instead of to whoever remembers to call it: one epilogue persists the new shape, re-primes the agent and the advisors, drops the provider sessions that cache message identity, rebases the pending snapshot, and records where the rewrite happened so an anchor from before it is not read as ground truth. Until a response arrives that describes the current shape the report is the local estimate, which is what the compaction decision was already flooring with.
  • An image block now reaches a provider only when the model serving THAT request can read it. Whether images may travel was decided during message conversion, which sees one model per session, while the main turn, compaction, an advisor and every side request each dispatch their own. So a session on a vision model with a text-only role model (a cheap models.compact, a text-only advisor) converted its image blocks through untouched and shipped them to a model that answers an image with a 400: the request could never succeed, and the session's own model looked capable. The reverse was as quiet, a text-only session model stripping images a vision role could have read. The whole policy (the operator's images.blockImages, the serving model's vision support, the per-provider image cap) now resolves in one place, the session's provider-context seam, which is the only point that receives the dispatch model. The request is shaped; stored history keeps its images, so a resume on a vision model still finds them.
  • A plan reference that is an ordinary path no longer kills the session. A plan file is addressed either as a session-local URL (local://<slug>-plan.md, what plan mode tells the agent to write) or as a path relative to the working directory, and the product accepts both: five call sites branched on the spelling, including one thirty lines below the offender in the same file. The plan-reference message built on every prompt did not branch, so a reference with no URL scheme (a stored mode_change entry, an ACP approval, an eval bridge) resolved as a URL and threw Invalid URL: docs/roadmap-plan.md out of prompt() before anything was read. That is the first prompt after a plan is approved and every prompt after it, the turn dies with a message naming a URL the operator never typed, and the plan mode the crash came from is the mode most likely to be carrying a named plan file. Resolution now has one owner (resolvePlanFilePath) that every site delegates to, so a spelling the product accepts in one place cannot be a crash in another.
  • The plan file survives every pass that rewrites history, asserted per pass rather than per outcome. The end-of-turn supersede pass, the redundant-result dedup and the shake elide tier are each handed the plan matcher through one helper, and a simulation now drives all three in a live session with a non-plan read of identical size as the control. Two things that test found: with the plan read by its canonical local:// alias the supersede row could not fail (the supersede key exempts any scheme:// path on its own, so the matcher there is only load-bearing for a plan named without a scheme, which is what the row now uses), and the crash above, which the same row reproduces.
  • Auto-compaction summaries now go out under the same provider settings as the turn beside them. A summarization request is a side request, and reaching the session's settings-aware, concurrency-limited transport requires the call site to hand compact() its completeImpl; the default inside compact() is a bare transport that reads no settings at all. Manual compaction, the handoff and the tree-navigation summary passed it, and the two that did not were auto-compaction (the most frequently fired request of the five) and the advisor's own overflow compaction. Those two went out with no stream idle or first-event watchdog, outside providers.maxInFlightRequests, and without providers.openrouterVariant, so a summarizer whose provider went silent had no deadline that would ever end it, several of them could leave at once under a cap that was supposed to bracket them, and an advisor on OpenRouter lost the sticky routing its live turns used. All five sites now name one shared transport on the session, so a new side request cannot be written without it, and the pairing is asserted on the wire: a simulation reads the options the provider was actually called with for the summarization request and the live requests around it.
  • Every summary a session asks for now carries the service tier the operator chose for that model's family. tier.openai, tier.anthropic and tier.google are serving priority and cost, and they reached the live turn (the loop asks the session per request) plus the handoff, but the compaction summary, the turn-prefix summary of a split turn, the advisor's overflow summary and a tree-navigation branch summary listed no tier at their call sites. So an operator on flex paid standard rates for the single largest request a session makes, and an operator on priority waited at standard speed for the summary that blocks the next turn. Each site now resolves the tier for the candidate that will run it (the advisor through its own tier.advisor resolver, which may inherit the session's), so a compaction candidate on a different family than the live model gets that family's tier rather than the conversation's. Server-side compaction still sends none, because that endpoint's request body has no field for it.
  • The title a session gives itself no longer goes out on an unwatched connection. Naming a session is a request the session makes for itself, and all three paths that fire one (the first user message, a replan once the model inits a todo list, and the label for every spawned subagent) built theirs on a bare transport that reads no operator setting at all: no stream idle watchdog, no first-event watchdog, outside providers.maxInFlightRequests and outside the per-provider concurrency cap, while the turn beside it carried all four. A title model that goes silent is a hang rather than a wrong answer, so one silent request could retire the replan refresh for the rest of the session (it is held behind a single in-flight latch), and a wide task fan-out issued one unbracketed request per subagent. All three now run on the session's side transport, the same one every compaction summary and handoff uses.
  • The two questions a session asks a small model on your behalf now run on a watched connection. auto thinking asks how hard your prompt is, once per turn, before the turn starts; unexpected-stop detection asks whether a reply that stopped without tool calls meant to keep going, at every settle. Neither request was visible anywhere and both were built on a bare transport that reads no operator setting: no stream idle watchdog, no first-event watchdog, outside providers.maxInFlightRequests and outside the per-provider concurrency cap, while the turn beside them carried all four. A silent provider on either one is a hang with nothing on screen, and the difficulty question holds the START of the turn, so the session sat behind its own 4s abort before the turn it was classifying could begin. Both now take the session's side transport, the same one every compaction summary, handoff and title uses, and the shape that transport has is now owned by one type (session/side-complete.ts) rather than being described again at each seam.
  • A provider stream that dies after asking for tools, but before any of them could run, is retried instead of ending the turn. The two shapes that hit this are a stream that stalls until the watchdog gives up ("Provider stream stalled while waiting for the next event") and OpenAI closing without a terminal finish reason; both are transient transport faults, both had streamed their tool calls, and neither had applied anything. The session refused to retry any failed turn carrying a tool call, on the premise that a completed call may already have run. It cannot have: the agent loop has one tool.execute() call site and the error stop returns before it, pairing every retained call with a placeholder that says so. So a zero-cost transport failure became a dead turn, and what reached the model was a fabrication (a ledger telling it to reissue calls on a batch where nothing had happened) while the operator read a wall of provider error text for a request that never left. A turn is now replay-unsafe only when there is evidence work happened: a Cursor exec-channel block, which dispatches the tool inside the provider stream, or a call already answered by a real result. Discarding the turn also takes the never-ran placeholders that were appended after it, which is what made the removal miss before (the failed assistant message was no longer last, so the dead turn and its placeholders were replayed into the retry). With the retry budget spent the old safety net is unchanged: every dropped call is still paired and the ledger still says which ones never ran, because a turn that cannot be replayed still has to leave the conversation answerable.
  • A tool call whose turn died no longer renders as a failed tool, and the provider's error is stated once, as the turn's error, instead of once per call. Two things were wrong on screen at the same time. Every transcript rebuild fabricated a result for those calls ({ the provider's error, isError: true } with no details), which says the TOOL failed: a red ✗ failed frame with the transport error presented as that command's own Output, and nothing saying the call never ran. The live path had been fixed for exactly this and the rebuild had not, so a card changed its story when the transcript was rebuilt from state. The rebuild now hands those calls the same never-ran placeholder the loop writes, so both surfaces render one shape: the call, and one line naming the reason. A read row in that state is marked not-run (!) rather than as a file that could not be read, and an ask card falls back to its label instead of painting the selectable question, because a question that was never asked must not be left looking answerable (a command or diff preview is still shown: in that state the call is the one fact the card has left). Second, the reason belongs to the turn, and it is said once above the cards. It used to be repeated on every dropped call under a pinned turn error that had already said it, so a wide batch was a wall of yellow text; a card names the fault now only when it carries the batch ledger, which the loop attaches to exactly one placeholder per cut-short batch. Taking it off the cards left a rebuilt turn stating its reason nowhere, because splitting an assistant turn into display segments scrubbed the stop reason off the head segment as well as the trailing ones, so a reloaded dead turn showed cards saying only that nothing ran and never what had happened. The head segment keeps the stop now: a rebuilt dead turn reads Error: <what the provider said> once, then one line per call saying it never ran. Live, that copy is still suppressed while the banner mirrors it, and the banner is mirrored on the head segment rather than on a trailing sentence, which is what used to leave an inline error on screen underneath it.
  • Resuming a session that survived a mid-stream tool-batch death no longer replays the death to the model. A turn a retry recovered is dropped from the model's context by design, and the never-ran placeholders paired to it were not, so a reopened transcript sent tool results whose tool_use was gone, one of them still carrying the batch ledger: 0 ran, 2 never ran and only the calls marked "never ran" need retrying. The retry had already reissued those calls and run them, so the first thing a resumed session read was an instruction to run the batch a second time, and for a batch that writes (a migration, a deploy) a restart was enough to trigger the duplicate. A dropped turn now takes the run of results paired to it, and only that run: the retried turn can reissue the same call ids, so a rule held for the rest of the walk would take the replay's real results too and leave a call with no answer. The stored transcript is untouched, because the transcript is what renders the retry.
  • /fast off no longer spends the service tier you configured. Fast mode sets priority for the family of the current model, and turning it off cleared that family outright, so an operator with tier.openai: flex (a cheaper, slower tier) who pressed /fast twice was left on no tier at all for the rest of the session, with no message saying so. The change is persisted as a service_tier_change entry, so every resume of that transcript kept the loss. Turning fast mode off now returns the family to its configured tier, and only a configured priority clears, because there is nothing else to go back to.
  • Compaction no longer un-spends the tokens it summarizes away. getSessionStats summed usage over the LIVE context, and compaction replaces that context with a summary, so every total it feeds fell on the turn a session compacted: /session reported half the cost the session had paid, the numbers went DOWN while the work went on, and a resumed compacted session started its accounting from the summary. Spend now covers the messages behind the latest compaction boundary as well as the live context, read off the stored branch (which is also what a resume reads, so the total survives a restart) and counted once per range however many times a session has compacted. The context gauge is unchanged and still reports the live context: what is in the window and what has been spent are two different questions.
  • The compaction-threshold notice no longer tells you to change a setting that is working. A profile configured with compaction.threshold: "256000" warned on every 200000-token model that the amount was "larger than this model's context window", said it was "compacting at 200k", and advised lowering it. Every number in it was arithmetically true and the sentence was still false: the trigger was one token below the window, where it can never fire, so proactive compaction was off and the session was relying on error-driven recovery. The threshold now caps at the largest trigger the model can reach and the notice says which one that is, as info rather than warning, because a model-independent amount is a legal choice that a larger model still honors in full. The token rows in /settings -> Model -> Auto-Compaction Threshold no longer promise the same trigger "on every model" either: the row description now says a smaller model compacts at its own maximum.
  • A context-overflow refusal is no longer swallowed when compaction is off. The recovery ladder pulls the failed turn out of context before it tries to promote or compact, so a retry cannot replay it, and the rung that gives up never put it back: with compaction.enabled false the prompt resolved with no assistant message, no error event and nothing in the transcript, so the one failure that most needs an explanation ("your context is too long") was the only one that produced total silence, while an ordinary 400 left its error on screen. The truncated-output rung had the same hole behind a comment claiming it surfaced the dead end. Both now restore the failed turn into active context, and only there, since the persisted entry was never dropped on those paths.
  • Four features that ship off no longer show their knobs in /settings. lsp.enabled, browser.enabled, github.enabled and secrets.enabled all default to false, and 13 rows below them rendered on a fresh install: lazy language-server startup and three diagnostics rules for a session with no server, headless and cmux and a screenshot directory for a Chromium nothing launches, a view cache with two TTLs for an unavailable tool, and a secret lifetime and audit log with no vault behind them. Each read as a feature that was on and broken. They now carry a condition on their master, so the screen shows the switch and nothing else until you turn it on. The masters themselves stay reachable, and the guard behind this is derived from the schema rather than a list of paths, so the next off-by-default feature that forgets a condition is red on arrival instead of the fifth instance of it.
  • The slash-command reference lists every subcommand, and docs/environment-variables.md names six variables it was missing. 88 of the 101 subcommands the registry accepts appeared nowhere on the reference page, because the category tables write /todo … and /mcp … and the enumeration behind the ellipsis was never written: /permissions yolo, /account login, /todo done and /memory rebuild could only be found by typing the bare command and reading the picker. The page now carries the full table, and the guard that already held command names and aliases in both directions holds subcommands the same way, including the two-level memory mm * names. The environment table gains the five VEYYON_CODEX_WEBSOCKET_* transport tunables that had defaults in code and no row, plus VEYYON_NO_INTENT, which strips the injected i parameter from every tool schema.
  • edit.modelVariants is a declared setting, and the settings reference now lists every key that exists only in a configuration file. The per-model edit-mode table is consulted on every edit and docs/environment-variables.md tells you it outranks edit.mode, yet it was absent from the schema, so it had no declared type, no validated shape, no default, and no row on any page: the only way to learn it existed was to read the source. It is the same omission the generated reference was written to close, seen from the other side, and it was not alone. 118 other keys the schema declares with no /settings row appeared in no generated document at all, among them gc.*, hindsight.*, commit.mapReduce*, shellMinimizer.*, skills.ignoredSkills, tui.maxInlineImages and the machine-written setupVersion. docs/settings-reference.md now carries a "Configuration file only" section covering all 119, with type and default, and the generator refuses to write a page that omits any schema key, so the next one is red on arrival rather than invisible for a year.
  • The context gauge reports the total that decides compaction. Every compaction check floors the provider-reported prompt tokens by a local estimate of the conversation the session is actually holding, because a provider that reports a smaller prompt than the stored history must not be able to suppress compaction; the footline gauge and /context skipped that floor and printed the provider's number alone. On a session whose provider under-reports, the composer read ▰▰▰▰▰▰▰▱ 90% left while auto-compaction fired on every single turn, and no number anywhere on screen disagreed with any other, which leaves an operator nothing to debug. getContextBreakdown applies the floor once, so display and decision are the same number: measured on the reproduction, the gauge said 1,000 tokens while the predicate saw 13,504.
  • A failed auto-compaction names the model that failed. The unattended path tries several candidates and surfaced only the last error, so a provider-specific fault arrived as a bare Auto-compaction failed: Summarization failed: 402 … with nothing identifying which provider had answered, on a session running a different one. Each candidate's failure now carries its provider/model, and landing on a fallback is announced the way the manual /compact path already announced it, with the reason the earlier candidate was passed over.
  • A CPU-limit host probe that cannot even start its command no longer takes the process down with it. probeCpuLimitSupport asks the host one question (systemctl --user show-environment) and guarded a rejected promise, but the production runner reaches child_process.execFile, which can fault SYNCHRONOUSLY: a bad executable path, EMFILE, or a spawn implementation that refuses the argument shape. A synchronous throw walks past a .catch(...), so an optional capability check surfaced as an unhandled error, and in one observed case it also produced three phantom terminal writes and a hook timeout in a suite that has nothing to do with CPU limits. A spawn that cannot start is now the same thing as a command that exits nonzero, with the reason on stderr, which is what every caller already reads.
  • The release-metadata check has a deadline again. getLatestRelease armed its request with new AbortController().signal, a signal nothing ever aborts, so the timeoutMs every caller passes reached the error message and nothing else: a connection that accepts and then stalls hung the call forever. It runs on the startup path, so a captive portal, a black-holed route, or a proxy that never answers froze the launch instead of falling back to "could not check for updates", and veyyon update --check had no way out but ctrl-c. It now uses the same withTimeoutSignal the release list and the checksum sidecar use, so a stall ends on its own budget and says how long it waited. The regression test drives a connection that answers only when its signal fires, rather than synthesizing a TimeoutError, which is what let a dead signal pass for a live one.
  • No behavior change: the session budget group reads the settings SLOT rather than the Settings store, so tools/read.ts and tools/fetch.ts no longer instantiate the store's module graph to spawn a process. The limits it reports and the point at which they apply are identical.
  • /account status, /account refresh and /account name no longer treat a prediction as spending. Routing answers "which account serves this provider next" even before the session has sent anything, and marks that answer as a prediction; the three text surfaces read the mark alone, so a fresh session's block listed every credentialed provider under the header "Accounts in use by this session", the footer claimed "2 of 2 providers in use", /account refresh re-probed credentials nothing had spent, and /account name silently named an account on a provider whose traffic had not started. The observed question now has one owner, activeSessionAccounts, which excludes predicted rows, and /account name asks it rather than hand-rolling the same predicate a second time. The account card is unaffected and still says serves next, because a prediction is exactly what it is reporting there. /account refresh with nothing routed also names its remedy instead of stopping at a refusal.
  • A reply that ends with a question to the user is no longer talked over by the unexpected-stop retry. With features.unexpectedStopDetection on, the final reply goes to a classifier that answers whether the model stopped mid-thought, and a question about what to do next is the shape it says yes to most readily: its own instructions listed "Should I do that for you?" as an example of an unexpected stop. The retry then appended a nudge and re-invoked the model, so the answer the session was waiting for was overwritten by another agent turn, and it happened only sometimes because whether that guard is armed at all is a setting. The route now consults the same settle policy the rewind, plan-mode, todo and verification routes consult, and it consults it BEFORE the classifier runs, so a question costs no classifier call either. Deferring spends nothing: a genuinely abandoned turn still gets its nudge at the next settle that is not waiting on an answer. The classifier's instructions no longer teach it that a direct question to the user is an abandoned turn.
  • A pasted API key is masked on every login surface, including the ones that asked for it without saying it was a credential. The login dialog and the onboarding wizard both read OAuthPrompt.secret as "echo unless told otherwise", and eighteen provider flows told them nothing, so pasting a key into /login kagi, /login nvidia, /login tavily or onboarding drew it in clear text on screen. Silence now masks on both surfaces, a flow that wants a readable field (an endpoint choice, a GitHub Enterprise domain, an email address) says so, and the pasted authorization code the paste-code providers ask for is masked as well, because it is exchangeable for tokens. RPC mode forwards the same flag on its input request so an editor client can mask its own field; an ACP client's elicitation form has no masked field to ask for and is unchanged.
  • The bash-tool-nudge banner says what the rule actually fires on. It read "when a bash command starts with grep/rg/find" while the condition fires on any command the call opens with one, so a call that began with ls and ran find in its third command surfaced advice that contradicted the command printed beside it, which is how a rule teaches you to ignore it.
  • session/account-inventory.ts takes the OAuth disable-cause prefix from the module that declares it rather than from the @veyyon/ai barrel, which pulled the streaming engine and every transport in for one string constant.
  • No user-facing change: two comments still described the deleted Secret Manager card, telling a reader to extend a lease with e on a row and claiming manager was the only word /secret reserves. They name /secret extend, /secret list and the verb set instead.
  • The prompt statement preferring the image tool over a plain read is gated on BOTH tools it names. It was gated on inspect_image alone, so a session carrying the image tool without read was told to prefer one tool over another it could not call, which is the same defect as the verification bullet that named the browser after the browser tool shipped off. The statement is a preference between two tools, so it needs both of them.
  • The Todos block above the composer collapses to one line once nothing is open, the same Todo list done · N tasks line the transcript card and the HTML export collapse to, from the same owner in @veyyon/wire. The card already collapsed, so a finished plan produced both at once on one screen: a green line saying the list is done, and directly under it an anchored block redrawing every phase and every struck-through task for the rest of the session, in the one region that never scrolls away. The collapse is derived from the board in hand rather than remembered, so appending open work reopens the full list on the next frame, and the expand toggle does not reveal a finished board on either surface. Closed todos still leave the board entirely if you have configured Settings → Tools → Todos → Todo Auto-Clear Delay; the collapse is what happens while they are still on it.
  • getGroup("statusLine") is typed from the schema instead of from a hand-written list. It returns every statusLine.* key at run time and the interface named six of eleven, so enabled, sessionAccent, transparent, compactThinkingLevel and showAccount were all readable and none of them type-checked. A mapped type over the schema paths cannot fall behind the next footline knob.
  • The composer footline no longer overflows the terminal while a view is proxied onto an agent. The focus badge (designer-3 · esc to go back) is prefixed to the row verbatim, and the room left for the segments was floored at one cell, so on a narrow terminal a 28-cell badge plus a segment rendered a 31-cell row into a 10-cell window: it wrapped, and the wrap pushed the composer up a row on every render. The badge is now clamped to the row exactly as the badge-only path already clamped it, and a badge that fills the row leaves no segments behind it rather than one cell's worth. Only reachable with the footline on and an agent focused, which is why it survived the row shipping off.
  • Every file in the product wore , the Command glyph, as its language badge: an Edit header read Edit: ⌘ packages/tui/src/box.ts, and the Python file below it carried the same mark. The default symbol preset has no per-language glyph and getLangIcon resurrected the blank one as lang.default, so a badge that distinguished nothing cost two columns of a header that truncates its path to fit. That preset now paints no badge at all; nerd keeps its devicons and ascii its per-language abbreviations, and the space after a badge belongs to the badge, so an absent one leaves no gap.
  • A tool call that never ran no longer renders as a failure. It drew the red ✗ failed frame of a command that ran and exited non-zero, with an Output section carrying the loop's model-facing placeholder ("Tool call was not executed because the provider stream ended with an error before the tool could run", and for a truncated call a paragraph instructing the model to split its payload), above a notice that said the same thing again in the operator's words. The card now shows the call the assistant asked for and one reason line, and that line carries the provider's own error, which used to reach the screen only inside the suppressed paragraph. This covers both placeholder shapes: a call the loop never dispatched, and a call an interrupt cut the batch short of before the tool was entered. A tool that was already running when the interrupt arrived keeps its output, because those side effects are real.
  • /secret's subcommands work in the terminal. list, rm, extend, log, discard and help did not parse there at all: the argument line was read as a credential, so /secret list stored the string list in the vault, switched secret protection on and reported success, while the command's own help advertised all five. A reserved first word is now a command on every surface, a malformed one is refused instead of stored, and /secret -- <value> stores a credential that begins with one.
  • /secret's argument dropdown offers every subcommand instead of only manager, derived from the parser's own table of reserved words, so a subcommand cannot be typeable and unoffered. Stored names and values are still never rendered there.
  • A slash command that declares both an inline hint and subcommands now shows the declared hint on an empty argument. The subcommand hint is null there, so the declared string reached nothing: /secret and /collab both showed an empty line where their grammar should be.
  • A turn that ends by asking the user a question is no longer overwritten by an autonomous continuation. Four guards in the agent_end tail can schedule one (a checkpoint demanding a rewind, plan mode demanding a decision, an unfinished todo board, missing verification evidence) and each decided on its own whether to fire; only the todo reminder ever checked, so whether a question reached the user or was immediately answered by another agent turn depended on which guard happened to be armed. That is the "reinvoked randomly, not consistent" report: the trigger was hidden state, not anything the user did. The tail now computes "is this reply waiting on the user" once and every route consults one policy table, so a route added later cannot compile without stating its own answer. Deferring is not dropping: each guard's state survives and fires at the next settle that is not waiting on an answer.
  • The empty-stop retry budget is returned to zero when its cycle ends instead of being left above the cap. A turn that does not run the per-prompt reset (an agent-initiated maintenance nudge, an IRC wake, a queued follow-up) inherited the exhausted counter, so it capped on its FIRST empty stop with zero retries and logged an attempt count for requests it never made. The developer reminders a retry cycle appends are dropped with it, so a later turn is not carrying instructions about a turn that was already discarded.
  • A resource limit the operator set reaches a session that never runs a shell command. session.writeBudgetGb, session.memoryLimitGb and session.maxProcesses were carried to the budget group by the bash, launch, write and edit gates only, so a session whose work was an eval kernel or an MCP server created its group with no memory.max and no pids.max written at all, and a runaway allocation in a Python cell met no ceiling. The limiter now reads the configured values itself when no gate has supplied them, and it does so before deciding whether a group is needed, since a limit nobody has mentioned yet is still a reason to create one. A value a gate does pass keeps winning, because a subagent's cloned settings are a better answer than the process-wide singleton.
  • subagent.maxConcurrency is one ceiling for a whole session tree again, rather than one per agent that spawns. The semaphore enforcing it lived on the task tool instance under a comment asserting that one instance meant one session, but a subagent gets its own tool set, so every spawning agent held a semaphore of its own and the operator's ceiling was multiplied by the number of spawners: with subagent.maxNestedSpawnDepth above 0, a cap of 32 admitted 32 per spawner. Stock installs leave that depth at 0, where a child receives no task tool and the per-agent and per-session readings coincide, which is why it stayed hidden. A tree is identified by the same budget-group owner the CPU, memory, disk and process limits key on, and a finished tree's ceiling is not inherited by a later session that happens to reuse its id.
  • A session's resource limits are no longer multiplied by delegating work to subagents. Every limit on the Resources tab is documented as scoped to the session TREE, and the budget group was keyed by session id, but a subagent opens its own SessionManager and therefore registered a SEPARATE group under its own id: an operator's 2 core cap became 2 cores per live subagent, and the same for the write budget and the process cap, which is the opposite of a limit. A subagent session now registers as an alias of the group its spawner belongs to, resolved through the alias chain so a subagent of a subagent lands in the ROOT group rather than in its parent's copy of it, and its own settings are not re-applied over the root's, because the operator set one budget for the tree. The group outlives any one member: a subagent finishing drops its alias and nothing else, /new and /resume on the root carry every borrower with the moved id, and only the owning session ending tears the group down.
  • The login screen is one frame instead of an append-only log. An API-key login that failed validation and asked again drew two questions, two footers, one status line per attempt, and its second question below the input that question belonged to. The frame now holds four things, each in one place: where to authorize, what is happening now, what is being asked, and which keys work. A pasted API key is masked and returned byte for byte, the title names the provider the way the rest of the product does (Login to OpenAI, not Login to openai), and the onboarding wizard masks its key prompt too.
  • The /debug protocol probe's test notification is delivered even while the terminal window has focus. Every other notification is now suppressed while you are looking at the window, and this one exists to answer whether notifications reach your desktop at all, so the gate would have reported a working notifier as broken.
  • The account card stops advertising keys it will not answer. While the + add another … row was selected the footer still offered n name, u usage and x logout, and all three read the selected ACCOUNT, so each was a chip the card painted, made clickable, and answered with nothing. The enter chip on that row had already been taught to say what it actually does; the keys either side of it had not. They are now absent on that row and back the moment an account is selected, while r, a and b stay because they are provider or profile scoped and do act from there.
  • An armed logout no longer survives whatever you do next. x arms the confirm and a second x removes the credential, but only escape and the arrows disarmed it, so x followed by a rename, a refresh, a balancing toggle or a provider switch left the confirm live, and the next x (about something else, minutes later) deleted an account. Every key other than the confirm itself disarms it now, at the one point they all pass through, so a key added later cannot forget to.
  • /login <text> answers the name you typed. Both /login and /logout resolved their argument by comparing it exactly, case sensitively, against a provider id, and everything else fell through to the pasted-callback path, whose reply is No OAuth login is waiting for a manual callback. So /login Anthropic, /login Claude, /login openrouter and /login gpt-9-turbo-max all produced one sentence about a subsystem the operator had never mentioned, with no next step in it, and the display name that the account card, the model hub and the login picker all print was the one spelling the command refused. The argument is classified now instead of falling through: a provider by id or display name (folded over case, spaces, dashes and underscores) starts that provider's login, a provider that signs in with an API key is named and handed the environment variable it actually reads, callback-shaped text with nothing pending keeps the manual-callback sentence that is true for exactly that case, and anything else is refused by name with the list of providers that would have worked. /logout shares the one resolver, so the two halves of the account surface can no longer disagree about what a provider is called, and it answers an API-key provider with the fact that there is no stored login to remove rather than calling it unknown.
  • An unknown provider name gets an answer you can read. The refusal from /login gpt-9-turbo-max named every provider that supports a browser login, which is 57 ids across twelve lines of transcript in reply to what is almost always a typo, so the one useful fact in it was buried in a list nobody scans. It now leads with the near misses, through the same helper that suggests a mistyped flag or setting key, and points at the picker with the number of providers in it: Did you mean anthropic? Run /login with no argument to pick from 57 providers you can sign in to. The count stays because it is the one number that says whether opening the picker is worth it.
  • /logout <provider> reaches anything the account card can remove. It resolved its argument against providers with an interactive login flow and nothing else, so /logout groq answered "has no stored login to remove" while the card two keystrokes away listed that exact stored API key and deleted it with x: two surfaces over one credential store, disagreeing about whether a credential exists. Any provider holding a stored credential is a logout target now, whichever way that credential was stored, and both spellings of the command (/logout and /account logout) resolve it the same way. A provider with genuinely nothing stored is still refused, by name rather than as an unknown provider.
  • The logout dialog spells the provider the way the rest of the product does, and says something about the key it is going to delete. Its title came from the registry's browser-login table, which has no row for a provider that authenticates with an API key, so opening it for one printed the raw slug: Logout · groq next to a card, a status line and a receipt that all say Groq. It reads through the one provider-label helper now. The row under it said API key #5 with stored API key #5 beneath, the same row number twice; the detail line now says the credential is stored on this machine, which is the scope removing it actually has.
  • A login started from a command lands on the account it added. /login <provider> and /account login <provider> wrote a "Successfully logged in" receipt to the transcript and returned you to the composer, so the account that was the entire point of the command was visible nowhere and /account had to be opened as a separate step. The card's own a key already round-tripped, which left one of the two ways to add an account landing somewhere useful and the other not. A login that stored a credential now opens the account manager on that provider with the new account in the list, while a login that failed or was cancelled leaves you where you were, because a card claiming success on top of an error is worse than the error alone.
  • Adding an account from the account card comes back to the card. a and + add another … called done() and then started the login, so the surface that answers "did that land, and is it selected" was torn down before the login it started had opened: you signed in and found the composer, with the new account visible nowhere. The login now round-trips — the card reopens on the provider it was started from, with the inventory rebuilt, so a freshly stored credential is in the list you are already looking at. It reopens after a login that failed or was abandoned too, because escape unwinds one level and the card is the level you came from. The new credential is deliberately not made the serving account: with load balancing off the selection decides what gets spent, and registering an account is not a request to start spending it.
  • A provider is spelled the way its vendor spells it. Every provider name a user reads is derived from the provider slug by one helper, and that helper title-cased each -/_ segment, so the /providers card, /usage, the usage CLI and the status line all read Openai Codex, Github Copilot, Cloudflare Ai Gateway, Deepseek, Google Gemini Cli, Xai Oauth, Minimax Cn, Nvidia, Openrouter, Litellm and Zai. The account manager card is what made it unmissable, because it lists every provider at once. Segments a vendor writes in a way title case cannot reach now carry that spelling (OpenAI, GitHub, AI, DeepSeek, Hugging Face, NanoGPT, NVIDIA, OpenRouter, CoreWeave, LiteLLM, vLLM, ZenMux, MiniMax, CLI, OAuth, xAI, zAI), and anything else keeps the mechanical rule, so a provider added tomorrow still renders without an entry. The spellings are not invented here: each one is what the catalog descriptor's own catalogDiscovery.label or the CLI help already says, and the suite takes its expectations from those descriptors at run time, so a new provider whose catalog label disagrees with the rendered name fails the build rather than shipping misspelled.
  • The account manager card no longer truncates the sentences it writes itself. The card is a split, so its pane is the narrow half, and every line the card composed was cut there rather than wrapped: the armed-logout confirmation read press x again to log out of Groq cr…, losing both which credential was about to be removed and that esc backs out; the scope line read shared by every profile and session on thi…, dropping the load-balancing state that sentence exists to deliver; the provider header read Anthropic · 3 accounts · 1 needs attenti…, cutting the only clause saying something is wrong; and the rotation explanation and the empty-provider sentence went the same way. All of them now wrap, the way a failed credential's upstream cause already did. Two things are still clipped on purpose and say why: text the card RECEIVES (a provider's own failure cause, at three lines) and an account's own head line, where a tag that does not fit is dropped so the identity keeps the room.
  • The model picker no longer keeps padding it cannot afford, and no card can decide that for itself again. ModelPicker carried its own termRows < 24 where every other card asks the shared rule, so between 24 and 32 terminal rows it stayed padded while the rule says the card is still pinned to its floor: opening /model on an ordinary split pane spent four rows of the list on blank padding, then handed them back in one jump at 33 rows. The decision is now unreachable from a call site. withCompact(sizing, decision) is replaced by sizingForArea(sizing, areaHeight, forceCompact?), which takes the height and applies the rule itself, so a caller can only ask for a card to go compact EARLIER (the session selector does, when it is not filling the height) and never later, which is the only direction the cliff lives in. The one-owner test used to prove this by grepping the sources for /\b(?:term)?[Hh]eight\s*[<>]=?\s*24\b/, which could not see a variable named termRows; it now asserts the property over every exported sizing plus a synthetic grid of margins and paddings, and pins the geometric precondition the guarantee rests on: a sizing whose margin is thinner than twice its padding cannot be continuous at its own boundary, with a negative control proving that is geometry rather than folklore.
  • The upstream-truncation notice says again that the elided amount is unknown, and a session recorded under an older wording strips cleanly. Compacting the sentence to Truncated upstream: 7B kept for tokens dropped the one fact the agent cannot recover by looking: how much was cut is not merely unstated here, it was never reported, so no re-read with a larger limit will produce it and the tail is all there is. The wording stays short, at Truncated upstream: 7B kept, elided amount not reported. The compaction also broke the strip, because the renderer removes a notice by rebuilding it from the metadata and matching the tail of the body, so a result written before the change kept its old sentence and printed it twice: verbatim in the output pane and again in the styled warning beside it. Retired wordings now keep a builder in RETIRED_TRUNCATION_NOTICES, and the strip tries every spelling this build has shipped.
  • A bash result no longer carries two blank lines for a notice it stopped emitting. Omitting the wall-time line from the model payload left the push that adds it, so every completed command paid for the notice list's separator with nothing in it, and the renderer's strip became a no-op that could no longer fold the line out of a session recorded before the change: an older transcript printed Wall time: 1.23 seconds in the output pane beside Wall: 1.23s in the footer. The payload is now the command's own bytes plus the notices that still exist, and the retired notice keeps its stripper so a persisted result still reads its wall time once. Three renderer cases had been asserting exactly this and failing on main since the omission landed.
  • An account with several usage windows shows all of them, and an account with no stored identity shows any at all. Two independent defects, both found against a real credential store rather than a fixture. Window labels came from limit.window.label alone, so a provider reporting one counter per backend rendered three bars all reading Daily and one reporting a plan window beside a per-model one rendered two reading 7 days: distinct windows, identical rows, which reads as a single window repeated. A window now carries the qualifier the limit's own label supplies (Daily · Anthropic, 7 days · Spark), duplicate reports are collapsed keeping the freshest, and the bars are ordered shortest window first instead of arriving in provider order. Separately, usage attribution required a stored credential's identity to match the report's, which is the right rule between siblings and wrong when a provider holds exactly one credential: Cursor, Kimi and xAI store no email and no account id, so those accounts showed no quota at all, and an Anthropic row carrying an orgId the report's metadata omitted lost every window it had. A provider with one credential now takes its report unconditionally, because the usage fan-out issues one request per stored credential and there is no other account for it to have come from. The label gutter is also sized per account and the clamp raised from 12 to 20 characters, so a qualified label is readable and every bar of one account still starts in the same column.
  • Choosing an account is one keystroke, and the card says what the keystroke does. The footer chip read enter use for Anthropic, which names a mechanism rather than the action; it now reads enter switch Anthropic to this account, and the row that is serving is tagged serving with the account you chose tagged your choice. The body also states the scope it never used to: every account on the card is shared by every profile and session on this machine, so switching changes what a different terminal will use too, and the same line reports whether quota load balancing is on. b toggles that setting from the card, because the question "what happens when this account runs out" is the one the bars provoke and answering it should not require leaving the screen.
  • A relaunch from a compiled binary no longer hands the new process its own entry path as a prompt. resolveVeyyonCommand forwarded process.argv[1], which in a compiled binary is the embedded /$bunfs/root/.../cli.js path rather than a script anything can run, so the relaunched session's argument parser read it as the initial prompt and a profile switch opened a transcript whose first user message was a bunfs path. A compiled binary now relaunches through process.execPath with no positional at all.
  • Every retired subagent.agents.<name> row is named, not just the row of the agent that happens to be resolving. model and thinkingLevel were retired when subagent model and effort got one owner, and the report fired from the resolving agent's row only, so a leftover on an agent that never spawns was never mentioned: the value sat in the operator's config looking configured and governing nothing, which is the state retiring the field was meant to end. The sweep now walks the whole subagent.agents table on the first resolution, keeps its per-field dedupe so later resolutions cost nothing, and still says nothing about a blank field, which is what a cleared row and the old Inherit pickers left behind. The retired field list and each field's replacement text are now a table, so a third retired field cannot be added without naming where its value went.
  • An ask question sent as an array, or an option sent as an array, is now refused as an array rather than as a question with no id or an option with no label. The guard reached for id and label on any non-string value, so a model that passed ["yes", "no"] where an option object belonged was told the option had no label, which points at the wrong field and at the wrong caller. The refusal text for the object and array cases also reads "an array" and "an object" now instead of "a array" and "a object".
  • A tool card the transcript rebuild painted with a turn-ending error is now recorded as final, so a later replay of the same call cannot mount a live card beside it. The ghost-question fix settled a rebuilt card in two places, next to a recorded toolResult and next to the trailing seal at the end of an idle rebuild, and missed the third way a rebuild produces a final card: a turn that stopped with stopReason: "error" draws the error into the card and never tracks it as pending, because no result is coming. A tool_execution_start for that call then built a second card, which for ask is the answered question re-offered as interactive with the error still above it. Both branches that paint an errored card, the grouped read and the generic tool, settle now.
  • The todo tool's model-facing text asks one owner whether a task has closed, instead of six hand-written status pairs. The "Overall: X/Y done, Z open" line, the remaining-items count, the active-phase progress, the worked-ahead note, the preview markers and the next-task pointer each listed the closed or the open spellings themselves, so they could disagree with the card above them and with each other. Behaviour is unchanged for today's four statuses; a fifth one now fails the build at each tally rather than being counted as neither done nor open.
  • A retired subagent.agents.<name>.model or .thinkingLevel is now named on every agent that still carries one, not only on the agent that happened to spawn. The report was scoped to the resolving agent, so a leftover on a DISABLED agent was never mentioned at all: that agent never resolves, so the value sat in the config looking configured and governing nothing, which is the state retiring the field was meant to end. Nobody should have to enable an agent to discover that its setting is dead. The whole subagent.agents table is swept on the first resolution of a session, still one report per agent and field for the life of the process.
  • An assistant reply that asks the user something now ends the turn, whichever settle guard happens to be armed. Four guards can re-wake the agent after it stops: an open checkpoint demanding a rewind, plan mode demanding an ask/resolve, an unfinished todo board, and a mutation with no verification behind it. Only the todo reminder asked whether the reply was a question, so a session that stopped to ask something was sometimes left alone and sometimes continued straight over the top of its own question, decided entirely by which guard was armed: from the outside, a turn that "gets reinvoked randomly" with no user action behind it. The decision now has one owner, SETTLE_CONTINUATION_POLICY, computed once per settle and consulted by every route, so a fifth guard cannot inherit the old behavior by default. Detection was widened at the same time: it tested the strict last line, which missed the single most ordinary shape of a real question, the question followed by the options it offers, and now walks back past option lines, table rows, headings and fenced blocks to the line that carries the prose. A deferred guard is deferred rather than dropped: the reminder it was holding still fires at the next settle that is not a question.
  • A tool called from an eval cell is now validated against its own schema, as one the model calls through the agent loop already was. The bridge that serves every cell language executed tools directly, so a cell was the one caller in the process that could hand a tool a shape its schema rejects: tool.ask({ questions: [{ id, options }] }) reached the ask dialog with no question text and the render pass threw an uncaught TypeError that killed the session and every subagent running under it. Validation happens at the one bridge all four transports share (the JS worker, the loopback HTTP bridge that serves Python and Ruby, the browser tab worker and cmux), so a cell now gets the same field-naming error the model gets, along with the same argument repairs, so a numeric string for a number argument still works. Arguments that are not an object at all are refused by name, the harness-injected intent field is kept out of validation so a closed schema (an MCP server's, say) no longer sees it as an unrecognized key, and yield, which validates its own structured payload, keeps its declared opt-out.
  • An ask dialog whose question carries no question text now refuses to open, naming the field and the question, instead of taking the whole process down. renderQuestionTitle reads question.question with no fallback and the constructor reads question.options.length, which is safe for every in-tree caller and for nobody else: ExtensionUI.askDialog is a published extension API and the collab/RPC paths hand over decoded JSON, not type-checked objects. One question shaped {id, header, options} reached the header renderer and threw undefined is not an object (evaluating 'text.replaceAll') from inside a render pass — an uncaught exception, so there was no tool error and no notice, just a dead session that took four running subagents with it. The dialog now checks its own precondition once at construction; a violation rejects the dialog promise, releases the modal surface, and reaches the caller as an ordinary error naming what to fix. Refusing beats a placeholder, which would ask a question nobody wrote and record an answer to it.
  • Settings → Model → Default Effort now explains a picker it has narrowed to nothing instead of heading a single row as a list of choices. Pick a model that exposes no selectable effort — cursor/composer-1.5, and 3057 of the 4138 bundled models — and the step printed "Valid effort variants for cursor/composer-1.5." over one row reading "Model default", which reads as a truncated list and invites a hunt for the missing levels. The narrowing was already correct; only the caption lied. It now says the model exposes no selectable effort, so only the inherit row applies, and the sentence has one owner (noSelectableEffortNotice in src/thinking.ts) shared with the Subagent Effort row, parameterised by the label each surface gives its inherit row — two literals would drift and the same model would read as differently broken on each screen. A model that does declare a ladder is unaffected and keeps the variants heading.
  • A defaultEffort row naming a level that does not exist is now reported instead of silently ignored. defaultEffort is a hand-editable map of model selector to effort, so "anthropic/claude-sonnet-4-6": "hgih" is a plausible hand-edit; the read path returned undefined for the unparseable value and fell through to the next source, so the setting was inert with nothing anywhere saying why the model was not running at the effort the file asked for. The reporter that subagent.thinkingLevel already had is now shared: parseConfiguredEffortSetting in src/config/effort-resolver.ts owns it for both, names the row, the value and the accepted levels, and warns once per process per key because the resolver runs on every status-line render. A row that resolves, is absent, is blank, or says auto stays silent.
  • RPC set_thinking_level refuses a level the model does not declare instead of answering success after quietly changing it to something else. Every other effort surface narrows to the model — the pickers do not offer it, /effort answers "Choose one of: …", and ACP's thought_level option throws with the accepted list — but RPC applied whatever arrived, and AgentSession.setThinkingLevel clamped it to a supported neighbour or dropped it and wrote a log line no client reads. So an embedder was told xhigh was set on a model with no such wire field and every later request ran at an effort nobody chose. It now returns an error naming the model, the rejected level and what would have worked, which is the shape the neighbouring set_model arm already used for an unknown model. inherit is still always accepted: it is how a client clears its choice, not a level to run at.
  • Fixed the "ghost question": an ask you had already answered could re-appear in the transcript as a live, interactive card, wired to nothing — no model call produced it and answering it did nothing. Every render path decided whether to build a tool card from pendingTools, which is a *pending* map: the entry is deleted the instant a result lands, so the check read the same "no card here" both before a card existed and after it finished. Any event that named the finished call again built a second card in the pending shape, and for ask the pending shape is the question with its option list. Re-announcing a finished call is ordinary — tool_execution_start has eight independent producers reaching one UI, and collab resync and subagent focus re-attach both resynthesize assistant deltas — so resolution is now a property of the transcript's own record of the call (settledToolCalls), consulted by every mount site: the two live ones, the tool-result handler, and the rebuild. The same hole silently duplicated cards for every other tool; that is fixed too. A settled call is inert on every path, a duplicate result no longer re-fires plan approval or todo displacement, and a backgrounded task still takes its live updates.
  • A todo board whose every task has closed collapses to one green Todo list done line instead of redrawing a finished plan in full on every later turn. It is a derivation, not a state: the renderer asks the board it was handed, so a list that gains a task after finishing draws in full again on the very next frame, and nothing in the session, the transcript, or the exported HTML records that a card was ever collapsed — a stored collapse bit is precisely what would keep a reopened list hidden. The terminality decision behind it also stopped being copied around. TODO_STATUS_IS_TERMINAL in @veyyon/wire is now the single owner of which statuses close a task, read by the TUI card, the todo HUD's auto-clear, and the HTML/collab renderer in @veyyon/tool-render, which kept a private copy of the whole status vocabulary and could therefore disagree with the terminal about whether a plan had finished. An unrecognized status in an old transcript reads as OPEN work, since announcing a finish nobody recorded is the worse failure.
  • A tool the session asks for and the registry does not hold is now logged by name instead of being dropped in silence. The drop itself is deliberate, because a stale selection naming a tool this build no longer ships must not fail the whole session, but nothing recorded that a name had been requested and not found. That silence is how the edit tool went missing: the session advertised 22 tools, sent 21, and the only trace was a model that never edited a file. Any future wiring defect that empties a tool out of the registry now leaves a warning naming the tool, the model, and the session.
  • The IRC ping-pong cap now actually fires while other subagents are working. It counts how many messages in a row a pair has confined to each other, scanning the global traffic log, and it ended that chain at the first line involving anyone else at all, including a line between two completely unrelated agents. With several subagents live something almost always landed in between, so the count rarely reached the cap and the loop the guard exists to stop ran unbounded, which is the loop that was reported. Traffic involving neither agent is now skipped instead of breaking the chain; only one of the pair talking to somebody else resets it, which is the rule the refusal message has always stated back to the operator.
  • The edit tool is no longer removed for the rest of a session because of the model that session opened on. sdk.ts deleted edit from the tool registry when a session was constructed on a cursor model, and the registry is built exactly once, so switching to Anthropic or OpenAI afterwards never brought it back. With a cursor model as the configured default that is every session, on every provider, left with no anchored edit tool and only whole-file write. Nothing reported it: a requested tool that is absent from the registry is dropped with no log and no error, so a session's own init record listed 22 active tools while 21 were ever advertised, and the difference was exactly edit. The exclusion is removed outright rather than re-evaluated per model, because the premise behind it was false: CURSOR_NATIVE_TOOL_NAMES is the set cursor-agent supplies for itself, edit was never in it, and withholding ours bought nothing.
  • A rule that matched a tool call no longer speaks over that call. The reminder a non-interrupting TTSR rule produces was prepended into the tool's own result, so on a call that also failed it became the first text block, and the first line of the first text block is the error headline: the card read x Error: <system-reminder reason="rule_violation" rule="cwd-reroot" path="builtin-..., next to the correct ! Injecting rule: cwd-reroot banner announcing the same rule a second time. The real failure was gone from the screen and displaced for the model too. Across 778 recorded sessions, 41 tool results lead with that markup, spread over six rules, so nothing about it was specific to cwd-reroot. The reminder now rides the hidden display: false channel the interrupting path has always used, delivered as an aside at the next step boundary: the model still reads it before its next tool call, the tool batch in flight is not cut short the way a steering message would cut it, and no surface the user reads carries internal <system-reminder> markup. Appending instead of prepending was the smaller change and was rejected, because it fixes the headline and leaves the markup on screen. The wording is also conditional now, since it asserted "The tool ran because the rule is configured not to interrupt" on calls that threw, returned an error, or were skipped, which is a false statement about what just happened told to a model that has no other account of it.
  • A benign sh -c "$SCRIPT" no longer takes a headless run down. Reaching into bash -c, eval and trap to find a delete written inside a script argument closed a real hole, but the same change refused any script word carrying an expansion it could not resolve, and critical is not a prompt everywhere. It is a floor /yolo cannot lift and a standing grant cannot apply to, so a session with no interactive surface (headless, CI, -p with no terminal, and every subagent under such a root, which inherits the root's surface) has nobody to ask and the tool call fails outright with "requires approval but no interactive UI available". Fifteen ordinary shapes reached that, among them bash -lc "$CMD", eval "$SETUP", sh -c "cd $DIR && make" and every eval "$(direnv hook bash)" / eval "$(rbenv init -)" line a shell profile is made of. The fail-closed direction was also buying nothing: this guard classifies one thing, a recursive delete of a protected path, and sh ./setup.sh, make, npm run clean and a bare $SCRIPT are all equally unreadable and all still run, so refusing one spelling stopped nobody hiding a delete and only caught the honest scripts. The script is now READ whether or not it resolved: a part that resolves is judged as ordinary shell text, and a part that does not stays a literal $NAME the inner scan calls unknown again, so bash -c "rm -rf $D" is still critical for the target it cannot name and the same command written bare gets the same verdict as the quoted one. Two refusals survive on top of that reading, both of them facts about the command rather than about unreadability: a script nested deeper than the guard follows, and a delete buried where the word scan cannot reach it, which is the eval "$(rm -rf /)" shape. The set of commands that reach a shell is now one exported registry rather than a branch in the middle of the scan, and the argument shapes are a union the scan handles exhaustively, so adding a shell is a change something can notice: a new member with no recorded decision fails the suite, and a new argument shape fails the type check.
  • A tool call an interrupt cut short no longer renders as a tool that ran and failed. The transcript already annotated the placeholder the loop invents for a call it never dispatched (__synthetic), which is the rarer of the two shapes; the far more common one is __skipped, the calls sitting behind the call you cancelled, and it went unread. So pressing Esc on a batch drew a screen of red x failed cards for tools that never touched the machine, each carrying the same fixed sentence about the interrupt, which is the one moment the transcript most needs to be honest about what ran. The annotation also distinguishes the two cases behind that shape, since they call for opposite responses: a call cut off before dispatch reads "not executed: an interrupt cut the batch short before this call ran", and one cut off inside the tool reads "cut off while running: side effects may be partial". The placeholder for a call skipped because a sibling was cancelling carried no discriminator at all until now, which is why this was invisible from the consumer side.
  • Context accounting now counts the tool schemas the request actually carries. When the full tool catalog is rendered into the system prompt, the provider-bound specs are sent with their descriptions stripped so the text rides the wire once instead of twice. The accounting measured the raw registry regardless, overstating the tool half by about 11,500 tokens per turn on the models that inline, which under the auto policy is Gemini and only Gemini. That total is not a display number: it feeds the compaction scaling ratio, and an inflated value keeps keepRecentTokens high, so each pass frees less history and the session comes back to the threshold sooner. Measured across 13,678 recorded Gemini turns, the value the ratio depends on was zero or negative on 11.6% of them, against 0.2% once the overcount is removed.
  • The critical-delete guard reads every command on the line, not just the first word of each segment. Every judgement started from argv[0] after stepping over sudo and friends, so anything else standing where the command word goes hid the delete outright rather than weakening the rule: for i in 1 ; do rm -rf / ; done, if true ; then rm -rf ~/.ssh ; fi, while true ; do rm -rf ~ ; done, { rm -rf / ; }, x() { rm -rf / ; }; x, time rm -rf /, ! rm -rf / and command rm -rf / were all classified as ordinary commands. On yolo that means they ran with no prompt, and on the default auto rung they ran unasked too, because the critical floor is what makes a delete stop and ask in the first place. A loop is the most natural way to write a cleanup, so reaching this took no intent at all. The classifier now asks the question at every word position instead of naming the keywords that may precede a command, since a list of keywords is a list of the bypasses somebody remembered. A script handed to a shell is read as shell text too, so eval "rm -rf ~", bash -c "rm -rf ~", sh -lc '...' and trap "rm -rf ~" EXIT are seen, and a script the guard cannot read at all, such as sh -c "$SCRIPT", is treated as critical rather than assumed safe. Scanning every position costs some prompts that did not happen before: a delete whose target is a variable inside a loop, for d in a b ; do rm -rf ./$d ; done, now asks, because an unresolvable delete target has always been critical and the loop was simply hiding it. That is the intended trade.
  • The temp-directory cleanup exemption can no longer be carried onto a name that was reassigned. The exemption lets a command line delete a directory it created with mktemp on that same line, and the check that the name had not been rewritten in between only ever looked at the run of bare NAME=value assignments in front of the command word. Every other way a shell writes a name puts it after the command word, so export T=/, declare T=/, readonly T=/, local T=/, read T, eval "T=/" and for T in / each left the exemption in force while rm -rf "$T" deleted something else entirely. Rather than enumerate those, the check now reads how the name is SPELLED: reading a name spells it $T, writing one spells it bare, so printf -v T, getopts o T and T[0]= are covered without anyone having listed them, and a command that can run shell text this scan never sees withdraws every carried name. Logging the path with echo "made $T", or cd "$T" before deleting it, still keeps the exemption, since that is the shape it exists for.
  • The settings migration stamp only moves forward. stampOwnedConfigMigrations rewrote settingsMigrationVersion whenever it differed from the current one, so an older build sharing a config directory with a newer one, an installed binary beside a source checkout, or a downgrade after a bad release, walked the stamp backwards. The newer build then read its own one-shot migrations as unrun and applied them a second time, to values the operator had set in between, which is the deletion the stamp exists to prevent.
  • A failing model pair no longer ping-pongs for the length of an outage. Restoring the primary model after its cooldown expired was allowed to run in the middle of a retry sequence, and the cooldown is shorter than a retry budget takes to burn: a server error suppresses the primary for 20 seconds, while ten retries at the capped backoff run for about 55. So the primary came back mid-sequence, the next failure hopped away from it again on a retry budget that had just been reset, and the two models alternated for as long as the fault lasted. Every lap re-sent the entire prompt at full input rate, and because only the winning attempt's usage is recorded, none of it appeared in the session cost. Measured on a reproduction: 26 requests and still going, against 5 once the chain is walked only once per turn.
  • Automatic compaction no longer sends a summarization request to a model whose context window cannot hold it. The engine has always exposed estimateCompactionRequestTokens so candidate admission can price the whole physical request, conversation plus static prompts plus previous summary plus hook context plus the requested output budget, and /compact consulted it and skipped candidates that did not fit. The automatic path did not, and that is the path that runs unattended on every threshold crossing and every overflow recovery: an undersized candidate got a full-context request that could only fail, and then the next candidate was billed for the same span again. Overflow recovery made this worst of all, because the first candidate is usually the session model whose window the conversation had just exceeded. Candidates that cannot fit are now skipped with the shortfall logged, and a compaction where none of them fit reports which model was short and by how much instead of a bare provider error. compaction.modelContextWindow overrides the window used for the decision, as it already did for /compact.
  • Settings → Model → Compaction → Remote Compaction describes what remote compaction actually does. The row claimed the entry "still stores a real local summary", and it never has: remote compaction is single-window, the provider's compacted window is the whole artifact, and the entry's summary is empty on purpose. An operator reading that row was told the opposite of the tradeoff they were being asked to accept. The text now answers the three questions the row exists for: it applies only when the session model is a supported OpenAI Responses model (Azure Responses deployments included, OpenAI Codex excluded, since the gate is the api family plus capability data and never a provider name), turning it off compacts locally on the usual summary path with readable summary text, and there is no second local summary because writing one would pay a model to re-summarize a span the provider already compacted and leave two versions of one range free to disagree. The same false claim is corrected in the source comments that carried it.
  • The driver write path for a server-side compaction is now covered. The engine returning an empty summary was tested and the read-side replay was tested, and nothing drove a remote-shaped result through AgentSession.compact(), which is where the result is validated, appended, and swapped into the live context. That gap is what let a successful compaction round trip be billed and then discarded. The new suite runs a real session against a local host answering the compaction endpoint and asserts the window is persisted verbatim and history is trimmed, plus the two cases that must still fail: an empty summary with no window, and an empty summary whose window is malformed.
  • /compact soft and /compact remote no longer tell you server-side compaction is gone. Both retirement notices claimed veyyon "no longer has" the provider-native path and summarizes locally instead, which is the opposite of what a session on a supported OpenAI Responses model does with compaction.remote on. The notices now point at the setting that governs it. The refusal for the retired per-provider remoteCompaction config key carried the same false sentence and now says the true one: the key is retired because server-side compaction takes no per-provider configuration, not because the feature went away. The rebuild suite for remote compaction entries was also built on a fixture storing readable summary text beside the provider window, an entry shape nothing has ever written, so its cross-provider case asserted that a resume degrades to a summary that does not exist. It uses the real shape now and asserts the real degradation: the original messages are re-expanded.
  • The composer's ctrl+b background hint and the key itself now work on a stock install. Both were gated on the same thing: only a call routed through the managed-job machinery registers a foreground wait, that registration is what the key resolves and what raises the chip, and the route was entered only when Bash Auto-Background or Bash Stall Detection was on. Both shipped off, so on a default profile no wait was ever registered: the hint never appeared, and ctrl+b fell through to its readline cursor-left meaning. Every non-PTY call takes the route now, and the two settings decide only whether a TIMER runs alongside the operator's key. Reported 2026-08-06, "there is no hint below the command that says you can background nor does it work".
  • A bash call whose own timeout is shorter than the auto-background threshold no longer backgrounds the instant it starts. The wall-clock timer is clamped to the command's timeout, since backgrounding a second before it would be killed anyway is pointless, and the "background immediately" decision read that clamped value: a collapsed timer was indistinguishable from an operator choosing Immediately. Harmless while the feature shipped off, and a command that never ran in view the moment it shipped on. Only a configured zero means immediately.
  • The context gauge no longer reads 0% left on a conversation that has barely started. A context window is static model metadata, so the catalog carries it, but an agent gateway adds models faster than the catalog is regenerated and discovery has no window field to read: an unrecognised model got AGENT_GATEWAY_DEFAULT_CONTEXT_WINDOW, a 200k guess, as the denominator of both the footer gauge and the compaction threshold. Cursor reports the real window on every turn and it was being discarded, so a 1M-window model that Cursor considered a fifth used rendered as empty, and auto-compaction fired each turn only to correctly answer that there was nothing worth summarizing. A window the provider states now outranks the catalogued one for every reader of model.contextWindow, on the live model and in the registry, and it survives the reloads that would otherwise restore the guess mid-session.
  • The GitHub Copilot provider's three rows in Settings → Providers describe what it actually reads. Each one named .github/, a repository-local tree the provider stopped loading when project scope was removed, so the settings screen offered a switch over a source that no longer existed and said nothing about the two roots the switch really governs. The rows now name ~/.copilot/ and COPILOT_CUSTOM_INSTRUCTIONS_DIRS. Nothing about what gets loaded changed; the description was the defect, and a provider description that names the wrong directory is worse than a terse one, because it is the only place an operator can check the answer without reading the source.
  • extractMCPServers in the OpenCode discovery provider no longer takes a level parameter. Removing project scope left it with one caller passing one value, "user", and a parameter that can only be one thing reads as though the other value is still reachable. No observable change: the provider reads ~/.config/opencode/opencode.json and nothing else, exactly as before.
  • A spawner can kill a running agent that has no background-job row. job list always REPORTED these agents, under "Running Agents — not job-backed", which is any agent woken via irc and any spawn whose job row settled while the agent kept running; job cancel could not touch one, and the tool said so, telling the model to coordinate via irc instead. That is fine advice for an agent that is working and useless for the case where you want it gone, so the class of agent that was visible, running, spending tokens and impossible to stop existed for as long as the process did. cancel now accepts the same id the listing prints. It is bounded by descent rather than by scope: you may kill an agent you spawned, directly or transitively, and nothing else, because everything in one conversation shares a scope and a scope check would let a child kill its own parent or a sibling it does not own. The transcript survives at history://<id>. The abort-then-release ordering now lives in one place, AgentLifecycleManager.terminate, shared with the Agent Control Center's x, and an agent whose session cannot be aborted is reported as still running rather than falsely reported as killed.
  • Two agents cannot trade messages forever. A pair that answers only each other has no way out on its own: every inbound message wakes the recipient, a wake is indistinguishable from progress, and neither agent can see the pattern because each one only ever sees the single message in front of it. Nothing bounded it, either — an irc wake is not a job, so no job budget applies, and the per-run soft request budget resets because a woken agent keeps re-entering — so an observed pair ran until the process was killed. The bus is the only participant that watches both halves, so the bound lives there: once a pair has exchanged sixteen messages in a row with nobody else involved, the next one is refused with a receipt telling the sender to decide the question itself or escalate to whoever spawned it. The count is of traffic confined to the pair rather than of strict alternation, because alternation is defeated by a single repeated send, and it resets the instant either agent talks to anyone else, so a pair that is also reporting to a third agent or to its spawner is never affected.
  • Answering an approval card for the session no longer leaves the cards behind it waiting for you. When a turn issued several calls to one tool at once, each call built its own approval card and queued it, and the standing session grant was read once when the call arrived, before its card ever reached the screen. So the first card asked, you chose Approve for session, the work ran, and then the queue drained: the same question again, once per call that had been waiting, about a tool you had already approved for the session. Concurrent calls to the same tool in the same session now join the prompt that is already open instead of queueing behind it, and they re-read the grant at the moment they wake, so one answer settles the whole batch. Deny for session settles it the same way. Nothing else widens: Approve still means this one call and still asks again for the next, the grant is still per tool and per session, and a call the grant cannot cover, a critical one, a boundary crossing, or a secret, still asks on its own terms.
  • Server-side compaction for OpenAI models bills one call, not two. When OpenAI compacted a session's context server-side, Veyyon ran a full local summarization of the same span alongside it and stored both, so the arrangement that was supposed to hand the work to the provider cost strictly more than doing it locally: the remote call, plus the identical paid summary that call was meant to replace. Only one of the two was ever read, because a session that can replay the provider's window has no use for prose describing it. The remote path now awaits the provider and nothing else, and the structural fields around the window (firstKeptEntryId, tokensBefore) come from the preparation, which owns them and never asked a model for them in the first place. Your compaction instructions still reach the provider, so an operator who asked for every file path to survive the cut still gets that. Two things the local summary supplied are gone by design: a session listing falls back to its header title where it showed a one-line synopsis, and the summary text no longer enumerates the files a span touched. There is no way to keep those and the saving both, since the only readable text the provider's window could yield is text a second model invented about a span it was never shown. A remote failure is also cheaper now: it costs one local summary instead of the two it used to, because there is no longer a discarded first one.
  • A resume that cannot replay a provider's compacted window re-expands the conversation instead of losing it. The window is encrypted under the provider's key and is meaningless to anyone else, so forking a session onto a different provider left an entry that could neither be replayed nor read, and with no summary text to fall back on it would have hidden the span behind a blank divider. The rebuild now checks that the active provider is the one that minted the window before it trusts the entry, and when it is not, every message the compaction hid comes back as real context. None of it was ever deleted: compaction only moves the first-kept marker, so the whole span was sitting in the session file the entire time.
  • The eval preludes name the real concurrency setting. parallel() and pipeline() size their worker pool from subagent.maxConcurrency, but the JavaScript, Python and Ruby preludes all documented it as task.maxConcurrency, a key that does not exist in the schema. Those docstrings are read by the model and printed to the operator, so the advice was actionable and wrong: veyyon config set task.maxConcurrency 8 exits non-zero on an unknown key and the pool silently stays at 32. Three names existed for one number, and only one of them was reachable.
  • The bash-tool nudge fires on a search it can replace, and no longer on a pipe it cannot. The rule tells the model to reach for the built-in grep/glob tools instead of shelling out, and its condition admitted a bare space in front of the tool name. A space is also what follows a pipe, so bun run check:types 2>&1 | grep -E "error TS" tripped it, and there the advice cannot be obeyed at all: the built-in tools take a path or a glob and cannot read another command's stdout, so there is nothing to switch to. A rule that fires where it cannot be obeyed teaches the model to ignore it, and it is then ignored on the searches it should have caught. The condition is now positional. It fires when a search tool starts a command, at line start or after &&, ||, ;, or a command substitution, and stays silent when the tool only consumes a pipeline. The body states the stdin limit that makes a pipe the exception, so the narrowing reads as a rule rather than a gap.
  • The Rules tab stops repeating itself. A row labelled *Rules*, inside a group named *Rules*, inside a tab named *Rules*, gave the operator three identical words and told them nothing at each step; it is now All Rules, which is what the row opens. The four stream-interrupt rows dropped a *TTSR* prefix their group heading already carries, so *TTSR Context Mode* under *Stream interrupts (TTSR)* is now *Context Mode*, matching how every other group here is written (the LSP group has *Format on Write*, not *LSP Format on Write*). The master toggle keeps the bare *TTSR* label, because a master row naming its own group is the house pattern and lsp.enabled does the same. Search is unaffected: it scores the setting id with a prefix bonus, so typing ttsr still ranks all five rows at the top. *Built-in Rules* also stopped pointing at a raw config key and now names the row underneath it.
  • A cleanup that deletes the temporary directory it just made no longer asks for approval. TMP=$(mktemp -d) && … && rm -rf "$TMP" is the most common cleanup an agent writes, and the guard refused all of them, in yolo too, because the blatantly destructive class is one of the two things yolo still stops for. The refusal was sound on the evidence it had: $(mktemp -d) is a command substitution, which the guard reads as one opaque word, so $TMP was unresolvable by the time the delete was judged and an unresolvable word in a recursive delete fails closed. It was still the wrong answer, because the command text says where that value came from. What now crosses a segment boundary is origin information rather than a value, so a name known to hold a path this command line created is exempt while nothing else about it is knowable, and cd / && rm -rf $PWD is untouched. The exemption needs the value to come from a bare mktemp substitution, the name to be unassigned since, and the delete target to be the whole word $TMP. rm -rf "$TMP"/* stays critical, because an empty TMP makes it rm -rf /*.
  • Staging a line range from a diff whose hunk header the parser cannot read now says so instead of staging nothing. The commit path and the edit path each carried their own copy of the unified hunk-header grammar, and they could only disagree in one direction: the edit path refused an unreadable header, the commit path returned a hunk at line 0. A combined merge header (@@@ -1,2 -1,2 +1,2 @@@) therefore made every hunk sit outside the requested range, so a line-range selection silently selected nothing. Both paths now read one grammar, and the commit path names the header it could not parse.
  • A collab guest no longer receives your secrets verbatim. /share ran the transcript through the secret obfuscator before uploading it; the collab host, which replicates the same transcript over a link anyone holding it can open, called it nowhere. Every host-to-guest frame now goes through one redaction seam, so the joining snapshot, live entries, agent events and error text all carry placeholders when Share → Redact Secrets is on, and the setting still turns it off for an operator who wants the raw stream. The seam is a single chokepoint rather than a scrub at each send site, so a frame added later cannot quietly skip it.
  • An exported /export HTML transcript is redacted the same way a shared one is. The export built its session data straight from the transcript with no obfuscator, so a file you were about to attach to a ticket held the literal credential the shared link would have hidden.
  • A collab link no longer puts the room key in a URL a terminal can open. The secret was dot-joined into the path (relay.example.com/r/<roomId>.<key>), and that text linkifies: one click on your own link sent the AES-256-GCM room key and the write token to the relay in an HTTP request line, so they landed in its access log and in any TLS-terminating proxy in front of it. That is exactly what the design forbids, since the relay is supposed to see opaque bytes. A host-bearing link now carries the secret in the fragment, which no client ever sends, so a click discloses only the room id the WebSocket handshake already reveals. Existing links in either form still parse. The browser deep link is unchanged: it nests the dot-joined form inside its own fragment, where a second raw # would be mangled.
  • A downloaded tool is checksum-verified before it is installed. sd, ast-grep, yt-dlp and ffmpeg were fetched over the network and written straight to disk, so a corrupted or substituted response became an executable veyyon then ran. The download is hashed while it streams and compared against the sha256 digest the GitHub release publishes for that exact asset; a mismatch deletes the partial file and refuses, and so does a release that publishes no digest at all.
  • Saving a setting can no longer empty a config.yml that could not be read. The save re-reads the file under a lock so an external edit survives, and the YAML writer then treats that result as the whole truth and deletes every key it does not contain. A read that failed for any reason other than the file being absent returned an empty config, so the next changed setting rewrote the file down to that one key and a stranded comment. Nothing was quarantined, because only a parse failure preserves a copy, so there was nothing left to restore from. This is the shape an update produces: the agent directory is rewritten under a running session, one read fails, and the debounced save that follows lands on a file that reads fine again. The save now fails and retries instead, reporting the filesystem's own reason, and a config file that exists but cannot be read is no longer mistaken for an empty one when choosing which file to write.
  • A subagent spawned while /yolo is on inherits the bypass. Every other part of the approval rung travelled to the child through the settings fork, but the full bypass is session state and was never written to settings, so the fork could not see it and the child asked again for something already answered.
  • Turning /yolo off now reaches a subagent that is already running. The bypass a child was given was a snapshot taken when it was spawned, so the revocation was silently partial: the parent went back to prompting while the child kept running every bash, edit and write unasked until it finished, and the status line only ever reflected the parent, so nothing on screen said the revocation had not landed. A child now consults its parent's live state on every check. The direction is narrowing only, so a parent holding the bypass still cannot hand one to a child that was spawned without it.
  • A session CPU budget disposed while its group was still being created no longer leaks the group and a watcher for the life of the process. /exit or /new during the first capped command is exactly that window: the group is created after the session is gone, so the teardown that ran earlier found nothing to release, the cgroup and its quota were never removed, and a once-a-second poll kept running against them. The group is now released when creation finishes into a disposed session, and nothing is handed back to a caller that would adopt into it.
  • One failed setup no longer disables the session CPU budget permanently. A momentarily unwritable cgroup parent or a busy systemd left the budget dead for the rest of the session, so an operator who read the warning, fixed the host and re-set session.cpuLimitCores got no budget and no second warning, with restarting veyyon the only recovery and nothing saying so. Changing the setting now re-arms it. The failure is still sticky within one setting value, so a broken host is not re-probed on every spawn.
  • The per-session CPU budget no longer drags the whole @veyyon/utils barrel into the tools/read closure. session/cpu-limit.ts and session/owned-resources.ts imported the barrel for two names each, which is 81 modules instantiated on a path that reads a file.
  • discoverSlashCommands imported from the package root is now the SDK helper the docs describe. An explicit loadSlashCommands as discoverSlashCommands re-export in the barrel had been shadowing the export * from "./sdk" that offers the same name, so every reader following docs/sdk.md silently got the other function.
  • An inert retry.perProvider entry no longer discards the broader one you wrote. The resolver picks the single most specific key addressing the model, deliberately without merging scopes, and only then asked whether that winner carried a usable value. So "cursor/*": { maxRetries: 9 } alongside a "cursor/grok-code-fast-1" entry that states nothing — {} left behind after clearing a value, a negative number, a quoted "3", a NaN — resolved to the built-in provider default of 3 rather than to 9. The wildcard was not overridden, it was never consulted: the empty entry won on specificity and then contributed nothing, so the whole config layer was skipped. describeRetryPolicySource then reported the result as cursor provider default, pointing at a built-in for a number the operator's own config produced, which is the wrong place to go looking. An entry with no usable field is no longer a candidate, so the most specific key that actually states a policy wins and is named as the source. The file's own rule already said it: every bad value falls through to the layer below.
  • computeStoredMessagesTokens honours its excludeEncryptedReasoning option on a cache hit. The exported helper keeps a running sum of the settled message prefix so the compaction checks do not re-walk the whole history several times a turn, and that sum was stored in a single slot keyed only on the message array. The option is not free of the sum: it decides whether an opaque provider reasoning payload is counted, which is exactly why estimateTokens underneath it keeps two slots rather than one. Calling the helper both ways against the same session therefore returned the first caller's total to the second, off by the full size of every signature in the settled history — measured at 1003 against 3 for a single 4000-character signature, in both directions. The running sum is now kept per variant. The one shipped caller passes the option constantly, so no in-product number moves; anything importing the helper from @veyyon/coding-agent/session/context-usage now gets the total it asked for.
  • compat.supportsServerCompaction is now declared in the models-config schema, so a typo in it is reported instead of ignored. It is the documented opt-in for pointing veyyon at a gateway that serves POST /responses/compact, and it is listed as overridable in the catalog, but it was missing from the compat field table that every one of its Responses-family siblings appears in. Undeclared keys are passed through unvalidated, so the flag happened to work while supportsServerCompaction: "yes" also validated cleanly, sailed into the resolved compat record, and was read as off by the engine's boolean check — no error, no server-side compaction, and nothing naming the mistake. A non-boolean is now rejected at config load with a message naming the key, and the flag is visible on the inferred model-override type.
  • A forced tool choice that survives an aborted turn keeps its identity. When a turn ends without serving the forced call, ToolChoiceQueue.reject replays the lost yield at the head of the queue, and it re-queued that yield under a decorated label — user-force-requeued instead of user-force. The label is not decoration: it is the key two live consumers match on exactly. #checkTodoCompletion reads consumeLastServedLabel() === "user-force" to suppress the incomplete-todo nag, on the ground that you asked for that specific tool and not a follow-up about your todo list, so the first replayed force reported the decorated name, the comparison missed, and the nag fired anyway. removeByLabel filters the queue by the same string, so a directive that requeued itself escaped the removal that was cancelling it and was forced onto the next turn regardless; the sibling clear() avoided the bug only because it empties the array after rejecting rather than filtering it. The replayed yield now carries the original label, which is what its own doc comment already promised: a stable label for targeted removal.
  • A session switch that fails part-way no longer leaves the abandoned session's provider prompt-cache identity on the live agent. switchSession rewrites two fields when it adopts a target transcript: the private mirror it uses to decide whether an inherited key may be discarded, and agent.promptCacheKey, which is the value that actually reaches the wire as prompt_cache_key. Its rollback restored only the mirror. So a /resume that failed after loading the target header — an unreachable recorded cwd, a model registry error, an extension hook that threw — rolled the transcript, the model, the tools and the thinking level back to where you were, and then sent every later turn of that restored conversation to the *other* conversation's cache shard. Two unrelated transcripts contending over one prefix tree, with the switch reported as failed and nothing on screen suggesting anything had moved. Measured on a session pinned to a fork's cache key: the turn after a failed switch went out under the target session's key instead. The rollback now restores the wire field alongside the mirror.
  • A per-session CPU limit now survives /new, /resume, a fork and a branch. The limiter was registered once, in the session constructor, under the session id of the moment, while every spawn site resolves it back by the session's CURRENT id. All four of those actions mint a fresh id on the same live process, after which the registry still held the budget under the conversation the operator had just left: nothing looked that id up any more, so the new conversation spawned unlimited. session.cpuLimitCores was still set and the settings screen still showed it, and session.cpuLimitKill went with it. The only symptom was an absence of throttling, which is indistinguishable from a fast machine. The session manager now reports an id change from the single place the id is written, and the session moves the existing budget onto the new id rather than opening a second one, so a background command started before /new stays inside the one budget the operator configured.
  • A tool call made from an eval snippet or a browser page is now subject to the same approval policy as one the model makes through the agent loop. Approval is enforced in one place, ExtensionToolWrapper.execute, and that wrapper reads its entire policy off the AgentToolContext its caller hands it. The agent loop resolves that context per call; the eval and browser bridges reached the same registered, approval-wrapped tools directly and passed none. An absent context does not read as "unknown, so ask", it resolves every control to its permissive branch at once, because each one is a field that is now missing: no settings, so tools.approval.<tool>: deny was an empty policy map and a hard block written by hand never fired; no planModeActive, so a plan session stopped capping mutation; no sessionApprovals, so "Deny for session" was forgotten on the next call through the bridge; no sessionManager, so the cwd boundary compared against an empty path. The bridge now passes the live context, which the session resolves through the same ToolContextStore the loop uses.
  • Pinning a durable default effort now works when the session already sits at that level. The write lived inside setThinkingLevel's isChanging branch, next to the event emission, the transcript entry, and the prompt-cache invalidation, all of which are legitimately gated on the level actually moving. Durability is not: asking for the level you can already see to become your default is the ordinary way to set one, and it was the single case that silently did nothing, leaving the value to revert on the next model switch or restart. The write now happens whenever persist is requested. off stays non-persistable, being a state to leave rather than a default to keep.
  • The exported and shared HTML session viewer no longer executes markup carried in a transcript. Markdown was rendered through a function named safeMarkedParse that was bare marked.parse: the renderer overrode text, codespan and code, while raw html tokens and link hrefs fell through to marked's defaults and were emitted verbatim. Against the parser the export actually ships, hello <img src=x onerror=alert(1)> rendered as a live img tag, a <script> block passed straight through, and [click](javascript:alert(1)) produced a javascript: href. Anyone who can land text in a transcript reached that sink: a page the fetch tool pulled in, a README in a cloned repo, an MCP tool result, a subagent return, or prompt-injected model text. On /share the payload ran on the share origin, which is also the default collab web base, so same-origin script could read a live session's room key and write token out of the URL fragment; on /export it ran when you opened the file. Raw HTML is escaped now and link hrefs are scheme-checked, the same two overrides the collab-web viewer already had, so http, mailto, relative and fragment targets still render and outbound links carry rel="noopener".
  • The systemd backend for per-session CPU limits could never succeed. It held its budget group open with systemd-run --user --scope ... -- sleep infinity, but --scope runs the command in the foreground, so the call never returned: a ten second deadline killed it and setup was marked failed for the rest of the session. The failure was silent in the worst direction, because the startup probe had already reported the limit as active and throttling, and the only warning arrived ten seconds into your first bash command, which stalled for those ten seconds. Every Linux host that reaches this backend was affected, meaning any machine without a delegated cgroup carrying the cpu controller but with a user manager running: most containers, and desktops without cgroup delegation. The unit is a transient service now, which returns as soon as it is registered.
  • The ask dialog marks the recommended option through one shared helper. It appended (Recommended) from a bare template literal while stripping it with a private copy of the string, and tools/ask.ts held a third copy, so changing the wording in either writer would have left the marker in the answer handed back to the model.
  • A durable thinking-effort pin is saved where the resolver reads it. setThinkingLevel(level, persist) wrote the retired defaultThinkingLevel enum, which is consulted only when defaultEffort is absent, so for every profile that has a defaultEffort object — anyone who has opened Settings → Model → Default Effort once — the write reached settings.json and was discarded on the next read, forever. The two callers that pass persist are the extension loader and the extension UI controller, both published SDK surface, so an extension pinning an effort durably silently did nothing. It now writes the defaultEffort row that actually governs the selected model: the model's own row when it has a readable one, otherwise the * row, so the pin cannot be shadowed by a more specific row and is observable for the model it was made on. Which row governs is asked of the resolver rather than restated, so it cannot drift from the precedence table. A profile still carrying only the retired enum is migrated on that first write instead of losing the level it had saved.
  • An extension asking for a durable thinking level gets one. ExtensionAPI.setThinkingLevel forwards a persist flag, but the runtime wirings that connect an extension to a session were written as level => session.setThinkingLevel(level), and a one-parameter arrow is assignable to the two-parameter handler type, so TypeScript accepted every one of them and the flag was dropped: print mode, RPC mode, subagents and one of the two interactive registrations turned a durable request into a session-only change that vanished on exit, silently. The published ExtensionAPI type did not declare the parameter either, so a typed extension could not ask for it in the first place. It is declared now and forwarded by the wirings, with the ACP wiring still to follow.
  • The account card's "add another account" row is a list position you can arrow onto, and the card is clickable past its head lines. The add row was painted as a caption with no target, so the down arrow cycled between the logged-in accounts forever and only the a key reached it; a provider with no accounts opened with nothing selected at all. Selection was keyed by credential id with no way to represent "the add entry", so it now carries a tagged target and the stepper walks one more position than there are accounts, wrapping the way every other list in this TUI wraps. Clicks were narrower than they looked: only an account's head line was a target, so a click on its plan line, its usage bar or its rename field did nothing, the add row did nothing, and every footer chip except esc close was painted as a control and was inert. Every line of an account's block now selects that account, the chips run the key they name, and a click on a gap between blocks is ignored rather than snapping to a neighbour.
  • The account card no longer loses its title row on a short terminal. It floored its own height at 16 rows, and its host mounts it bottom-anchored, so a terminal shorter than that got a frame taller than the screen and the overlay clipped it from the TOP: the title and the close glyph were cut, and every row offset the card had computed for its body, footer and click targets was shifted by the amount clipped. The floor was standing in for a minimum the sizing code already enforces, so removing it makes a 12-row terminal paint a 12-row card.
  • Focusing into a subagent re-scopes every surface above the composer, and unfocusing puts them all back. The transcript, status line and editor already followed the view; four surfaces did not, and none of them re-scoped on the way out either. The todo board was read from the driving session rather than the viewed one, so an agent that owns no todos showed the parent's checklist as its own, and the focus transitions were the only session switch that never reloaded it. Its reconcile pass was worse than a display leak: it read the whole observer list and persisted the result back into the driving session, so once the board on screen belonged to the agent, a single observer event copied that board onto the parent and wrote it to the parent's session file. A pinned error banner stayed above the composer across the transition with nothing behind it, because the transcript component it mirrors had already been dropped. The running-agent badge counted the whole conversation while the HUD beside it listed only the viewed agent's spawns, so a leaf agent's view reported running agents that had no row anywhere in it. And the composer chip band advertised two keys that mean something else inside an agent's view: esc returns to the main session rather than interrupting, and the dequeue key drains the parent's queue into the editor the agent is looking at. All four re-derive at the one choke point every focus transition already runs through, including the registry-driven auto-unfocus when the viewed agent dies.
  • Eight values computed on every prompt render reached a template that never mentioned them. A render payload and its template are joined by a string, so the drift is silent: the work is done, the value is passed, and it is discarded. tools/bash was handed hasAstGrep, hasAstEdit and autoBackgroundThresholdSeconds; tools/read was handed DEFAULT_MAX_LINES; tools/task was handed agentNames and hasReadOnlyAgents, left behind when the template moved to iterating agents; and autoresearch/prompt was handed baseline_run_number and default_metric_name, the latter a duplicate of the metric_name it does use. Two of them read settings, so astGrep.enabled and astEdit.enabled had a reader in bash.ts that a grep for either key would find and that no rendered description could ever reflect. Both keys stay live through the tool-loading policy, which is the reader that actually decides whether the tools exist. A new lock walks every prompt.render call and fails on a payload field its template does not reference.
  • The lock that proves every settings key is read no longer accepts a coincidental word. A key reached through getGroup was counted as wired when its group was read anywhere and its bare leaf name appeared anywhere in the workspace, and leaf names are words like enabled, only, except, model and threshold, which occur thousands of times as ordinary locals, so the field half of that test was very nearly free: an invented compaction.instanceof would have passed. The field must now be reached the way a field of a group object actually is, read off an object as .field or bound out of one by destructuring. All 409 declared keys still pass.
  • A settings key having a reader is now distinguished from it having an effect. commands.enableClaudeProject passed the reader lock the whole time it was dead: the literal settings.get("commands.enableClaudeProject") was right there in discovery/claude.ts, so the key had a reader. What it did not have was a consumer: the value was read, returned as a field of an object, and the only caller destructured the sibling field and dropped it. The lock now also walks every settings value assigned to a field of an object literal and fails when nothing anywhere reads that field back off an object or binds it out of one, which is the one shape of "read but no effect" that can be found mechanically. Two enclosing calls are excused because their consumer is real and simply is not TypeScript: prompt.render, whose templates are held honest by the sibling lock added alongside this, and logger/telemetry, where recording a value without branching on it is the point. 242 mappings are checked; reconstructing the original defect shape fails the new check while every older check still passes.
  • The composer footline stops shedding the two values that decide what you do next. It sheds parts from the right end when the line will not fit, and the context gauge is deliberately appended LAST so it reads as the line's last word — which made the one live value on the line the first casualty of every width that did not fit. On the default preset at 80 columns there was no gauge at all; on full at 160 you got a cache-hit percentage and nothing saying how much room was left before compaction. The approval rung went next, and the rule that should have saved it existed only in dead code: the deleted #buildStatusLine refused to shed mode ahead of the model name or the profile chip, on the grounds that it is the one place that says whether the next command will ask before it runs, but that method had zero production callers and the footline had no such rule. Both are protected now, alongside the running-subagent count and the composer's draft token readout. At 80 columns every preset now carries path, profile, approval rung, subagent count and gauge; the badges that shed instead are ones you can re-read at any time.
  • The status-line preset's path budget reaches the screen. default asks for 40 characters of path, nerd for 60, minimal for 30, and the footline pinned all of them to 30 — as it also did to any statusLine.segmentOptions.path.maxLength an operator set, so choosing nerd for its long paths changed nothing on screen. The preset wins now, the operator's own option beats the preset, and 30 survives only as the fallback for a preset that names no budget.
  • The agent-focus badge is proved on the surface that renders it. It says whose session you are viewing and that Esc leaves it, and the suite defending it drove StatusLineComponent.getTopBorder — a method with no production caller, since the composer is borderless. The badge itself has ridden the footline since it moved there; only its proof was pointed at nothing.
  • The context gauge and the git segment each had a second rendering mode nothing could reach. context_pct chose between the draining eight-cell bar and a 47k/170k token readout on a bar option, and git chose between branch * and per-kind counts (*2 +1 ?3) on a compact option plus three show* flags every preset set. The composer footline is the only renderer of any segment and it asked for the bar and the compact form unconditionally, so the alternates, their options, the emberRamp colouring that only applied to the dead gauge, and the preset entries configuring all of it were settings over unreachable code. Removed; the forms that render are unchanged.
  • An MCP failure now names which server failed and what to do about it. Of 67 detected message strings under src/mcp/, 4 named a remedy, and those 4 were one shared HTTP-failure builder; the rest said things like Transport not connected, Request timeout after 30000ms, No response body and Transport closed. A session runs several MCP servers at once, so none of those told an operator or the model which one had died, and none named the next step, even though every one of these failures has a specific one: a deadline is "timeout" on the server's config entry or VEYYON_MCP_TIMEOUT_MS, and a dead connection is /mcp reconnect <name>. The transport layer is handed a config with no server name in it, so the identifier it can honestly state is the URL for http and legacy SSE and the subprocess command for stdio, and that is what these messages now carry, alongside which request or notification was lost. Two builders that previously read alike are now distinct because their fixes point in opposite directions: a stream that closed without answering is not a timeout, so it does not tell you to raise the deadline. The per-status HTTP remedy is also total rather than partial: it returned nothing outside 401/403/404/429/5xx, which left the whole 4xx band silent, and 4xx is exactly what a misconfigured entry produces.
  • A failing MCP tool tells the MODEL what to do, not only what broke. The first reader of a tool error is the model, which can open no selector and run no slash command, and it was handed MCP error: Transport not connected with the server and tool names present in the result metadata but absent from the text. Its only two behaviours were to call the same tool again with the same arguments or to abandon the task. The text now names the tool and the server and states a retry budget of one with the escalation to use after it, because a transport, auth or configuration failure returns the same error on every attempt. The redaction path is fixed in the same way: when a server echoes the call's arguments back inside its error the error is still withheld, but the message says so and says to change the arguments, where it used to read MCP request failed. and be indistinguishable from a network fault.
  • MCP config validation shows the shape that would work. Server "x": stdio server requires "command" field states the rule and leaves the syntax to be guessed; the message now carries a copyable "command"/"args" pair, and each transport failure names the other transport as the alternative for the reader who picked the wrong one. /mcp add, /mcp remove and the config writer name the file they were operating on, why the operation was refused, and which command reaches the other scope.
  • The MCP stale-session reconnect had silently stopped firing. isRetriableConnectionError detected a restarted server by matching /^http (404|502|503):/ against the error message, and the shared HTTP-failure builder had since moved the status mid-sentence behind the failing URL, so nothing matched and a recoverable stale connection became a failed tool call with no signal. The check is unanchored now, and the transport-state phrases it reads live next to the strings that must contain them behind one exported predicate, so a future rewording cannot disable the reconnect without failing a test.
  • Four credential and model errors no longer name a remedy the reader cannot perform. tools/tts.ts said Run /login -> xAI Grok OAuth (SuperGrok or X Premium+) or set XAI_API_KEY; /login carries no textMode, so it is a TUI menu that an ACP client, a --print run and the model itself all cannot reach, and the arrow named a submenu entry rather than an argument. Its sibling in tools/image-gen.ts was the same sentence, had already been corrected, and the two had drifted, so the sentence now has one owner beside the credential resolver whose null return is the failure. AgentSession's "No model selected" and its three no-usable-credential branches named /login, /model and veyyon setup to readers who may have no terminal at all, and veyyon setup exits with "requires an interactive TTY" without one; each branch now names the interactive route, the terminal route and the environment variable separately, and the credential branches delegate to the one credentialRemedySentence owner rather than restating it. The compaction and handoff no-model errors said only No model selected.
  • The MCP config-failure status label named a file the agent no longer reads. Config-level MCP failures were reported under the stand-in server name .mcp.json, and project-scope .mcp.json discovery has since been removed; the label now names the layer, and the message body still carries the real path.
  • An MCP config the loader cannot read is reported again, at the scope that survived. The only provider that ever raised Failed to parse JSON in <path> was mcp-json, which read the project root, so removing project-scope MCP discovery left the operator's own <agentDir>/mcp.json with no reporting path at all: a mistyped comma there produced a session that booted clean with every configured server missing and no line anywhere saying why. The native provider raises the same message for a file that is present and unparseable, so /mcp list and the boot health zone render it exactly as before. A working-tree mcp.json stays silent on purpose, because the operator did not write it and cannot be asked to fix it.
  • An unparseable <agentDir>/mcp.json no longer takes MCP startup down with it. loadAllMCPConfigs reads the denylist and the force-enable list out of that same file before it can return anything, and both reads rethrew the SyntaxError, so one bad character stopped the loader rather than degrading one server. A file that does not parse now answers "no denylist, no allowlist" to those two queries, while the mutation path still refuses to write over a file it could not read, and the parse failure itself is reported through the warning channel instead of an exception.
  • One conversation can no longer see, message, revive or read another's agents when a single process drives several at once, which ACP does whenever a client opens a second session. The agent registry is process-global and only some of its readers were filtered, so the unfiltered ones reached across: the Control Center seeded another conversation's on-disk subagents into your roster and streamed its Comms traffic, the running-spawns badge counted work you never started, history:// served a live agent's whole transcript by name, agent://<id> returned whichever conversation's report was found first, the job tool listed agents irc list deliberately withholds, and a collab guest was mirrored, and could kill, revive and read the raw session file of, agents from a conversation that was never shared with it. issue://N and memory:// picked the first registered session's checkout, so a short-form URL answered from another project. The boundary now has ONE owner, AgentRegistry.canAddress, that irc list, irc send, irc wait and the job roster all consult, rather than four hand-written copies of the same rule for someone to fix three of. Where the caller genuinely cannot be identified the lookup refuses and names the ambiguity instead of guessing, following local://.
  • Agent-to-agent traffic is now recorded with the conversation it happened in. Two conversations routinely run an agent under the same model-chosen name, so an id was never a conversation key: /new in one conversation erased the other's chat history from the Comms stream, and the pane hid a line the moment its agent was released, which is the opposite of what it promises. The display-only relay that echoes agent chatter into the driving session also resolved its target as the literal id Main, so it pasted one conversation's exchange into another operator's transcript, and did nothing at all in a headless host, where the root is named after its session instead.
  • A subagent's whole report can no longer vanish into a watch window. watchJobs suppresses #enqueueDelivery outright, so a job that finished while watched had no delivery anywhere: not queued, not in flight, not retained. unwatchJobs only forgot the watch, and resumeDeliveries lifts a different flag and has never been able to see one, so the child's output survived solely inside the return value of whatever installed the watch, and any path that dropped that value dropped the report permanently and silently. Lifting a watch now re-arms the delivery of anything that settled inside the window. The two call sites that return those results themselves, the job poll and the vibe wait, acknowledge before they unwatch, which is what keeps delivery exactly once: reversing either one hands the operator the same report twice, once returned and once pushed.
  • A project AGENTS.md no longer outranks your own. A repository's file said "do not use subagents for this repository", the operator asked for subagents directly, and the agent refused, citing the repository over both their own ~/.veyyon/AGENTS.md and their live instruction. Four separate things told it that a narrower file wins: the authority prose said "later and deeper files override earlier and broader files", the project prompt's dir-context block said "Deeper rules override higher ones", the provider doc said "Later scopes override earlier ones" under a list that numbered project last, and the render order put every project file AFTER the global one, in the highest-recency slot, so position beat the prose on exactly the pair that mattered. The ladder now runs your live instruction absolute, then your own home configuration, then the profile, then the project lowest, and the files render in that order so the strongest one holds the last word. A narrower file may add detail the broader ones do not cover and is followed there; it may not contradict, loosen, or forbid what a broader one allows. This is a safety boundary rather than a convention, because a project file is content checked into a repository you may not have written, and it is the same claim as the removal that stopped a cloned repository replacing a system-prompt section: both halves of "a repo cannot rewrite the operator's instructions" are now closed. Within the project group the file closest to your working directory is still the most specific one, since both files are project scope and neither outranks the other.
  • Deduplicating identical context files no longer relabels them. When two scopes hold the same text one copy is dropped, and the survivor's <file path=...> label is what tells the model whose rules it is reading, so picking by array position re-attributed your own standing rules to whichever repository file happened to quote them: identical bytes, and a project file wearing your authority. The survivor is chosen by scope authority now, from the same rank table the render order uses, so reordering the render can no longer flip the attribution in silence. A longer project file that quotes your global one keeps both, whole: the global copy survives on rank, and nothing an author wrote is rewritten.
  • "Your instruction in this conversation is absolute" reaches the model in every session, not only in sessions that loaded a context file. It rendered inside the context block's {{#if contextFiles.length}} gate together with the scope ladder, so an operator with no AGENTS.md anywhere got neither, while rules, always-apply rules and memories still rendered and any of them can tell the agent to refuse. The two halves are separate prompts now: the ladder stays gated, because with no files there is nothing to rank, and the absolute-authority sentence renders unconditionally. It sits in the project runtime section rather than the cached prefix, so it costs no prompt-cache invalidation.
  • The status line counted task subagents twice. A spawn registers an async background job AND counts as a running subagent, and the job badge counted snapshot.running.length outright, so three subagents rendered as 3 · 3: two adjacent numbers that always moved together, neither of which said what it was counting. The job badge now skips jobs a subagent already stands for, which leaves it reporting the async bash, debug and launch work that has no subagent behind it. Both the full bar and the composer footline read the count from one owner, so they cannot drift apart again.
  • A subagent that needs permission can now ask for it. The spawner called extensionRunner.initialize(actions, runtime) while the signature takes a fourth uiContext, so every spawned agent's runner kept the no-op surface, hasUI() was false, and any call that needed approval died with requires approval but no interactive UI available rather than asking anyone. That was invisible only while spawns were forced to yolo and therefore never asked; the moment children began inheriting the operator's rung it became a hard failure on an ordinary call, and the child was lost mid-task with no card ever drawn. A child's request is now presented at the ROOT session of its conversation, resolved through the agent scope that every agent inherits at registration, so a request from any depth arrives directly rather than being relayed through intermediates that can be parked or busy. The card names the agent that raised it, because the operator answers one queue and two children asking to run bash at the same moment are otherwise identical prompts. A root that genuinely has no surface (ACP, or -p with no terminal) still refuses, which is the correct answer when nobody can be asked, but the refusal now names the agent and explains what was blocked and how to unblock it.
  • A subagent waiting on an approval card is no longer killed by its own runtime budget. subagent.maxRuntimeMs counted the time the operator spent deciding, so a slow answer aborted the agent while its prompt was still on screen: the operator then answered for something already dead and the work was lost with no report. The budget now excludes every approval wait, open and already answered, so it bounds the agent's work rather than the human's reading speed. A child that is merely slow is still capped exactly as before.
  • local:// no longer guesses which conversation it belongs to. When a caller does not thread its own session context, the handler falls back to the agent registry, and that fallback took the first main-kind ref it found. A multi-session host (cmux, ACP, an embedded SDK consumer) registers every conversation as kind: "main", and the fallback has nothing to disambiguate with, so the pick was arbitrary with respect to the caller. local:// writes as well as reads, so one conversation could silently read, and then overwrite, another's planning artifacts, with no error and nothing on screen to notice. Ambiguity now refuses: two or more live root sessions produce the existing No session - local:// unavailable, which names a caller that should be threading its context and is the one thing the doc comment above it already asked for. A single live root still answers, and now answers in one case it used to refuse, where the first main ref had already been disposed while another was live.
  • Fifteen settings that carried a full label, description and documentation entry could not be reached in /settings at all. The adapter that turns a schema row into a UI row dropped every number whose declaration listed no preset choices, on a rule written when no such setting had a UI block, so retry.maxDelayMs, the two github.cache TTLs, mcp.notificationDebounceMs, exa.searchDelayMs, providers.ollama-cloud.maxConcurrency, model.toolCallLoopGuard.threshold, the two codexResets limits and the five read.summarize line thresholds were documented, defaulted, honored at runtime, and invisible. subagent.idleTtlMs had already been fixed one at a time by giving it a preset list. They are typed into a text box now, the same control string and record settings already use, and the count of reachable settings goes from 324 to 338. A number typed there is checked rather than coerced: abc, 0x10, 1e400 and a stray leading space are refused with the reason shown under the input, instead of storing NaN or silently becoming 0, and clearing the box restores the default rather than writing zero. Where a value has a floor that means something the schema now states it, so a negative delay or a zero loop threshold is refused by the setting's own declaration; providers.ollama-cloud.maxConcurrency keeps accepting 0, which is documented as "no limit". onboardingVersion is the one number that must not be a row, since it is what the app writes when setup finishes, and it now says so in the schema rather than relying on the bug to stay hidden.
  • Tool-result pruning runs again. compaction.supersedeReads and compaction.dropUseless both default on and both controlled nothing: commit fbb73be9, whose subject is about an unrelated prompt-cache setting, deleted AgentSession's #pruneToolOutputs and #pruneStaleToolResults and their two call sites as collateral. Every stale read result the operator believed was being superseded, and every result a tool flagged uneventful, had been occupying context on every request since. Both passes are wired back into #checkCompaction with their original cache economics (the stale-result pass every turn regardless of compaction.enabled, the overflow prune only on the threshold path), and both settings are now pinned in both positions by tests that observe the rewrite in the live context and in the from-disk rebuild.
  • test/agent-session-prune-persistence.test.ts is restored. It was deleted rather than diagnosed: it had gone red because the pass it guarded no longer ran, so it was the surviving witness to the unwiring above rather than a stale test. It asserts the contract that made the passes safe in the first place, that a pruned rewrite is persisted so /fork, /tan and resume rebuild the same prefix instead of cold-missing the provider prompt cache.
  • A session compacted by the removed provider-native remote path no longer loses its history on every rebuild. Those entries carry a fixed placeholder where the summary should be ("Remote compaction preserved provider-native history for this session.") because the real history lived in an opaque provider blob that is deliberately never replayed. buildSessionContext honored their firstKeptEntryId anyway, so every turn before the cut was dropped from context and replaced by that one sentence, permanently, on resume and on fork. The raw entries are still on the branch, so such an entry is no longer treated as a compaction boundary and they are re-emitted verbatim: this is the same ruling prepareCompaction already made, which had been re-expanding messages the model's context no longer contained. An earlier real compaction on the same branch still applies its own cut.
  • /handoff works from a client without a terminal. /compact handoff refuses with "Use /handoff [focus instructions] to transfer context to a new session", and /handoff declared only a TUI handler, so an ACP client was refused and then pointed at a command its dispatcher answered "no such builtin" for, which forwards the text to the model as ordinary prose: no handoff, no error, and a reply about a command the operator believed they had run. The operation never needed a terminal (the RPC surface has driven session.handoff headlessly all along; only the spinner and the transcript repaint were TUI-bound), so it is a text-mode command now, advertised to ACP clients with its focus hint, guarded against a streaming turn in the same words the TUI uses, and reporting the new session and any saved document path rather than a bare acknowledgement.
  • Four more messages a text client receives no longer name a command that client cannot type. /thinking ended every reply with "use /settings → Model → Default Effort", /cwd pointed twice at "/settings › Interaction › Profile", /secret said to turn on "Hide Secrets" in /settings, and /usage reset sent you to /login; all four are text-mode commands and none of those four commands is dispatchable outside the TUI. Each now names a remedy the caller can perform, the veyyon config set spelling for the three settings and the surface /login lives on for the fourth, without dropping the settings-screen route a TUI operator would rather use.
  • A session rooted in a non-active profile no longer runs on a mixture of two profiles. The layer providers were taught to read the caller's agent dir, and its context files, skills and prompt templates followed it, but the callers for the other five layers never passed one, so loadCapability filled the gap with the process-booted profile: the session got profile B's instructions and profile A's rules, slash commands, workflow commands, custom tools, hooks and MCP servers. Nothing threw and nothing warned. All six loader entry points take an agent dir now (discoverRules, loadSlashCommands, discoverCommands, discoverCustomToolPaths, discoverAndLoadHooks, loadAllMCPConfigs), each defaulting to the active profile exactly the way loadCapability already did, and every session call site passes the dir the session was actually rooted in. Rules had no discovery wrapper at all, which is why both of its call sites were missed; there is one now, so the default lives in a single place. The MCP disable list moved with the server list rather than after it, because scoping only the servers would apply one profile's disable list to another profile's servers and resurrect a server the operator had turned off. Custom tools carry the profile's own installed plugins too, not just its tools/ directory.
  • The prompt no longer tells the model to write the operator's standing rules into the wrong profile. Of the five agentConfiguration rows, "Agent directory" and "Skills directory" named the dir the session was rooted in while "Active profile" and "Profile AGENTS.md" named the profile the process booted with, so a session running on another agent dir was handed a Profile AGENTS.md path that was not the file whose contents it had just been given. All five rows derive from the one resolved agent dir now.
  • Two ways a recursive delete could reach the root without asking, both found by running the command through a real shell and comparing what rm actually received against what the guard had decided. cd / && rm -rf $PWD was approved: cd rewrites PWD inside the very command being judged, and the guard read the value this process holds, so it judged the old directory and the shell deleted the root. PWD and the other shell-maintained variables are never substituted now, because no read of the environment can be right for a value that does not exist yet. Separately, rm -rf ../../../../../.. was approved because the guard skipped relative paths entirely, on the reasoning that refusing them would refuse ordinary work. It resolves them against the working directory instead, so rm -rf ../build and rm -rf ../.. still run unasked and a climb that lands on / or the home directory is refused for the same reason its absolute spelling is.
  • Delegation policy is stated in one place: the prompt's Delegation section. The grep, glob and ast_grep tool descriptions each carried a rule ordering the model to hand an open-ended search to a task subagent, and none of them was gated the way the section is: grep and ast_grep checked only the subagent.enabled master switch, glob checked nothing at all. With subagents on and every agent type disabled, the section correctly disappeared and those three lines still ordered a handoff to something that could not be spawned; with subagents off, glob ordered it anyway. A tool description now describes its tool, and what the removed lines said (an open-ended multi-round search belongs in a subagent that isolates the output) is already the "No idle handoff" delegation gate.
  • subagent.delegation: allowed now says what it means. The two stronger levels each emit a sentence, allowed emitted none, so the weakest setting rendered the Delegation heading, the gates list and the subagent-value bullets with nothing saying when spawning is appropriate: the ability was described and its trigger was not. It now states that delegation is available and that an explicit request is what triggers it, which is exactly the state the level exists to express. Every level still keeps the task tool, so asking for delegation outright works at all three.
  • The documentation no longer describes an approval system weaker than the one that ships. Moving the unset tools.approvalMode default to auto left the handbook saying the working-directory boundary, the secret-use boundary and the bash critical guard hold "in plan, ask, and auto-edit", a list that omits the rung a fresh install now runs on, so a reader concluded a default install writes outside the working directory and spends stored credentials without asking. All three fire on every rung except yolo, and the critical guard fires on yolo and under the /yolo bypass as well; docs/handbook/src/architecture/sandbox.md claimed the opposite, that yolo auto-approves critical patterns. Three headless examples were worse than stale: veyyon -p --approval-mode auto-edit resolves to ask-command, which prompts for every exec-tier call, and -p has no terminal to answer on, so every command in the documented workflow failed rather than pausing. docs/settings.md still carried approvalMode: yolo # default in its worked example. Every guard the corrected text now claims is covered by test/tools/default-rung-guards.test.ts, which drives the real tool wrapper with nothing configured.
  • Renaming a row in the approval dialog can no longer turn that row into a silent denial. The four labels were written once in APPROVAL_SELECT_OPTIONS and again as four bare string comparisons in the wrapper that reads the dialog's answer, so editing one list and not the other left "Approve" matching nothing and the call refused with denied by user, with nothing on screen saying the two lists had drifted. The labels are named once now and both the dialog and the comparisons read them from there.
  • A settings row whose value is a machine number now reads as words. One string was doing two jobs, the text shown and the value stored, so every duration and named-zero row printed the number: Max Subagent Runtime rendered 0 while its own picker called that Unlimited, and the auto-close budgets rendered 300000 and 1800000. The option list is where those words already lived, and the row maps through it at render time only, so preselection and write-back keep the number and no setting can persist "5 minutes" where milliseconds belong. A value no option names falls back to itself rather than blanking, which matters because any number can be written into a config file. A row owned by a lower layer composes project config · 5 minutes from the labelled value too: it used to compose from the stored one, so the row that answers "which layer set this" was the single place the raw count still appeared.
  • A subagent whose session could not be flushed at park time no longer strands the scheduler. The park is retried on the next idle TTL, which it already should have been, but the real cost was invisible: the two-stage timer reads a stage beside each deadline, and a deadline re-armed by a failed park carried none, so the expiry skipped it without clearing it. #scheduleNext then re-selected the same entry as its next wake, computed a zero delay from a timestamp already in the past, and span for the rest of the session while parking nothing. One failed flush was enough. deadline and stage are written only through one arm/disarm pair now, and every due entry is cleared before the expiry decides what to do with it, so an entry it cannot classify is re-derived from the registry instead of surviving.
  • A parked subagent is no longer dropped while it is being woken. close runs off the shared timer and a reviving agent stays parked until its rebuilt session is attached, so a status check could not see the wake: the ref was unregistered mid-revive, attachSession and setStatus both no-op on an unknown id, and the caller was handed a live session that no roster listed and nothing would ever dispose. The close now defers while a revive is in flight and lands after the agent goes quiet again. An explicit release during a revive is still a release, but it disposes the rebuilt session and says so rather than resolving with an orphan.
  • A queued park is no longer applied to a subagent that went back to work while the expiry was draining. Expiries run one at a time so a large idle cohort cannot trigger a burst of persistence work, and inside that window a later agent in the same batch can be messaged, run a whole turn and go idle again; its queued park then described an agent that no longer existed and threw away a session that had just restarted its own TTL. A stage is dropped when the agent it names has had a fresher deadline derived since.
  • A parked subagent can no longer be left permanently unwakeable by a status change that lands mid-revive. Waking a parked ref restored from disk builds a reviver through the persisted-subagent factory and records the adoption before the rebuild starts, and the compensation that drops that adoption when the wake fails covered only one of the two ways out of the region. The other, the re-check that refuses a ref which stopped being parked while the factory was awaited (an abort, a re-registration, a collab mirror update), threw straight past it, so a reviver built from the ref as it no longer is stayed cached with no deadline armed. Every later wake preferred that cached reviver over rebuilding, so one badly timed status change poisoned the agent for the rest of the session. The whole region is compensated now, not one exit from it.
  • A subagent killed while it was being woken no longer comes back to life holding a session nothing will ever dispose. The revive re-read its ref afterwards only to ask whether it had been released, so a ref flipped to aborted mid-revive was handed the freshly rebuilt session and set back to idle. The kill had already run its dispose against a parked ref, meaning against no session at all, so the resurrected one was a process, MCP client and file-handle set leaked for the rest of the run, and a terminated agent was back in the roster. The wake is refused now and the rebuilt session disposed, the same way a wake arriving a moment after the kill is refused.
  • Parking an agent that cannot be parked no longer disarms its close deadline. park() cleared the pending deadline and rescheduled before it checked whether the agent was idle and live, so a call on an already-parked ref wiped the close budget armed at the park and returned without re-arming one. parked is a stable state, so no later transition would ever re-derive it and the agent stayed listed until exit. The deadline is cleared by the park that actually happens.
  • Rewrite Scrollback (tui.scrollbackRebuild) is on by default, so a streamed block that reflows after part of it scrolled past the top of the window no longer shows up twice in your terminal history. The repair existed and was switched off; see the @veyyon/tui changelog for why the old behaviour was only ever right inside a multiplexer.
  • The bash guard stops blocking ordinary deletes through a variable. It resolved only $HOME, so every other variable read as unknown, and an unknown expansion in a recursive delete is treated as destructive: rm -rf $TMPDIR/scratch and rm -rf ${CARGO_TARGET_DIR}/debug both stopped to ask, in yolo, whose whole promise is that it does not. It now resolves any variable set in the environment the command will actually run with, which includes the env argument of the call itself, and applies inline VAR=value assignments the way the shell does. A value that would word-split or glob, a variable nothing has set, a ${VAR:-/} operator form, and a command substitution all stay unknown and still stop the call, so rm -rf "$dir"/* is refused exactly as before.
  • Reading a credential through bash now asks, the same as reading it any other way. The working-directory and credential boundaries only see tools that declare their filesystem targets, and bash declared none, so read ~/.ssh/id_rsa prompted while bash cat ~/.ssh/id_rsa did not. It reports the paths it names inside a credentials directory, and only those: running the whole boundary over a shell command would ask about /usr/bin/env and every toolchain path a build mentions.
  • An "Approve for session" answer can no longer wave through a call it was never given about. The grant is keyed by tool name, so it retires the ordinary tier prompt and nothing else: a destructive command, a path outside the working directory, and a call that spends a stored credential each still stop and ask, because those prompts are about the arguments rather than the tool. Approving bash ls for the session used to run a later rm -rf ~ with no prompt at all, including under yolo, whose critical floor exists for exactly that command. Grants are also dropped when the conversation changes, and a grant is only recorded from an ordinary prompt.
  • Spawned agents get the project's standing rules again. Every spawn site (the task tool, the eval agent() bridge, and a Vibe worker) handed the child its parent's context files with every entry named AGENTS.md filtered out, which is every layer a user actually writes: ~/.veyyon/AGENTS.md, the active profile's AGENTS.md, and each AGENTS.md in the project walk. Only a stray CLAUDE.md survived, and because the filtered array was still an array, the child treated its layers as already resolved and never loaded its own. The subagent was then handed the standing claim that every AGENTS.md was already inlined, so it neither had the rules nor was allowed to go looking for them. Spawned agents now inherit the parent's layers unchanged, a child rooted in a different directory loads its own instead of the parent's, and a parent that resolved no layers at all is logged rather than passed down as silence.
  • Fixed /branch and /btw re-prefilling the whole retained transcript. A branch keeps a genuine prefix of the source conversation, so the provider cache the source populated is still valid for it, but createBranchedSession reminted the session id and left providerPromptCacheKey unset (unlike fork(), which seeds it), so the first post-branch turn paid a full uncached prefill. /btw was the expensive one, because it branches at the live leaf where the prefix is byte-identical. The branch header now carries the source's cache identity and both branch paths adopt it.
  • Fixed manual and advisor compaction, runEphemeralTurn (which drives /btw and IRC), and the guided-goal interview sending promptCacheKey: sessionId, which discarded a pinned fork or tan key and cold-missed the prefix those sessions were created to share. All four now use agent.promptCacheKey ?? sessionId, matching the rule handoff already followed.
  • The Agent Control Center and irc no longer show agents from other sessions. The agent registry is process-global and nothing ever told it a conversation had ended, so /new, /resume and /handoff swapped the transcript under the same session and left every subagent of the previous one registered: the roster listed them, irc list offered them as peers, and messaging one woke an agent whose replies were written into a transcript you had already left. Each ref now carries the session id of the conversation it belongs to, inherited down the whole spawn tree, and every roster is filtered to the conversation it was opened for. This also covers hosts that drive several sessions in one process (ACP, cmux, the SDK), where the leak was permanent rather than sequential. Re-rooting a session releases the previous conversation's subagents rather than leaking their refs and their live sessions, and drops their traffic from the comms stream.
  • Fixed an IRC wait hanging forever when the agent it was waiting on was released. IrcWaiter.cancel deregistered the waiter and cleared the wait timeout without resolving the promise, removing the last thing that could have settled it. A cancelled wait now resolves null, the same as a timeout.
  • First-run setup shows all of every step. The overlay applied its body budget as a bare slice and no scene knew the budget existed, so each list asked for a fixed number of rows and the tail was cut with nothing on screen to say so. What went missing was the part that mattered: "Browse all…" is the last row of the curated theme list and the only route to every other theme, the provider list ended somewhere inside its ten-plus accounts, and every subagent description was cut mid-word without an ellipsis, so a row read as corrupted rather than shortened. Each scene is now handed the rows it actually has and sizes its own list to them, and a scene that still overruns says ↓ N more rows below instead of ending mid-list.
  • Setup can be left with Esc. Esc fell through to the active scene, where no scene claimed it, so the only advertised way out was ctrl+c — a key users read as "kill the program", not "I'll finish this later". Esc now ends onboarding from any step, and the footer says so.
  • Esc means one thing across the whole of setup. On the splash it used to START the wizard, alongside Enter and Space, so the key a user reaches for to get out of something walked them further into it, and the splash advertised ctrl+c skip setup while every step advertised something else. Esc now leaves setup from the splash and from every step, and both hints say so.
  • The setup footer names what its keys do. advances the step and commits nothing, so it is a skip, but it was labelled "next" directly beside "enter confirm", leaving nothing on screen to distinguish keeping a choice from abandoning it. It now reads → skip step and esc leave setup. The lists inside a step no longer print their own esc close legend either, which contradicted the footer's Esc on the same screen.
  • The subagents step shows every role at once. Each role's description is a full sentence that cannot fit beside its name at this width, and wrapping it in place cost three rows per role, so four of seven roles were on screen. The list is one row per role now, with the highlighted role's whole description underneath.
  • The import step no longer shows a column of identical truncated home paths. Every candidate rendered its absolute source path, which is mostly the home prefix, so each row read /home/<user>/.claud and told you nothing about which file it was. Paths are ~-shortened.
  • Todo reminders now instruct the agent to continue instead of framing the turn as a stop. An unfinished state is reminded at most once, up to the configured limit; repeated user continue prompts and unrelated tool calls do not replay it. Both model and TUI previews are sanitized and width-bounded, put the active task first, show at most five items, and report the hidden count.
  • Compaction and resume now treat an explicitly cleared todo list as authoritative instead of resurrecting an older continuity snapshot. Structured continuity still retains every phase and task, while its model-facing projection is bounded and no longer repeats identical objective and macro text.
  • Explicit todo views now return one active-first, five-item projection plus the hidden count instead of printing open work twice. The full state remains machine-owned, and expanded TUI todo output still shows the complete plan.
  • veyyon models refresh <provider> now bypasses the cache for only the named built-in or extension provider instead of contacting every configured catalog.
  • Removing a secret that has another copy underneath it no longer tells the agent the name is dead. A credential can be stored in more than one scope, and removal takes the narrowest, so removing the project copy of a name that also exists in your profile uncovers the profile one and the #NAME# placeholder keeps expanding, to a different credential. The revocation notice said the opposite: it told the model the placeholder no longer expands and that writing it would send the literal text, which was false in exactly the case where a live credential had just changed identity underneath a name the model was already using. Removal now distinguishes three outcomes and says which one happened. Nothing left is a real revocation and is reported as one. Removing the copy in effect and uncovering another names the scope that took over, says the placeholder now spends that one, and gives the command to remove it too, without flagging a revocation. Removing a copy that was already shadowed reports the removal and says the placeholder spends what it spent before, because for that name nothing changed.
  • The refusal that names which subcommands read an option reads as English when three of them do. The verbs were joined with " and " between every pair, which is indistinguishable from correct at two verbs and became /secret add and /secret rm and /secret discard take it as soon as a third subcommand took --scope. The suite that pins this grammar had noted the hazard and chose --limit as its singular case for exactly this reason; the plural case is now pinned at both two verbs and three.
  • A blank line an extension puts in a status widget now renders as a blank row. ui.setWidget takes an array of lines, and an empty entry is the only way an extension can separate two groups of rows inside one widget, but each entry was wrapped in its own Text, which renders nothing at all for whitespace-only content. The separator was dropped in silence: the widget came out a row short with the two groups run together, and because the empty entry still counted against the ten-line widget budget, the extension was charged for a row it never got. Neither padding nor a non-breaking space was a way around it, since both are stripped before the emptiness check. The content belongs to a third party, so its author cannot spell their way out of this from outside veyyon; an empty entry now emits a real blank row.
  • Installing a pinned version that has no release now says so, instead of blaming your platform. The installer decided whether a release existed by asking GitHub for /releases/tag/<tag>, which renders for any tag that exists whether or not a release was ever cut from it, and renders nothing different for an unpublished draft. A tag in either of those states passed the check, and the install failed later at the asset download with veyyon-linux-x64 not published for this release?, which points at the platform binary and sends you to build from source for a release nobody published. The rollback runbook pins --ref vX.Y.Z, so that misdirection reached people who were already recovering from something. Both installers now ask the endpoint that lists a release's download links, which separates the three cases, and each gets its own sentence: the tag does not exist, the tag exists but has no installable release, or the release is there and your platform's asset is not in it.
  • A capability you configured that fails to load now tells you so, instead of being quietly absent. Four paths reported the failure only to the log file, which nothing reads during a session, so the symptom was always the same: the thing you asked for was simply not there and the session looked healthy. A .mcp.json that cannot be parsed, or an entry naming neither command nor url, produced zero servers and zero words, and because the file never yielded a server name there was nothing for the per-server failure path to report against either; it now arrives as a failed server in the boot health zone and in /mcp list. An extension named by --extension or extensions: that throws while importing was dropped along with every tool, command and flag it registers; a path in extensions: that does not exist, usually a typo, was the quieter half of the same hole, since there is no file to load and so no load error to raise. An agent definition whose frontmatter is unusable, or that cannot be read at all, disappeared from /agents and from task, while a failure of the whole directory five lines away in the same function was already reported. Each now reaches the notice channel its neighbours already used, and each stays soft: healthy siblings still load.
  • The +Nk per-turn token-budget directive no longer fires on ordinary prose, and it is off until you ask for it. The unit used to be optional, so any + followed by digits armed a ceiling: a pasted diff stat (the diff is +42 -13 lines) set a 42-token budget, +1 to that idea set a 1-token budget, and bump it to +2 workers, score went +5 today, cost was +0.5 percent and see commit +3 above all did the same. Those are not small budgets, they are exhausted ones, so budget.remaining() read as spent and the hard +Nk! form made eval agent() refuse to spawn, with nothing in the message that looked like a directive to explain it. k or m is now required, which removes the whole class of false positive, and the new magicKeywords.turnBudget setting (Interaction, Magic Keywords) defaults to off: unless you turn it on, +500k in a message is ordinary text. Goal Mode budgets are unaffected and still apply on their own.
  • The setup wizard no longer paints its own background, so it takes the colour your terminal already has. It used to fill every row of the viewport with pure black, which on a grey terminal read as a black slab pasted over your theme. That is also why it survived review for so long: in tmux the ground is black anyway, so the fill looked like no fill at all. Padding, width and layout are unchanged and only the forced ground is gone, which means the wizard now sits on the terminal's own colour the way the "Match terminal" theme does.
  • The setup wizard's footer names the keys the step in front of you actually accepts, instead of one fixed line for every step. The Providers step has panels, so it now says tab switch panel, which is the cycling the old footer never mentioned even though it worked. The key that moves you forward reads esc next step, or esc finish setup on the last one, rather than the previous esc skip, which told you a step could be passed over but not where you would land or how far through you were.
  • Onboarding runs once per machine and stays done. The setup generation was stored in the active profile's config, so launching with a different --profile found nothing there, read the default of 0, and ran the whole wizard again on a machine that was already set up. It now lives in the cross-profile ~/.veyyon/config.yml, and an existing per-profile value is promoted the first time you launch, so relocating it does not re-onboard anybody. That promotion reads every profile on the machine rather than only the active one, which is what the first version of this fix got wrong: on a machine set up under work, a first launch of any other profile still found nothing to promote and onboarded you all over again. Onboarding is a property of the machine, so any profile that recorded a completed setup is proof it happened. Two quieter versions of the same fault go with it: completion is recorded when the wizard is put on screen rather than after it finishes, so a crash, a Ctrl-C or a closed terminal no longer loses the record, and a config file that cannot be parsed now reads as unknown rather than as a fresh install, which is what let one corrupt file re-run onboarding on every launch.
  • Resuming a session no longer leaves onboarding waiting to ambush you later. --continue, --resume and --fork skipped the wizard and recorded nothing, so a machine whose first launch was a resume stayed byte-identical to a fresh install, and the wizard then appeared the next time you happened to start without one of those flags, which could be weeks later and looked like onboarding firing at random. A resume still defers a repeat onboarding, since that machine already has a record, but it no longer defers the first one: a machine with nothing recorded has no session of its own to resume.
  • A setting that cannot be written to ~/.veyyon/config.yml now says so instead of failing in silence. The failure was caught, logged and dropped, so the screen showed the new value while the file kept the old one and the change reverted on the next launch. Onboarding was the worst case: a machine that could not persist the wizard's completion re-ran it on every launch with nothing on screen to explain why. A refused global write now reaches the same notification a refused profile write already used, naming the file that would not take it, and it is announced once per file rather than once per attempt.
  • Starting veyyon no longer erases your terminal's scrollback. Every interactive launch emitted ED 3, the escape that erases a terminal's saved lines, unconditionally and with no way to turn it off. ED 3 is not selective: it took the whole history you launched from, your shell session, the build log you were reading, everything, none of which veyyon wrote and none of which it can put back. Three separate paths did it, so fixing one was not enough: the first paint, the cold-launch transcript replay, and the theme swap that the terminal's own background reply resolves during startup, which recoloured a transcript that did not exist yet and destroyed your history to do it. All three now ask first. The new Clear Scrollback on Startup setting (Interaction, Startup and Updates) is off by default, and with it off a launch still clears the viewport so the frame is clean, it just leaves what you had scrolled above it. Turning it on restores the old behaviour. Replacing the transcript on purpose, with /clear, a branch, a resume or a compaction collapse, is unchanged, since there you asked for the screen to be replaced.
  • The installer no longer hands your own file to whoever held the name before it. Every artifact it writes gets a receipt beside it, a hidden .veyyon.veyyon-owner sidecar, and that receipt is what tells a later install or uninstall which files are safe to replace and which belong to you. It recorded only that an installer had once written to that path, so it outlived the file it described: delete an installed veyyon by hand, put anything else at that name, and the sidecar left behind said the new file was the installer's. Reinstalling then overwrote it and uninstalling deleted it, which is the exact data loss the ownership check exists to prevent, reached through the record instead of around it. A receipt now records a SHA256 of the artifact it was written for and is honoured only while that artifact still matches, so it vouches for a file rather than for a location. A symlink is identified by the target it holds rather than by the bytes at the far end, because a --source install points into a checkout that changes on every git pull and hashing through the link would call your own install foreign the first time you updated it. Receipts left by installers up to 1.0.46 record no identity and are still honoured where the install can be recognised some other way, and each one is upgraded on contact; where it cannot, the installer refuses and tells you the file changed rather than calling it somebody else's. An ownership question it cannot answer, including a machine with no sha256sum or shasum on it, is answered no.
  • veyyon update re-stamps the ownership receipt it invalidates. The updater replaces its own binary in place, which is exactly the change the receipt beside it is meant to detect, so every successful update left a sidecar describing the binary that was just retired. The next curl -fsSL https://get.veyyon.dev | sh would then refuse to touch the binary it had installed itself, and an uninstall would leave it on disk. The receipt is now rewritten after the swap and again after a failed swap rolls the old binary back, so the file on disk and the record beside it never disagree. Updating with a binary from before this ships still leaves a stale receipt behind, and the next installer run adopts it and repairs it rather than refusing.
  • A session rooted in a profile that is not the booted one gets THAT profile's instruction file, skills, plugins and agent definitions. Five separate sources each resolved the process-active profile on their own: the native context-file provider called the profile AGENTS.md ladder with no argument, the marketplace registry was read from the active profile's plugins/installed_plugins.json, and discoverAgents took its user agents/ dir, its extensions: packages and that same registry from the active profile too. Every one of them is a function of the agent dir the caller named now. The agent-definition case was the worst of the five, because a definition carries a system prompt and a tool list, so the wrong profile's marketplace could silently change what a spawned agent is and what it is allowed to do, and nothing anywhere said so.
  • The context-file loader stopped compensating for the provider it no longer needs to correct. It re-resolved the global and profile scopes itself, keyed them by absolute path against the provider results, and dropped every profile candidate belonging to another profile. With the provider fixed at the source, that pass was proved on a seventeen-case fixture matrix to change nothing at all, except one case where it was actively wrong, and the filtering it did was never free: the capability keys every user-level context file to one slot, so dropping the item that won that slot also dropped the foreign-tool home file it had shadowed, and the operator lost a scope either way.
  • disabledProviders: ["native"] now actually disables veyyon's own context scopes. The loader's re-resolution pass read <config root>/AGENTS.md and the profile ladder straight off disk, so switching the provider off left both files loading anyway. A setting the code quietly overrules is worse than no setting.
  • An AGENTS.md that exists and cannot be read is reported exactly once, and the report names the errno. Two owners read the same two scopes and both warned about the same path, and the one an operator was most likely to see said only "could not be read", with no EACCES or EIO to act on. The provider is the single owner now and it recovers the real error rather than the swallowed one.
  • Startup back-fill seeds the profile the prompt is being built for. It seeded the ACTIVE profile's AGENTS.md unconditionally, so building a prompt for another agent dir wrote the persistent, update-proof file into the profile that was not in use and left the one in use with nothing to edit, which is the entire gap the back-fill exists to close.
  • Switching profile no longer opens the new session with a prompt you never typed. /profile switch <name> respawns the CLI on the same terminal, and between the old process handing the terminal back and the new one resuming stdin nothing is reading it, so the kernel queues everything that arrives in that gap and delivers the whole backlog to the new session as its first keystrokes. A queued carriage return in there submitted a turn, which is how a first message full of terminal chrome (a /$bunfs/... path, the nerdfont tip, pieces of the previous status line) reached the model. Startup now drops the terminal's input queue outright with tcflush before it takes the terminal, so it does not matter who left the bytes behind: keystrokes, a terminal's replies to the departing session's probes, or anything a multiplexer injected all go the same way. Where there is no tcflush to call (Windows consoles have no termios) input read before startup finishes is discarded instead. Anything typed once the prompt is live is still accepted.
  • The collapsed Todos board shows what you finished, and in-progress has a glyph of its own. A finished task was filtered out of the collapsed board entirely, so a stage that had just closed three tasks looked exactly like one that had done nothing: you saw remaining work and nothing else, which is most of why the board reads as stalled even while it is moving. The two most recent finished tasks now stay on the board beside the open ones, and the stage's done/total still implies whatever is not listed, so the block grows by at most two rows. In-progress drew the pending checkbox in a different colour, which is not a state distinction at all: it is lost on a reader who cannot separate the two hues, in a low-contrast theme, and in any capture that drops styling. It draws now ([~] in the ASCII preset, a half-filled box in the nerdfont one), on the board above the composer and on the transcript card alike. Task rows and stage names are bounded to the terminal width at the same time, because this block is anchored above the composer rather than scrolled: a row that wraps does not go away, it makes the block taller on every rebuild.
  • The bash-tool-nudge rule stays quiet when a search tool reads a heredoc or a herestring. It fires when grep, rg or find STARTS a command, which is the shape the built-in grep/glob tools can replace, and a pipe already fell outside that. A search fed by <<<"$output" or <<EOF is the same case arriving by a different route: the text lives in the shell, there is no path to hand the built-in tool, and the advice cannot be followed. Only the segment holding the redirect is spared, so a real file search later in the same command line is still caught. A single < redirect keeps nudging, because that operand is a file on disk and the built-in tool wants exactly that.
  • Finished todos stay on the board for the rest of the session instead of disappearing a minute after you complete them. The widget ran a timer that stripped every completed and abandoned task once Todo Auto-Clear Delay elapsed, defaulting to 60 seconds, and each board update restarted the clock. Against a typical turn that meant the board spent most of its life showing remaining work only, so a stage that was moving looked identical to one that was stuck, and rows you had watched turn green were gone by the time you looked back. Canonical todo state stopped clearing itself on a timer for the same reason a while ago, that a total shrinking after you finish something reads as work being lost, and the widget was the half of that decision nobody converted. The setting keeps every value it had and the timer still runs when you give it one; it now defaults to Never, which arms no timer at all.
  • Killing an agent kills everything it spawned. job cancel and the dashboard's x key aborted and released only the agent they were given, and because descent is resolved by walking parentId through the registry, unregistering that one agent detached its whole subtree in the same step. Its children carried on running and spending tokens, they still appeared under "Running Agents" because that listing is scoped rather than descended, and every later attempt to kill one was refused, since the chain from the child now dead-ends at an id the registry no longer holds. That is the visible-running-immortal agent the kill was added to abolish, recreated one level down by the kill itself. The subtree is now snapshotted before anything is released and torn down deepest first, so a parent is never released ahead of its children. A descendant that has already left running is released without an abort it has no turn to receive, and one that refuses to abort stops the sweep and leaves the agent above it registered, which is what keeps the wedged one reachable for a retry rather than turning a failed kill into an unkillable orphan.
  • Plan mode no longer offers to delegate research to an agent you have turned off. The name it handed the model was a literal, and it was task, retired in favour of deep, so the offer named an agent that no longer exists; the fallback behind it was scout whether or not scout was among the enabled agents. Both are now resolved against the live enabled set, and plan mode says it cannot delegate when that set is empty. The eval agent() bridge had the same shape from the other end: an omitted agent resolved against the configured default without checking that default was enabled, so a spawn could be routed to a lane the operator had switched off. It now refuses and names the agents that are enabled, which is what the task tool already did. The Ruby, Python and Julia preludes each sent their own copy of that default, one of them still naming task; they now leave the key out so the host resolves it, the way the JavaScript prelude always has.
  • Compaction tells the summarizer which context window it is cutting for, so the recent-history budget can be capped against it. AgentSession passes the model's declared window at all three preparation sites (/compact, plan-mode compaction, and the auto pass); a model that declares no window is passed nothing rather than a guess, and keeps the uncapped behaviour.
  • The Todos board no longer goes quiet for the rest of a turn because something interrupted it. A queued message or a peer interrupt can cut a tool batch short before the todo call runs, and the placeholder standing in for that call is marked as an error. The session read that as todo refusing the write, and since the placeholder text is identical for every interrupt from the same source, a second one looked like the same failure repeating and retired todo for the remainder of the turn. The board then sat on whatever it last managed to write while the work moved on without it, which is the stale list you were left reading. A call that never ran is not a verdict on what it was asked to do: there is nothing to fix and retrying is exactly right, so the session now leaves the advice alone and keeps whatever it already knew about real failures, which still latch as before.
  • The commit agent's file analysis no longer asks for an agent you have turned off. It hard coded sonic as the agent it fans one-file analysis out to, in three places at once: the session's spawn capability, the agent field of every spawn, and the tool description. sonic is a bundled specialist, so it ships disabled, which made the default profile the broken one: each spawn was permitted by the capability and then refused by the enablement check, and the refusals were flattened into the analysis text the commit message is written from. You still got a commit, without the evidence it presents itself as based on, and nothing said so. The name now resolves against the live enabled catalog and falls back to any other enabled agent, since sonic was this code's own cost preference rather than anything you asked for. When nothing at all is spawnable there is no substitute, so the tool is withheld and logged instead of offered and refused, and the commit message is written from the diff.
  • A rule scoped to tool:bash is matched against the command, not against the JSON that carries it. The bash tool exposed no matcher digest, so both bundled bash rules were run against the raw streamed arguments and were wrong in both directions at once. The search nudge anchors on a search OPENING a command, and in the wire form the command starts mid-string after "command":", so the plainest local search there is — grep -rn foo src — never nudged; test-scope was dead the same way, because in that text bun test is followed by a quote rather than by the end of a command. At the same time the && and ; inside a QUOTED remote command (ssh host "ls x && grep -n foo y") read as shell operators, so the nudge fired on searches running on another machine and advised a built-in tool that cannot reach it. The digest is now the command itself, with heredoc bodies removed because a script written with cat <<'EOF' is data the command writes rather than commands it runs, and the nudge stays quiet for ssh, docker exec/run, podman, kubectl exec and nerdctl exec. Its body says what the built-in tools can actually reach: this machine's filesystem, plus a single remote file through ssh://host/absolute/path.
  • A launched process that ends now says so, instead of waiting to be asked. launch start returned as soon as the process was spawned and then went silent forever: the only ways to learn that a supervised process had finished were wait, which blocks the turn, and polling logs. So "start it and go do other work" — the whole reason to background anything — was the one thing launch could not do, and a finite command handed to it (a test gate, a build) trapped the caller in a poll loop with no way out. Every terminal outcome is now delivered as a background job, the same delivery an async bash command uses, carrying the exit code and the tail of the output; a process that did not exit 0 lands as a failed job. An end the caller asked for stays silent: stop and restart drop the watch first, and a detached process, which outlives the session that would receive the notice, is never watched.
  • The bash-versus-launch rule is cut on how a process ENDS, not on how long it runs. Both tool descriptions said anything "long-running" MUST be launch and never async bash, so a four-minute test gate satisfied the stated rule and went to the process supervisor, which is not built to report a result. The rule now reads: ends on its own, however slowly, is bash; runs until something stops it, or needs input later, is launch. launch also states what start does not do, that ready blocks for its whole timeout when the pattern never prints, and that an exit arrives on its own so wait is for readiness rather than polling.
Removed
  • The bash-tool-nudge rule. It fired on any bash call whose command opened with grep, rg, ag, ack, find or fd, and told the model to reach for the built-in grep/glob tools instead. Searching from bash is a preference, not a defect: the advice already had to carve out pipelines, heredocs, herestrings, and every remote shell (ssh, docker exec, kubectl exec, podman), and each carve-out was a case where the reminder had been arriving on a command that could not follow it. What was left is a sentence the system prompt already says, repeated into the transcript at the moment the operator is reading a search result. ttsr.disabledRules entries naming it are inert and can be dropped.
  • The /secret manager card is gone, and manager is no longer a word /secret reserves: after /secret it is now the start of a credential like any other unreserved word. Every capability the card held is a verb that runs anywhere (/secret rename, /secret value, /secret scope, /secret copy, /secret log --name), which is where they belonged: a screen only a terminal can open left a client with no TUI holding a command whose entire management half did not exist, and the roster, the log view, the search, the key map and the confirmations were five more surfaces to keep honest for capabilities that are one line of text each. Every notice that used to say to open the manager now names the command to run, which is the same command on every surface.
Fixed
  • Reordering the active tool set no longer throws away the provider's prompt cache. The tool array the provider receives is part of the cached prompt prefix, and tools are addressed by name, so their order means nothing to the model. Several callers still handed over a different order for an unchanged set: refreshSshTool filtered ssh out and re-pushed it at the tail, an MCP selection restore emitted every non-MCP tool ahead of every MCP one, and leaving plan or goal mode replayed a list saved before later activations moved it. Each of those re-serialized the prefix from the tools block onward and the next request paid full input rate for all of it, with no change in token count to show for it. A permutation of the set already on the wire now keeps the order it is replacing; a genuine add, removal, or swap is unaffected.
  • Releases publish again. Nothing in the CLI changed in this version: the only difference between 1.0.39 and 1.0.46 in any shipped package is the native addon's version sentinel, which carries the version number and nothing else. The seven versions in between are what it cost to get a release out. The publish step could not read back the draft it had just created, so each attempt failed at verification and left its release behind as a draft, and 1.0.40 through 1.0.45 are still sitting there unpublished. The fixes were to the pipeline rather than the product: install the controller's dependencies before the tag gates run, capture the identity that created the draft, name the repository when downloading a draft's assets, install the verification tooling before verifying, authorize the verification against hidden drafts, and finally publish a draft once it verifies. Documented here because a published release with no entry is indistinguishable from an undocumented one, and the website's changelog build fails on it.

1.0.39

2026-08-01 pending release
Changed
  • /secret has no verbs in a terminal. Whatever you type after it IS the credential, so storing a token is /secret ghp_… and nothing else: no add to remember, no name to invent first. A bare /secret opens the hidden field instead, and either way you are then asked what to call it in a visible, optional field, where Enter accepts a generated name. manager is the only reserved word. The old grammar demanded the label before the thing being labelled, and because the name came first, /secret add ghp_realToken stored a live credential as a NAME with no value attached. Clients with no terminal keep the full add/list/rm/extend/log/discard grammar unchanged, since they have neither a hidden field nor a manager to replace it with, and /secret manager there explains that rather than reporting an unknown subcommand.
Fixed
  • The Agent Control Center now confirms subagent termination instead of aborting immediately on x. You can also hover a terminable row and click its [x]; both paths open the same Dismiss or Yes, terminate card, and the transcript remains on disk. The hover target overlays the row's final cells only while visible, so an idle row keeps its model and activity details.
  • A browser run that throws now reports what the code printed before it failed. The displayed lines are folded into the error text and any screenshots it took still render, so a failing script tells you where it got to instead of only that it stopped. Cancelling a run is left alone, since you already know why it ended.
  • The Agent Control Center card is centred against the terminal rather than sized against the text it happens to contain, so it no longer sits off to one side. Its per-row [x] hit box is measured from the width the roster actually drew, which keeps the clickable region on the glyph when a scrollbar appears. The roster also holds a minimum height, so the card stops twitching as agents come and go.

1.0.38

2026-07-31 pending release
Added
  • Expanded session.instrumentation into a complete session-study record. basic adds lifecycle checkpoints, task transitions, tool and model timing, and effective model request parameters; rich adds context attribution, directional agent-message delivery, result weight, and model throughput; ultra adds compaction links, per-task transitions, routes, fingerprints, and provider source details. veyyon session stats reports each available family. off adds no telemetry but still stores the normal conversation and tool history required to resume.
  • First-run setup now includes a Choose subagents step. Only the general task worker starts enabled; bundled specialists and user or project agent definitions require an explicit grant there or in Settings → Subagents → Agents. Delegation guidance now preserves each concrete agent role, uses task only as the general-purpose fallback, keeps unmatched specialist work in the main session, and collapses homogeneous triage fan-outs into one retrieval and classification operation. The classifier uses the shared Unicode alphanumeric matcher, so non-ASCII labels follow the same token boundaries as the rest of the CLI.
  • Auto QA can upload grievances to https://veyyon.dev/api/grievances, where a Cloudflare Pages Function validates the batch and stores it in D1. Upload is controlled by Auto-upload Grievances in each profile and defaults to off. Local recording remains separate, and veyyon grievances push performs one explicit upload without changing the toggle.
  • The Subagents HUD, the /agents roster, and the inline task widget now show the reasoning effort each agent is actually running at, including an effort it inherited. Previously the effort appeared only when a :level suffix had been typed into the model pattern, so every stock agent rendered as a bare model id and two agents running at different efforts looked identical.
  • /secret rm and /secret extend complete the names of the credentials you have stored, so you no longer have to recall an exact name with nothing on screen to recognise it by. That is a worse position than any other command's arguments put you in, because the whole point of a stored secret is that its value is never displayed, and a mistyped name is a silent no-op rather than something the surface can correct: /secret list was the only way to recover a name. The names come from the running obfuscator rather than the vault on disk, because the vault means file I/O plus a decrypt on every keystroke and load() throws on a malformed or key-missing vault, which would turn a bad vault into a dropdown that crashes as you type. extend completes to extend NAME with the cursor ready for --ttl while rm completes to a finished command, read off each subcommand's declared usage rather than naming extend a second time in the completion code. add is deliberately left out, since the name you give it is one you are inventing and offering existing names there would read as a list of things to overwrite. No secret VALUE reaches the dropdown in any field.
  • The model is told which credentials it can spend, in an AVAILABLE SECRETS section rebuilt from the live secret runtime every time the base system prompt is built. Storing a secret told the model about it in that turn and only that turn, so a session started the next day had GITHUB_TOKEN active and obfuscating while the model had no way to know it existed. Rebuilding from the runtime rather than remembering from the conversation also fixes revocation and expiry structurally: a name the runtime stops returning simply stops being rendered. Names only, sorted so the bytes are stable for prompt caching, and the section is absent rather than empty when protection is off or nothing is stored.
  • Both installers answer --help (-Help on Windows) with their option list. sh install.sh --help used to print Unknown option: --help and exit 1, and an unknown option printed the complaint and nothing else. The options were documented in a comment at the top of each script, which is precisely what an install run as curl … | sh or irm … | iex never shows anyone: there was no way to discover --source, --ref, --local or VEYYON_INSTALL_DIR short of opening the raw file on GitHub. Each script now has one usage printer, its header points at that printer rather than carrying a second list to go stale, and an unknown option prints the list on stderr alongside the complaint. scripts/installer-help-parity.test.ts runs the POSIX one for real and pins that both installers offer the same six options under their two spellings.
  • argot.autoload decides whether the project you launched in is loaded for the session, or every load is left to the agent's argot_load calls. The startup load already existed and was unconditional, and the handbook described the opposite behaviour ("veyyon does not guess which project you mean: the agent decides"), so an operator could not predict whether their repository would be walked as the session came up, and had no way to say no. The default is true, which is the behaviour that shipped. The decision has one owner, shouldAutoloadArgotAtStartup, rather than the conditions spelled out inline at the SDK's call site, so a second startup path cannot honour the setting on one route and ignore it on another. It changes WHEN a dictionary is built and nothing else: the codec is still built, the model still gets argot_load and argot_unload, and expansion stays unconditional, so a handle written after an agent-driven load still expands to exact bytes.
  • veyyon prompt --statements prints what each individual rule of the system prompt costs, with the condition that decides whether it is in this prompt at all, and lists every rule this configuration leaves out. The section breakdown could not answer the question an operator actually has: TOOL POLICY is one row of it and 9KB of prompt, so the answer was "tool policy is large". The cost is MARGINAL, meaning what the prompt would be shorter by without the rule rather than the length of the rule's text, because render ends in a format pass that normalizes whitespace across statement boundaries and text lengths would therefore produce a breakdown whose parts exceed the whole. The parts reconcile exactly instead: section bytes equal the banner plus the sum of the statement bytes plus the one separator newline, measured and pinned rather than argued.
  • veyyon prompt --statement <id> prints one rule's rendered text, which is the counterpart to --section at the granularity a rule has and the next thing anyone wants after seeing a row in the cost table they do not recognise. Rendered rather than the template behind it, so an interpolated rule such as the personality block shows what the model receives. A rule that is not in this prompt reports the condition that would include it and why it exists, and still exits 0, because a rule being off is a configuration and not a failure; an unknown id exits non-zero and quotes the ids of the section it named, since an empty stdout reads as an empty rule rather than as a typo. The printed text weighs exactly what --statements charges the rule, asserted, so the two surfaces cannot disagree about the same rule.
  • The bench can run a per-rule prompt experiment. VEYYON_EVAL_SYSTEM_PROMPT_STATEMENTS had no arm vehicle when it landed, so the mechanism built for the harness could not be used by it: an operator would have had to set the variable outside the runner, where the single-IV guard cannot see it and two different ablation arms fingerprint identically. An arm now carries arms/<arm>.statements.yml, validated before the run (unknown statement id, a value that is neither text nor null, malformed YAML), staged as statements/<arm>.json, folded into the arm fingerprint, and mounted into the container the same scoped way the section override is. arms/candidate-ablate-delegation-gates.* is the worked example, checked through the builder's own validator so it is known to load.
  • VEYYON_EVAL_SYSTEM_PROMPT_STATEMENTS replaces or removes ONE rule of the system prompt, which is what makes an eval able to attribute a score change to a rule instead of to a section. A JSON object of statement id to replacement text, or to null to ablate the rule. Same instrument as the per-section override, one level finer, and deliberately the same shape: environment variable only, no config key and no CLI flag, because a config-reachable prompt override could silently contaminate a production run and a contaminated eval reports a number that looks valid. null and "" are different operations and both are pinned: null removes the row and the separation it carries, "" keeps the row present and drops only its words. Every way an override could do nothing is refused loudly rather than ignored, including an unknown statement id, a value that is neither text nor null, and malformed JSON. An override cannot resurrect a rule whose condition is false, since the condition decides presence and the override decides text.
  • system-prompt-builder/gate-registry.ts lists every setting that changes the system prompt: the setting path, the template variables it decides, what the model sees change, and whether a mid-session flip reaches it. A settings-fed gate used to be declared in up to six places that had to agree, and the one that failed quietly was the rebuild trigger. Frozen gates now say why they are frozen, and the two reasons are kept apart, because "fixed at session start on purpose" and "fixed because the read sits above the builder" call for different fixes.
  • Every package that ships prompts now has a prompt registry, and veyyon prompt --prompts lists all of them. Two packages had none: @veyyon/ai shipped fourteen prompts (a tool-call format guide per dialect, plus the tool-catalog template) next to the fourteen modules that imported them by relative path, and @veyyon/metaharness shipped the edit benchmark's three. That text goes into a model's system prompt, so "which prompts does veyyon send" had an answer that was short by seventeen, and the inspection command listed none of them. Prompts moved to packages/ai/src/prompts/ and packages/metaharness/adapters/edit/prompts/, each with a registry beside them where the import is the registration. veyyon prompt --prompts now lists every id from all three product registries grouped by directory, and veyyon prompt --prompt <id> looks a prompt up in whichever one holds it, so dialect/gemma and compaction/summarization-system work like any coding-agent id. The benchmark harness's prompts stay out of the listing: they are asked by a measurement tool, not by the agent.
  • The auto-compaction threshold is now a two-level picker in /settings: Auto-Compaction Threshold opens to three modes (Auto, Percent, Tokens) with a green check and the current amount on the active one, and each mode drills into its own presets plus a Custom entry. The flat list it replaces mixed all 19 auto/percent/token options in one list, so the three semantics were invisible until you read every description, and a hand-edited value like 170000 showed as nothing selected. Custom values are validated and normalized on entry (92 stores as 92%, 170_000 as 170000), and a stored value the parser cannot read is shown as a warning with Auto in effect instead of presenting Auto as your choice. The stored value is unchanged (auto, 85%, 200000), so existing configs, the legacy thresholdTokens/thresholdPercent fold-in, and the clamp warnings all keep working.
  • Added an Experimental settings tab: every experimental feature now lives in one place — Argot shorthand (five settings, moved from the Context tab's Experimental group), Tool Calling Mode, and Auto-Learn (moved from the Memory tab). The tab's name says "experimental" for everything on it, so labels no longer need an "(experimental)" suffix and the features stop pretending to be regular settings on three different tabs.
  • update: confirm 'Checksum verified' on a successful self-update.
  • release: derive commit-history notes + gate the generator on CI.
Changed
  • Subagent nesting now defaults to parent-only spawning. Your main session can still spawn direct subagents, but those children do not receive the task tool unless you raise subagent.maxNestedSpawnDepth. You can override the blanket limit for one agent through subagent.agents.<name>.maxNestedSpawnDepth or the Agents settings editor; -1 remains unlimited. Existing maxRecursionDepth values migrate to the equivalent nested-depth policy.
  • tui.scrollIsolation now defaults to OFF. While it is on, veyyon holds the mouse in order to read wheel events, which takes drag-to-select away from your terminal: selecting text becomes shift+drag, or /copy to pick text and code out of the conversation without the mouse at all. That trade may be worth making deliberately, but it was being made for everyone by default, and breaking the most ordinary thing a terminal does is an opt-in rather than a default. With it off, the wheel, native scrollback, drag-select and copy all belong to your terminal again, and the prompt still sits at the bottom of the live view. Turn it back on in /settings under Appearance, Display, or with veyyon config set tui.scrollIsolation true; nothing about its behaviour changed when it is on.
  • /secret list renders as an aligned table with a header, wide-character-safe column widths, and a status column that appears only when something is close to expiring. The near-expiry threshold now has one owner shared with the warning sentences, so the marker in the list and the warning below it cannot disagree.
  • The swallowed-drag hint, the tui.scrollIsolation description and the gated tip no longer promise that the mouse comes back on its own. A hold that released after a few seconds of quiet was tried and removed: it unpinned the composer at unpredictable moments and made whether a plain drag selected anything depend on how recently you had typed. The wording outlived the behaviour, which is worse than saying nothing, because it sent you off to wait for a handback that never arrives. All three now state plainly that veyyon holds the mouse while the setting is on and name the three answers that actually work: shift+drag, /copy, and turning the setting off.
  • The bounded JSON walk moved out of the secret obfuscator into src/json-transform.ts. mapJsonStrings rewrites every string in a JSON value, keys included, and three callers want three different rewrites: the obfuscator's placeholders, the argot token dictionary, and whatever transform the session applies at the outbound provider seam. Only the first is about secrets, but it lived in secrets/obfuscator.ts, which reaches 65 modules including an 18-module JSON Schema validator (the obfuscator redacts tool schemas). So provider-boundary.ts imported one function and got all of it, and since every module that can make an outbound request reaches that seam, so did they: reading a local file loaded a schema validator. The walk now reaches two modules, provider-boundary.ts reaches three where it reached 66, and tools/read.ts is 24 modules lighter. Import it from @veyyon/coding-agent/json-transform; the obfuscator re-exports the same function, so nothing that already worked stops working.
  • veyyon -p starts without loading the slash-command handlers. Text and ACP mode dispatch every message through executeAcpBuiltinSlashCommand, and that function imported the builtin registry statically: 740 modules of handlers, and behind them the settings store, the MCP client and the session store. Almost every message is a prompt rather than a command, so a plain veyyon -p "hello" paid for the entire command surface to discover the text had no slash in it. The registry loads inside the function now, after the parse has already said the text is a command. A command still runs exactly as it did; what changed is when the handlers arrive. Print mode reaches 227 modules where it reached 960.
  • The MCP HTTP transport uses the shared isRecord instead of spelling the same three-clause check out inline, and commit/{shared-llm,changelog/generate,analysis/summary}.ts and secrets/obfuscator.ts import completeSimple, validateToolCall and toolWireSchema from the modules that declare them rather than from the @veyyon/ai entry point. That entry point re-exports the whole package, so taking one function from it costs 363 modules; commit/shared-llm.ts reaches 184 where it reached 325.
  • Context accounting and the turn-budget directive moved out of the terminal UI. Both lived under modes/ because the surfaces that display them do, and the session engine imported them from there, which is the wrong direction: the layering gate had to carry a standing exception for each. parseTurnBudget is at session/turn-budget.ts and the token accounting is at session/context-usage.ts. The /context grid stayed where it was, in modes/utils/context-usage.ts, and imports only the shapes from the accounting module.
  • tools/ may import the terminal UI only to draw, and only through named leaves. Unlike the session engine a tool renders its own output block, so it cannot be forbidden the UI outright, and that partial permission is how the boundary rots: thirty-two files under tools/ import from modes/, each one obviously fine on its own. A gate now lists the ten modules they may reach and what each is for, so an eleventh is a decision someone writes down rather than an import that slips in.
  • The Agent Control Center sizes itself to the roster. It used to take the whole terminal whatever was in it, so a run with four agents drew four rows and then about twenty rows of empty bordered card over the transcript you opened it to look past. It keeps room for eight rows so it does not resize on every spawn, grows with the roster, and still takes the viewport and no more when the roster is larger than the screen. The Comms stream keeps the full height, because a feed that resized its own frame as messages arrived would be worse than the space it saves. With no agents running it also stops offering the three keys that act on a selected row, since there is no row to select.
  • Every place that tells you which key expands a folded block now reads the key you have. Nine surfaces wrote ctrl+o out as a literal, so rebinding app.tools.expand left them naming a key that no longer expands anything: the Agent Control Center's Comms chip and fold line, the rule-injection notice, the shared execution footer, the bash block, and both ssh output hints. The line count they carry is unchanged, and is still shown when the action is bound to nothing.
  • The hook editor's footer reads its chords too, and it now names both submit chords. It said enter or ctrl+q submit, while app.message.followUp ships as ctrl+q and ctrl+enter and the handler has always accepted either, so a chord that really submits was missing from the row that lists them.
  • config/settings.ts stopped dragging the whole of @veyyon/ai. It is the most imported module in the package (528 test files, and every runtime consumer of Settings) and it reached 380 modules, 228 of them that package: the streaming engine, every provider transport, the model registry, the error taxonomy. Three imports carried it, each naming a barrel or a re-export instead of the module that owns the value: the in-flight caps setter came from @veyyon/ai/stream rather than from the caps themselves, THINKING_EFFORTS came from the @veyyon/ai barrel though @veyyon/catalog/effort owns it and imports nothing, and the sqlite credential store came through the barrel (345 modules) rather than from @veyyon/ai/auth-storage (212), which defines it. Now 250, with config/settings-schema.ts down from 371 to 106 and thinking.ts from 346 to 6. Nothing about behaviour changes; what changes is that reading a setting no longer instantiates the streaming stack. Neither existing architecture gate could see any of this, because both walk without resolving workspace packages and read this file as 36 modules, so the cut is held by a new gate that resolves them.
  • Reading a local file no longer loads the MCP client, the skill loader or the memory consolidator. tools/read.ts reached 972 modules through five hops, and each hop was a process-global slot or a pure function living inside the heavy module that fills it. internal-urls/mcp-protocol.ts used MCPManager as a type everywhere except one MCPManager.instance(), so reading a static slot cost the MCP client and its transports; internal-urls/skill-protocol.ts reads the active-skill snapshot from inside the skill loader; internal-urls/memory-protocol.ts wanted getMemoryRoot, a two-line path join, from the module that asks a model to summarise a session; and tui/status-line.ts wanted one status glyph from the tool renderer.
  • The session layer stopped carrying the prompt registry and the tool layer. session/messages.ts reached 356 modules, and 261 of them came through two imports that had nothing to do with message shapes. PROMPTS came from prompts/registry.ts, which imports all 143 prompt files by design, for one interjection template; and formatOutputNotice came from tools/output-meta.ts, which owns the fluent builder, the tool wrapper and the spill configuration on top of the notice text, and therefore reaches settings, the streaming output sink and the artifact store.
  • Asking the theme engine for a colour no longer loads an ASCII diagram renderer. modes/theme/theme.ts is the second most imported module in the package (291 test files, and every component that paints) and it reached 307 modules. Thirty-six of them were mermaid: getMarkdownTheme lived there, and it binds a diagram renderer to the palette, so every consumer of a colour paid for the renderer whether or not anything on screen was a diagram. Nothing here was a barrel import, which is why the earlier sweep did not find it: the function was simply in the wrong module.
  • The same import mistake was found in twenty-six more places and the rule is now written down rather than counted. A value defined in a cheap module gets imported through the @veyyon/ai barrel because the barrel re-exports it and that is the first completion an editor offers; the names are identical either way, so nothing ever fails. assistantText, assistantTextBlocks and instrumentationRank are each defined in a module that reaches exactly one, against the barrel's 346, so modes/utils/copy-targets.ts, hindsight/transcript.ts and cli/session-stats.ts each fell from about 347 modules to 76 on one line; task/agents.ts went 520 to 253 and modes/components/settings-selector.ts 783 to 655. Twelve of the fixes did not change their own file's number, because those files also import completeSimple or streamSimple and genuinely want the streaming engine, and they were made anyway: a file whose graph is large for a good reason is not a licence to name the wrong owner, and the day the expensive import moves out the wrong one is still there. The gate holds it as a table of value, owner and the owner's reach, so a new entry costs one line instead of a new ceiling. Type imports are out of scope on purpose, since they are erased.
  • argot.models and argot.disableAboveTokens are now argot.encode.models and argot.encode.disableAboveTokens. Those two are the only Argot settings that decide whether a model is taught to WRITE shorthand; enabled, autoload, tokenBudget and subagents decide whether the feature runs, when a dictionary is built, how many tokens it may spend, and what a subagent starts with. Flat, all six read as peers, and nothing in the names said that emptying the allowlist stops the teaching while expansion carries on regardless, which is the distinction you need to predict what turning it off does. Existing configs need no edit: both keys migrate under encode the first time the file is read, in either the nested or the dotted spelling, and the retired key is dropped the next time the file is saved. A config carrying both spellings keeps the encode value and discards the old one without reading it, so the result never depends on which key is visited first.
  • The two gate test suites stopped describing the prompt through a document no session reads. prompt-gate-registry.test.ts partitioned every gate variable it could find by regular expression over system-prompt.md; it now reads the statement rows and the statement text, which is what reaches the model. That also closed a silent hole in the old check: the expression matched {{#if}}, {{#unless}}, {{#each}}, {{#ifAny}} and {{#has}}, so {{#when MAX_CONCURRENCY ">" 0}} was a gate it could not see and subagent.maxConcurrency was partitioned over a set that omitted the one variable it gates. A row's condition names its variable structurally, so that hole cannot exist on this side, and the cross-check is now exact identifier membership rather than a substring match that would accept {{#if renderMermaidSomethingElse}} as evidence for a row claiming renderMermaid.
  • prompt-gate-inputs.test.ts asserts which text each gate moves, instead of that 76KB of prompt differs. expect(flipped).not.toBe(baseline) proved the flip reached the assembler, which was the bug it was written for, and nothing more: it passes just as well if the flip changes the wrong text, in the wrong section, or one byte of whitespace, and it could not be read, so nobody could tell from the suite what subagent.maxRecursionDepth is supposed to do. Each gate now names the statement it decides, with the signature DERIVED from that statement's own text rather than pasted into the test, so the claim cannot rot into a quotation of prose that has since been reworded. Verified against four mutations the old comparison passed, including one where tui.renderMermaid gates a different statement entirely.
  • The system prompt is now assembled from named statements in full. All six sections are converted, 68 rows in total (conventions 1, ROLE 2, RUNTIME 12, TOOL POLICY 34, EXECUTION WORKFLOW 13, DELIVERY CONTRACT 6), and system-prompt.md no longer feeds any session. A single gated line such as an ast_grep preference, a delegation rule or one contract block can now be named, asserted on, priced in tokens and ablated in an eval without editing the prose around it. Two conditions were added for the shapes the larger sections need: whenAll/whenAny hold conditions rather than variable names so they nest, which is what lets a row say "the task tool is active and this is not the Codex wording", and not covers a block-level {{else}} arm. Zero word-level differences across the gate matrix.
  • The granularity rule that decides how fine a statement is now admits units the prompt itself delimits. DELIVERY CONTRACT is five unconditional XML blocks and EXECUTION WORKFLOW six numbered steps under headings; merging each set into one row would have been faithful to the old rule and wrong, because those boundaries come from the document rather than the registry and an eval that ablates one step needs it to have a name. The check allows adjacent unconditional rows only when the second opens a heading or an XML block, so an arbitrary prose split is still reported.
  • The system prompt's RUNTIME section is now assembled from twelve named statements instead of a block of Handlebars conditionals, and the statements are what a session actually sends. Each one has an id, a stated purpose and a condition drawn from a closed vocabulary, so a single gated line such as the memory://root URL or the MCP discovery notice can be named, asserted on and switched off without editing prose around it. Not one word of the prompt changed. The spacing changed in three gate combinations, deliberately: format deletes a run of two or more blank lines and keeps a single one, and RUNTIME's template put unconditional blank lines between conditional blocks, so with two of those blocks absent # Skills & Rules was landing directly on # Internal URLs with no gap. A statement owns the separation that follows it, so the spacing no longer depends on which unrelated blocks are missing. The three differences are enumerated with their measured deltas and the list is asserted exhaustive in both directions.
  • The -1 that older configs stored to mean "unset" is named in one place. config/settings.ts declared its own constant for it beside the one in config/optional-number.ts, so the module that deletes the old sentinel and the module that translates it each had their own spelling of the same number. No behaviour change; the point is that there is nothing left to keep in sync.
  • The session-entry types are declared once, in @veyyon/agent-core, instead of twice. This package and the agent core each wrote out the same fifteen entry interfaces and their own SessionEntry union over them; twelve of the fifteen were identical and three had drifted, so compaction in the other package saw a SessionInitEntry without the spawns and readSummarize this one actually writes and a ThinkingLevelChangeEntry without configured. The shared shapes now live in one file and are re-exported here under the same names, so every existing import keeps working, and the two entry kinds only this package persists reach the shared union through the declaration-merging hook that already existed for that purpose.
  • The secret obfuscator's JSON type is JsonWithOptionalFields, not JsonValue. It is a deliberately laxer shape than the repository's JsonValue (@veyyon/utils), whose objects never hold undefined, and it needs to be: mapJsonStrings walks tool-call arguments, and a TypeScript object with optional properties is not assignable to the strict shape, so the walker would refuse the values it exists to rewrite. Two exported types with one name and different contents is a bug waiting for an editor's auto-import, so the name now says what the difference is. JsonRecord is unchanged in shape.
  • veyyon prompt --prompts lists every prompt from all three product registries, grouped by the directory each lives in, and --prompt <id> resolves an id from any of them. It listed and looked up only this package's own, so the compaction prompts that rewrite a session's entire history and every dialect format guide were absent from a list that looked complete. An unknown id is now refused with the nearest registered id quoted back and the directory named, rather than a rule that no longer identifies one tree.
  • The "trim each, drop the blanks" loop is nonEmptyTrimmed from @veyyon/utils. gh.ts wrote it twice, 145 lines apart, for a PR identifier list and for search-query fragments, and autoresearch/helpers.ts had a third copy with deduplication folded in. Nothing was wrong with any of them, which is why it was worth naming: the next copy is the one that forgets the trim or decides a whitespace-only entry counts, and then two parts of the product disagree about whether " " is a value. dedupeStrings now adds only uniqueness on top.
  • Host probing moved out of the prompt builder into utils/host-environment.ts. system-prompt.ts is about assembling a prompt, and roughly 280 of its lines were not: spawning lspci and wmic, racing them against a deadline, draining a pipe an exited child left behind, caching the answer on disk, and reading /proc/cpuinfo. Burying a subsystem with its own failure modes inside a 1200-line file about something else is what let two of those failures be handled at different volumes without anyone noticing. The prompt builder now asks for what it actually wants — the CPU, the GPU and the finished rows — and passes its preparation budget in, so the probe's margin (it must outlive its own deadline long enough to write the null cache) lives with the probe instead of being derived from a constant in another file. system-prompt.ts is 1201 lines to 912.
  • firstNonEmpty is in @veyyon/utils rather than private to the prompt builder, which needed it in both halves of that split. It picks the first value that is set and not blank after trimming, which is the case ?? and || each get half of: ?? keeps an empty string, || drops one but also drops 0, and neither trims. A TERM= exported blank now falls through to COLORTERM for the same reason it always should have.
  • The one parser that cuts a bannered prompt lives in banner-grammar.ts, beside the grammar it parses. It was in prompt-sections.ts, whose header called it "section machinery for the default system-prompt template" while it served every prompt in the product, and that mislabelling is what let it close over the system prompt's banner table in the first place: handed the subagent prompt, same grammar, it recognised only the banners the two happen to share and folded the rest away without a word. The banner table is now a required argument, so there is no default to fall back to and no prompt the parser knows. prompt-sections.ts keeps what is genuinely about the system prompt: its section names, its table, and the reordering a harness profile asks for.
  • The three memoized derivations in prompt-sections.ts use the shared once rather than a module-level let and a ??= written out three times. Three copies of a caching pattern are three chances to get it wrong in a way only one of them shows: ??= re-runs forever if its derivation ever yields an empty string or zero, which these do not today and nothing was checking. The regression test that keeps the reads deferred was also flagging the deferred spelling as if it were an eager one, so the honest fix failed the check that exists to encourage it; it now looks for a read nothing on the line defers, and proves on synthetic input that it still catches an eager read.
  • The prompt banner grammar is its own module, and the file that held it is named for what it contains. prompt-blocks.ts owned two unrelated things: how a banner is written and recognised in EVERY prompt, and the system prompt's own list of sections. The universal half is now system-prompt-builder/banner-grammar.ts, a leaf that knows no prompt, so prompts/registry.ts no longer reaches into the system prompt's module to ask what a banner looks like. The remaining half is section-registry.ts, since a "block" in that subsystem already means an entry of the string[] buildSystemPrompt returns, and the file contained none. PROMPT_SECTIONS became SYSTEM_PROMPT_SECTIONS for the same reason: it lists the system prompt's sections, not every prompt's, and the PROMPT_SECTIONS/ override directory shares the old spelling.
  • Collapsed seventeen helpers that existed as byte-identical copies into one definition each: the project resolution that decides which launch daemon a directory uses (two copies, so the client and the presence file could have disagreed about a symlinked project), the "YAML if .yaml, otherwise JSON" decision the LSP and DAP config readers each made privately, the provider-name rendering three user-visible surfaces each had their own version of, and the patch check that refuses two hashline sections resolving to one file, which now lives in the package that defines the section type. Three more followed: the DAP files' private error renderer, which the shared errorMessage already did better (an error with an empty message now shows its class name instead of nothing), the commit an experiment records, and the current-branch-or-HEAD spelling two bundled commands each rolled themselves. Then four whose copies could disagree across a boundary: the diagnostic text sanitizer two rendering surfaces stated separately (a diagnostic containing a tab would have rendered differently depending on where you saw it), the browser tab id the supervisor and its worker each derived (a tab addressed under two ids takes commands on neither), the collab wire envelope the host and the browser guest each coded, which now lives in @veyyon/wire beside the header length it reads, and the WebCrypto byte coercion four packages needed, now asStrictBytes in @veyyon/utils. The token subcommand followed, which the auth gateway and the auth broker offer identically and each implemented separately, down to the JSON shape it prints. The envelope is the one that fails most quietly: the payload still decrypts, because the room key is untouched, so a host and a guest that disagreed about the byte order would deliver a frame to the wrong peer without an error anywhere. Four more after that: the tree indentation three renderers drew (the JSON tree's copy had drifted to the OPPOSITE argument order, so the same nesting could draw different rules in two panes of one screen), the thenable guard the IPC and MCP stdio send paths each carried (the surviving copy's own comment justified the other as "battle-tested there", though only one of the two was tested at all, and those tests moved to @veyyon/utils with the function), the token subcommand described above, and the bootstrap veyyon bench and veyyon dry-balance share, where the part that matters is the failure path: if settings or the extension providers throw, the credential store opened a line earlier is closed before the error propagates, or a SQLite handle leaks on every failed invocation. The last two: the log replay both worker supervisors performed (a worker has no logger of its own, so it ships the level with the message and the supervisor replays it, and a copy that mapped a level to the wrong method would move a class of worker diagnostics out of the log an operator is reading), and the runtime installer's pipe reader, which moved to @veyyon/utils as readPipeText.
  • Fixed two ways an eval kernel could be started twice for the same work. The key a retained kernel is stored under, (session, cwd, interpreter), had three copies, one per managed runtime, and the Julia copy had drifted: it resolved the interpreter path without following symlinks, so reaching the same Julia through a link (/usr/local/bin/julia and the versioned binary behind it) started a second kernel that shared no state with the first, and it joined the key's parts with ::, a sequence that can occur inside a session id or a path. All three now use one builder that canonicalises the path and separates the parts with a byte that cannot appear in either.
  • Tool-output folding now recognises six more shapes an agent meets constantly: python -m unittest -v per-test lines, cmake/make progress ([ 42%] Building C object ...), make's directory recursion, gradle tasks that did no work, docker layer ids, and maven artifact fetches. Measured on runs captured on a real machine rather than on fixtures: a 41-test unittest run goes from 2,473 to 170 characters (93.1% smaller) and a 41-file cmake build from 2,556 to 60 (97.7%), both keeping every diagnostic and the summary verbatim. Only the shapes that state no work was done are folded, so a gradle task that ran, a docker Step 4/12 line, and every [ERROR]/[WARNING] maven line stay.
  • A read with a bounded line range now says on its last line what it padded: read file:1-3 answers with six lines and [Showing lines 1-6: you requested lines 1-3, plus 3 lines of trailing context]. The padding is deliberate, it saves the follow-up read that a one-line-off anchor needs, and it was documented only in docs/tools/read.md, where a reader looking at the result never saw it: the same read was reported as over-delivery twice, because the surprise happens where the result is, not where the docs are. The counts come from the range that was actually shown, so padding cut short by the end of the file reports the smaller number, an unpadded read carries no notice, and :raw stays byte-verbatim.
  • veyyon gc now lets you set how recently a file may have been written and still be left alone: gc.writeGraceMinutes in your config, or --write-grace-minutes for one run. The window was a fixed five minutes while the retention knob beside it was already configurable, so you could tune how long sessions are kept but not how much slack GC leaves for live writes. One minute is the minimum, and a smaller value is raised to it with a message rather than honoured, because a shorter window would let GC delete a blob a running session wrote a moment ago. Breaking a stale GC lock keeps its own five-minute window, so a shorter grace no longer also makes one GC run steal another's lock.
  • Every prompt veyyon sends a model is now owned by one registry per package, and the import is the registration. Prompts were reached by ad-hoc relative path from wherever they were used: 160 import … with { type: "text" } specifiers across 85 files, 27 of them in one module. A registry beside them listed 23 of the 143 and recorded each one's location a SECOND time as a path string the compiler cannot check, so a prompt's home was written down twice in spellings nothing kept in agreement, and 120 prompts were written down nowhere. src/prompts/registry.ts now holds the text import, id and purpose of every prompt in one row each, nothing else may import a prompt file, and veyyon prompt --prompts lists all 163 with what each is for instead of 23. Prompts that lived beside their consumer (src/commit/prompts, src/commit/agentic/prompts, src/autoresearch, packages/agent/src/compaction/prompts) moved into their package's one prompts tree.
  • Prompt files are grouped by when they fire instead of piled in a system/ directory. Moving 163 prompts into one tree was not the same as organizing them: system/ held 61 of them, 40% of the tree, with personalities, plan mode, rule violations, IRC, session titles, loop redirects, agent creation, memory and the main system prompt all as siblings, and six more sat loose at the root. Directories now say when a prompt reaches the model: session/ for what defines a session, turn-control/ for what interrupts or resumes a turn, side-channel/ for turns that reuse the context but are not the task, plus subagent/, plan-mode/, rules/, autolearn/, titles/, thinking/, requests/ and bench/. The largest directory outside tools/ is now 17 of 163. A prompt's id is its path, so the ids veyyon prompt --prompts lists moved with the files. The PROMPT_SECTIONS/ names are unaffected: those are the banner sections inside the system prompt, not prompt files.
  • One splitter now cuts every bannered prompt, and both callers agree about a broken one. The product had two implementations of the same NAME\n==== grammar: the template slicer walked byte offsets and refused a missing or out-of-order banner, while the reorder and inspection path walked lines and silently folded an unrecognised banner into the section above it. Unifying the section definitions had been mistaken for the whole fix, so a renamed banner refused the build in one path and quietly merged two sections in the other, shipping a prompt with a region missing and reporting nothing. Strictness is now a caller's choice on one parser: the template slicer requires its sections and names the id, the banner and the document when one is absent, while a custom prompt with no banners is still read as one region rather than an error.
  • The context gauge is now the last thing on the footline. On the default status line it sat between the model and the session name, so the one number that changes every turn was wedged between two that never do, and the default and minimal presets disagreed about it. Standing state reads first, the gauge last. A gauge you place explicitly on the right side of the line stays where you put it.
  • Tool cards now line up with everything else in the transcript. A card drew its frame at column 0 while the prompt glyph, assistant text and command blocks all sat two columns in, so every tool call broke the single left edge the eye follows down the screen. The card starts on that edge now, and keeps the same gap from the right.
  • /compact soft and /compact remote now say that those names are retired. Both were removed with the provider-native compaction path they used to steer, and typing one fell through to the plain focus-text path: veyyon compacted with your configured type, folded the word into the focus text, and reported success, so it looked like the type you asked for had run. It still compacts and still passes your text through exactly as typed, and now it tells you which name you used and to use /compact summary instead.
  • veyyon update now refuses to replace a binary that is a symlink, instead of silently destroying the link. If ~/.local/bin/vey points at a checkout build, the update renamed a downloaded binary over that path: the checkout survived, nothing pointed at it any more, and the update reported success, so you kept editing a build that no longer ran. The refusal names the link, where it points, and both ways out (update that install directly, or rm the link first). A hardlinked binary still updates.
  • An update that fails inside a directory you cannot write to now reports why it failed. The cleanup of the downloaded file failed too, and that error replaced the real one, so you were told veyyon could not delete vey.new when it could not write into the directory at all. The download is left behind instead, and reclaimed by the next update.
  • A dotted key at the top level of a config file now works. subagent.model: openai/gpt-5 looks exactly like the nested form the docs show, and people write it, but it was parsed, merged, and then never read: values are looked up by walking nested keys, so the setting sat in the tree under a literal "subagent.model" key that nothing looked at, and it silently did nothing — no warning, and veyyon config list showed the default. Every setting was affected. Flat keys naming a setting this build knows are expanded when the file is read, so either spelling works; a setting written both ways keeps the nested value and drops the flat one with a warning naming both; a key this build does not know is still preserved exactly as written.
  • veyyon config reset <key> removes the key instead of writing the default back into config.yml. Writing it made the reset value look explicitly configured, pinning a default that was meant to follow the app.
  • An optional numeric setting is now unset by having NO key, instead of storing -1 to mean "no value". The sentinel made -1 unreachable as a real value, and presencePenalty: -1 is a penalty providers accept. Choosing Default in /settings removes the key, and a config holding the old -1 on one of these keys has it dropped on load, so your effective settings do not change while -1 becomes settable. The seven affected settings are the six sampling knobs and compaction.modelContextWindow.
  • Subagents now run the model you are working with. On a stock install they each ran a DIFFERENT model — scout and sonic on a small one, reviewer on a thinking one, designer on a third — and no subagent model setting could change it: the bundled agents carried role aliases (@smol, @slow, @designer, @task) in their frontmatter, and an unset role expanded to a built-in priority.json chain rather than reporting that the role names no model, so those aliases won before any choice of yours was consulted. Role expansion no longer has a chain (every role, advisor included, inherits the live main model when unset), no bundled agent pins a model, and the four layers that can name a subagent's model — that agent's row, the blanket subagent.model, the definition's own model:, then inherit — resolve in one place with the deciding layer reported. priority.json still picks a fast or strong model on first run, where nothing has been chosen yet.
  • A configured subagent model that matches no available model now refuses the spawn and names the setting to fix. It used to fall silently through to the next layer, which is indistinguishable from your setting having no effect. /agents shows the pattern, the model it resolves to, and which layer decided, so an override that was outranked is visible rather than merely disappointing.
  • Only the general-purpose worker and agents you wrote yourself are offered to the model now. The five bundled specialists (scout, reviewer, designer, librarian, sonic) ship unoffered: each agent type costs its description in every request of the session, and most sessions want a worker and nothing else. Enable the ones you want in the Subagents tab or with /agents, where space cycles offered / not offered / blocked. An unoffered agent still runs when something names it outright, so /review keeps spawning reviewer; blocked refuses even then.
  • Subagent effort is now picked from a list instead of typed, and a value that names no level is reported instead of ignored. "Subagent Effort" was a free-text field, so hihg was accepted, resolved to nothing, and read as "inherited" — a setting that looked configured and did nothing. Both effort surfaces (the blanket setting and the per-agent row) offer the same rows — off, minimal through max, auto, and Inherit — from one vocabulary, and an unrecognized value from a hand-written config is named alongside the levels that would have worked. It is still never rounded to a neighbouring effort.
  • Task delegation moved under the same area and gained a level: subagent.delegation is off, allowed (the default), preferred, or required, replacing task.eager. off removes the task tool outright instead of describing a tool the prompt then forbids, and every delegation instruction is derived from what you have enabled — with only the worker offered, nothing tells the model to pick an agent type or to send research to a scout it cannot spawn.
  • The context gauge now reports how much room is LEFT, and says so. It measures against whichever limit comes first — the auto-compaction trigger when auto-compaction is on, the model's window otherwise — and the quiet footline shows that as a draining 8-cell bar with a labelled percentage (▰▰▰▰▰▰▱▱ 76% left), so the bar and the number cannot disagree. The bar used to grow as room ran out, which is a fuel gauge running backwards, and a bare 38% beside it was read as consumption by half its readers. Text presets show tokens on both sides of the slash (47K/170K) instead of 47.3%/200,000, which put a percent and a token count either side of a slash and was true under no reading. The percentage is a whole number: a tenth of a percent moved every turn and decided nothing.
  • Clicking the context gauge in the composer's footline opens the /context breakdown. The footline has room for one number, and the question behind it needs the per-category split; a hover cannot serve it because the main screen tracks mouse buttons without motion reporting, so nothing is known about the pointer until a press.
  • /context reports the room left alongside what is used, formats its token counts (272K rather than 272000), and, when the per-category breakdown cannot be computed, says that and why instead of printing three plain lines that look like a healthy narrow report.
  • Argot has one name. The package was published as argot, its settings were argot.*, and its directory and veyyon's wiring modules were lexpack, so every reader had to learn the mapping. The directory is packages/argot and the modules are argot-wire.ts / argot-cache.ts / tools/argot.ts. Nothing you configure changed: the setting keys were already argot.*. Three things the mismatch had been hiding turned up with it: the handbook's Argot chapter was an eight-byte stub, because SUMMARY.md linked why/argot.md while the real chapter sat at why/lexpack.md; a stale duplicate of the Argot blog post was still in the repo; and the dictionary-generation script for the DeepSWE bench imported a package name that does not exist, so it could not have run.
  • The per-turn receipt (display.showTokenUsage) now reports how long the turn took. The total duration was read only to divide the output tokens by it, so the row published a rate and never the time behind it: you could read 59.3/s and still not know whether the turn took four seconds or forty, which is exactly the number you want when comparing two models on the same prompt. The one time value it did show was time-to-first-token wearing the clock icon with nothing to say so, so a reader took it for the turn's length. The clock now means the turn's length, formatted the way the status line formats elapsed time, and TTFT is labelled ttft.
  • A shell command that failed is now marked as failed, not just tinted. The bash block deliberately shows no title, since the frame would only repeat the $ line, and that suppressed the failure marker too: state: "error" reaches the border colour and nothing else, so with colour stripped — a monochrome terminal, a colour-blind reader, a transcript pasted into an issue — a failed command rendered byte-identically to a clean one. A failed run now carries its own ✗ failed header. This also covers failures that carry no exit code: a timeout, an abort, or a command that could not be spawned propagates as a thrown error whose result has no exit code to key the Exit: N chip on, so that whole class of failure previously showed no marker at all.
  • A rendering hook or message renderer that throws now says so in the transcript instead of being replaced without a word. Tools, extensions, and hooks can all supply their own renderer, and a throw was survivable but invisible: you saw the tool's name where its card should be, raw output where its diff should be, an empty box for a multi-file edit, or the built-in card in place of an extension's, with nothing but a log line behind it (and for custom messages, not even that). The substituted render now carries one line naming which renderer failed, why, and what you are looking at instead, marked with a glyph so it survives a monochrome terminal. Returning undefined still declines quietly: that is how a renderer opts out for one call.
  • The model slot holding the model you are working with now has one name. It answered to default in storage, interactive as setModel's role argument, and both spellings in scattered inline comparisons, and one line stored default while logging interactive for the same write, so a session-log entry could not be matched to the setting it changed. Callers pass either spelling and resolveModelSlot translates once.
  • The priority service tier now reads as a serving tier rather than a fourth effort level. Its icon sat immediately before the thinking-level glyph in the same color, so ⚡ ◉ high looked like one more rung on the effort scale. It now trails the effort as its own chip, in its own color, and names itself, which also makes the tier visible in symbol themes whose fast icon is empty (it used to render nothing there). /fast keeps its name and now names what it changes ("Priority tier (fast mode) enabled") instead of describing the same state in a second vocabulary.
  • A setting replaced by another is now marked retired in the schema, so it stops advertising itself as a choice: veyyon config list leaves it out, and config get/set still work but name the key that governs the behavior now. compaction.thresholdTokens, compaction.thresholdPercent, and defaultThinkingLevel are the first three.
  • Optional numeric settings share one definition of "unset". The -1 sentinel was written out by hand in thirteen schema entries, with two different submenu encodings and a list of paths maintained inside the settings selector; the selector now derives that set from the schema, and every Default row comes from one helper.
  • The auto-compaction trigger now has one setting, compaction.threshold, whose unit is part of its value: auto (the model's window minus the reserve), a percent that moves with the model (85%), or an absolute token amount that is the same on every model (170000). It replaces two rows both labelled "Compaction Threshold" (compaction.thresholdTokens and compaction.thresholdPercent) that wrote one axis with an invisible precedence, so picking the wrong one silently did nothing. Your global config is rewritten on load — the amount becomes threshold: 170000, the percent becomes threshold: 85%, and both retired keys are dropped — so the ambiguity leaves the file without moving your trigger; project configs and --config overlays, which are never rewritten, are folded in at read time with the same precedence. The resolved threshold is now reported with its origin — 170k (85% of 200k) — whenever it is capped for the current model, still coming from a retired key, or unparseable.
  • Handoff now ends with the same <files> block a summary does, so a session started from a handoff gets the same map of what was read and modified.
  • /compact subcommands are now the two compaction strategies, summary and handoff. The former soft and remote modes existed only to steer provider-native remote compaction, which was removed; a stale /compact soft ... or /compact remote ... is read as focus text rather than erroring.
  • Settings search now ranks by field instead of one concatenated blob: the setting named for your query comes first, prose matches come last, and a setting can declare the words users actually type for it (reasoning finds Default Effort, copy/clipboard finds scroll isolation). Searching no longer matches a setting by its current value or its enum values.
  • Thinking effort now has one persisted home: defaultEffort, a per-profile list of model to effort rows edited at /settings → Model → Default Effort. A row keyed by a model selector applies to that model, and a * row applies to every model without one. It replaces the profile-wide defaultThinkingLevel enum, which is still read so an existing config keeps working: with no * row, that value becomes it. Effort resolves in one documented order (session choice, then an explicit :level on the role's selector, then the model's row, then the * row, then the model's default) owned by config/effort-resolver.ts rather than written inline at each call site.
  • /thinking and its /effort alias now change the current session only and print where the saved default lives. They used to rewrite the profile-wide default while the cycle keybinding did not, so the same change stuck or evaporated depending on how you made it, and there was no way to try an effort without keeping it.
  • Effort pickers now follow the active model's catalog-defined variant list. They show an explicit Default row, Veyyon's Auto control, Off only when the model permits it, and only native effort names after that. A low/high model no longer presents unsupported medium or xhigh choices that would be silently clamped.
  • The compaction.model and subagent.model chain editor now edits the highlighted primary or fallback position directly. Add fallback appends a position, and Delete removes only the highlighted position. Reopening model pickers reuses the catalog projection and sort while refreshing authentication badges.
  • changelog: backfill the undocumented veyyon changes across all packages.
  • identity: lock every @veyyon manifest to author santhreal.
  • natives: document the version-sentinel freshness gate and tri-state AVX2 lock-step.
  • release: lock the fork-notice-safe changelog roll.
  • release: unify the changelog roll onto the gate's bullet predicate.
  • release: verify the published linux-x64 binary launches.
  • release: verify the darwin binary's .sha256 sidecar too.
  • hashline: sample the seed-fuzz corpus on the gate, soak the full 3M nightly.
  • hashline: reclaim the range-edit perf bullet into [Unreleased].
  • ai: satisfy useLiteralKeys in the prototype-key metadata test.
  • lint: remove dead vars and a comma operator flagged by biome.
  • natives: warn (not silently skip) on a stale workspace native.
  • Align stale tests with strict contracts (unblock release gates).
  • Repoint sibling-package collapse-and-trim onto the utils owner.
  • utils: give collapseWhitespace a dependency-free subpath.
  • Repoint the three named errorMessage copies onto the utils owner.
  • veyyon-shell: unify head_tail_dedup onto one primitive owner.
  • tui: paint terminal ground (OSC 11) + track missing source; add handbook book-freshness gate.
  • Docs+debrand: canonical internal-doc coherence, verification stamps, dead-code cleanup.
  • Fix auth-broker-gateway ASCII diagram alignment after veyyon rename.
  • website: auto-sync changelog from CHANGELOG + published GitHub releases; auto-deploy site on release.
  • Website changelog: show only veyyon releases + Unreleased block; credit pre-fork oh-my-pi history as a note, not release cards.
  • Gate npm publish + Homebrew tap on their secrets/vars; debrand brew formula (veyyon.rb, veyyon binary + vey alias, santhreal/veyyon repo).
  • Debrand user-visible surfaces: terminal title π→vey, App/report/prompt/gallery omp→veyyon.
  • wip: in-progress modes/plugins/mcp/tui/docs work from parallel session (committed to unblock the 1.0.0 release cut).
  • Biome format wrap in auto-compaction-queue test.
  • natives: changelog references the real exported symbol __ompInstallTokioRuntime.
  • deployment: repo secrets/variables table + rollback-and-hotfix runbook.
  • Rustfmt diff.rs, crash_handler.rs, bun.rs.
  • swarm-extension: debrand npm metadata (description + repo/bugs URLs).
  • utils: hoist levenshteinDistance to @veyyon/pi-utils (one canonical home).
  • mnemopi: use canonical levenshteinDistance from @veyyon/pi-utils.
  • cli: near-miss did-you-mean routing coverage.
  • changelog: record the Kimi usage and recent-sessions ordering fixes.
  • changelog: note the set_cwd argot_load tip fix.
  • Purge upstream (can1357/oh-my-pi) traces from runtime source.
  • Lock loud surfacing of managed AGENTS.md seed failures.
  • coding-agent: correct the auto-chdir fallback chain in the maybeAutoChdir comment.
  • tui: warm the native addon before process.platform mocks.
  • utils: repoint strip-ansi's stale browser-consumer refs to @veyyon/tool-render.
  • doc-freshness: surface tracked-but-deleted docs loudly; re-stamp releasing.md to canonical HEAD.
Fixed
  • A browser run that fails now shows what it printed before it failed. Both backends built the run's output and then discarded it on the error path, so a cell that displayed a page observation and then hit its deadline came back as a bare "Browser code execution timed out" with none of the evidence it had just produced. That is exactly inverted: a run that succeeds needs its output least. The displayed text is now folded into the error, and any screenshots the run took still render. A cancelled run is left alone, since there is no failure to explain.
  • /secret add with no name now asks for the name in its own visible field before opening the masked one. Previously it opened a single hidden field titled "Paste the secret", which reads as a request for the secret's NAME: type GITHUB_TOKEN there and veyyon stored that name as the credential under an invented SECRET_1. Because the field is masked nothing on screen contradicts the misreading, and nothing downstream can catch it either, since a name is a perfectly well-formed secret value. A shape heuristic would be worse than the bug, because an AWS key id such as AKIAIOSFODNN7EXAMPLE is uppercase and underscore-free and looks exactly like a name. One prompt cannot ask two questions, so there are now two: the name field is optional, echoes what you type, and says a name is generated if you leave it empty; the value field follows, masked, and names the secret you just chose. Cancelling the name field abandons the command before any credential is asked for, and an unusable name is refused there rather than after you have pasted a live secret. /secret add NAME is unchanged and still asks only for the value.
  • The Agent Control Center is centred in the terminal, like every other modal in the TUI, instead of sitting flush against the top of the screen. The card was laid out against its own height rather than the viewport, so the shell had no room to centre it in and the panel hung from the top edge over an otherwise empty screen. It also hugs its content now: a one-agent roster draws a short card rather than framing rows of bordered nothing, keeping a four-row floor so the first spawns do not resize the panel while you read it.
  • Clicking the row-local [x] in the Agent Control Center roster terminates that agent again. The hit test placed the card's body one column right of where it is drawn, because it added the modal's horizontal padding while the frame insets every body line by exactly one column regardless of that padding. The target sat one column past the [x] the pointer was over, so the click fell through to opening the agent on any card whose padding was not the compact variant. The roster now reports the width it actually drew at instead of the hit test re-deriving it.
  • set_cwd now reports the directory it moved to. The outbound path optimization shortens absolute paths under the session's roots to save tokens, and a re-root registers both the old and the new directory as roots, so the one message whose entire content is those two paths had both of them rewritten: Moved cwd: /work/project → /work was delivered to the model as Moved cwd: . → .., naming neither end and reading as a move to a parent directory. The result of this tool is now exempt from that rewrite; every other tool's output is shortened exactly as before.
  • Ending a session no longer kills a browser or desktop app you launched yourself. The browser tool can attach to an already-running Chrome or Electron instance instead of spawning its own, and it recorded a process id in both cases, so the teardown that runs on /exit sent a process-tree kill to whichever instance the handle named. It now terminates only a process the tool actually spawned.
  • Cancelling browser open no longer leaves a Chromium running with nothing pointing at it. Two abort paths released the browser handle only when the open had taken a temporary hold, so pressing Escape during an open, or aborting while the open waited its turn in the per-tab queue, stranded a live browser that no teardown path walks.
  • Cancelling a browser run that has already been cancelled now stops the run instead of waiting out its full timeout. The cancellation was sent to the tab worker before the run itself, so the worker had no active run to match it against, discarded it, and then executed the run it was meant to cancel.
  • The Agent Control Center now confirms subagent termination instead of aborting immediately on x. You can also hover a terminable row and click its [x]; both paths open the same Dismiss or Yes, terminate card, and the transcript remains on disk.
  • Subagents whose model and effort are left at Inherit now receive the parent session's configured effort through a dedicated runtime channel. Previously the inherited model selector omitted effort by design, so child initialization fell back to the saved global default and could run at auto even when the parent session had selected another level. Per-agent, blanket, frontmatter, and explicit model-suffix overrides still win.
  • The Linux, macOS, and Windows installers now record ownership receipts for binaries and completion files. Reinstall and uninstall preserve unrelated files at those paths, while exact legacy Veyyon launchers and generated completions migrate without manual cleanup. Source installs also verify the checkout's origin before updating or deleting it, so an unrelated pristine repository is moved aside rather than reset or removed.
  • Source self-update now requires a clean tracked checkout and records the current Git revision before fast-forwarding. If dependency installation, generated-artifact regeneration, native provisioning, version verification, or the runtime probe fails after the merge, Veyyon restores the previous revision and its runnable artifacts before reporting the failed update.
  • The release train now waits until the public releases/latest redirect resolves to the exact release tag, verifies the installed version against that tag, and runs the production installer round trip on Linux x64, Linux arm64, macOS x64, macOS arm64, and Windows x64 before reporting success.
  • Tool calls now refuse an expired or removed secret placeholder before execution and name only the retired placeholder in the error. Turning protection off also retires every name advertised by the running process while preserving stable redaction output. Retired names cross secret-runtime refreshes, a replacement stored under the same name clears the refusal, and ordinary text such as #TODO# remains untouched.
  • The Windows installer now accepts a healthy release binary that writes a native-variant warning to stderr while its search self-test succeeds. Windows PowerShell 5.1 promoted that warning to a terminating NativeCommandError, so a bytecode-free Windows build passed --version and --smoke-test but was rejected before installation. Exit status and the expected search match now decide the preflight while stderr remains available when the command actually fails.
  • Provider payload confidentiality failures now identify a safe structural cause such as a cycle, non-JSON value, size bound, accessor, or protected-key collision. The request still fails before network I/O, and neither the payload, key names, secret values, placeholders, nor transform exception text reaches the operator message.
  • Concurrent explicit binary updates now use unique same-directory staging and rollback files, then serialize the installed-path mutation. One failed update can no longer truncate or delete another update's live download, and stale cleanup cannot sweep an active rollback copy.
  • Task spawn-policy and self-recursion refusals now return explicit error results. A requested background task also fails loud when no async job manager exists instead of silently running synchronously and changing the parent turn's blocking behavior.
  • install.sh --uninstall now preserves a user-managed executable at the legacy ~/.bun/bin/veyyon path. It removes only the exact Bun-global Veyyon package symlink that older installs created, while canonical binaries, aliases, completions, and installer-owned PATH entries retain their existing cleanup behavior.
  • Session listing now reports a failure to enumerate recovery backups instead of silently continuing with primary files only. A backup remains intact and visible in the unreadable-session report until the directory or storage backend can be read again.
  • Session-backed Python, Ruby, and Julia eval now replace an interpreter that exits unexpectedly mid-cell and replay the cell once. Unexpected process exits were classified as user cancellation, which bypassed the existing dead-kernel recovery path. Timeouts and explicit cancellation still stop without replaying the cell.
  • A cold Julia eval now gets 60 seconds to compile each kernel startup phase instead of 15 seconds. Clean hosted runners exceeded 30 seconds before the first cell could run, which surfaced as a cell timeout even though the user's code had not started. Warm kernels still return immediately.
  • Tool status headers no longer gain a blank leading cell when the active symbol preset intentionally leaves that tool's glyph empty. Empty symbols now produce no ANSI wrapper, so IRC inbox headers and other iconless tool rows begin at the same column as their title.
  • With tools.discoveryMode=all, generate_image starts in the searchable tool inventory instead of sending its schema on every provider request. Explicit tool whitelists still keep it active, and selecting it through tool discovery persists activation for subsequent turns.
  • Subagents no longer embed their launch-specific id and a live peer roster in the system prompt. Agents discover the current roster through irc list only when coordination needs it. Sibling launches now share the same cacheable system-prompt prefix instead of invalidating it whenever an agent id, activity, or peer status changes.
  • A credential passed with = no longer reaches editor history or the on-disk draft. The predicate that decides whether a submitted slash command may be recalled and resumed tested for --token followed by whitespace or end of line, so --token=sk-live-... never matched: one keystroke decided whether a live bearer token was written to durable storage and offered back on arrow-up. Measured before the fix, /mcp add srv --url https://example.com --token=sk-live-SECRET123 reached <artifacts>/draft.txt verbatim. The classifier now recognises twenty-four credential option names in the space, =, quoted and bare-trailing spellings, the short -t and -H, and credential material sitting in no option at all: URL userinfo, and a query parameter whose name looks like a secret using the same pattern the logger already applies to MCP URLs. It scans the arguments of every command rather than an allowlist of command-and-verb pairs, so a credential passed to a command nobody thought of is still caught. --url is judged on its content rather than its name, which keeps an ordinary /mcp add srv --url http://x recallable.
  • /mcp add with no arguments answered with a single 174-character line, the widest usage string in the product, which at 80 columns broke [-- <command...>] across lines and left a dangling [-- that reads as a broken flag. It also restated the usage as a second literal, free to drift from the one the command already owned.
  • Tool approval prompts now use a structured permission card that separates the one-call scope, approval reason, and requested action. Approve and deny choices include explicit descriptions, radio focus, and complete navigation help instead of presenting one flat accent-colored text block.
  • config list is laid out for the terminal it prints on. It emitted one unwrapped key = value (type) line per setting, so at 80 columns fifteen lines ran past the edge and bashInterceptor.patterns was a single 2355-character line; the terminal re-broke each of them wherever it liked with no indent, which put the tail of a value at column 0 where it read as another setting. The listing now goes through the same width-aware renderHelpTable / renderHelpParagraph primitives as --help, so values wrap into a column and continuation lines stay indented under their key. Long values WRAP rather than truncate: config list is the command an operator runs to read what a setting is actually set to, so an ellipsis would hide the part they came to check, and trimming and rejoining the continuation lines reproduces the stored value byte for byte. A value that opens with a token too long to share its key's line (a 2.3kB JSON blob) gets its own indented block instead of being jammed into the value column and overflowing anyway. What still exceeds the width is only what has nowhere to break, an unbroken enum spelling or a regex with no spaces in it, each on a line of its own.
  • A value-taking flag with no value is refused instead of silently dropped. --model, --approval-mode, --thinking, --system-prompt and every other string-valued flag fell through the parse loop when they sat in the last argv position, so veyyon -p "..." --approval-mode exited 0, answered normally, and ran on the DEFAULT approval mode. Nothing was printed. There is no misspelling to notice in that command, so the only evidence the operator had that a safety-relevant flag took effect was that they had typed it, and the same hole could quietly run a session on the wrong model. The refusal names the flag and both accepted spellings. The one case still skipped rather than refused is the profile bootstrap's internal boundary marker, where refusing would discard the message the user is waiting on to report a flag they did not really leave empty.
  • An unrecognized flag suggests the flag you meant. veyyon --modle=x answered "unknown flag: --modle" and stopped there, leaving the reader to diff their typo against a list of fifty-seven flags, while a misspelled SUBCOMMAND one keystroke away already answered "Did you mean veyyon config?". Suggestions come from the parser's own flag tables, so a name it offers is always a name it accepts; nothing close enough produces no guess rather than a wrong one.
  • An interactive launch with no terminal exits 2 rather than 1. cli/exit-codes.ts names this case verbatim in its description of the usage code, so the documented contract and the code disagreed, and the visible symptom was one mistake split down the middle: veyyon confg exited 2 while veyyon confg get foo reached the no-TTY guard and exited 1. A wrapping script branching on the code to decide whether retrying could help got opposite answers for the same typo.
  • Seven settings rows no longer sit on the settings screen offering a choice that changes nothing. Speech Vocalization Mode, Enhanced Speech Rewriting and Speech Vocalization Voice render while Speech Vocalization is off; Speech Model and Speech-to-Text Submit Trigger while Speech-to-Text is off; Auto-Background After and Stall After while Bash Auto-Background and Bash Stall Detection are off. All four master toggles ship off, and every read of the dependent values is behind its master, so with stock settings you could open /settings, pick "Final message only" or "Stall After: 15 seconds", watch the row take the value, and get no change in behaviour at all. That is the same failure as a dead flag and worse than an absent feature, because the screen confirms the choice. The two bash values did not even reach the model: the bash tool description renders them only inside the {{#if autoBackgroundEnabled}} and {{#if stallDetectionEnabled}} guards. This adds no new mechanism. ui.condition and the selector's visibility check already existed and are what hides 26 of the memory tab's 27 rows behind the chosen memory backend and three Advisor rows behind advisor.enabled; these seven had simply missed it. Four predicates were added beside the existing nine, no default moved, and the rows are unchanged when the feature is on. Row counts with stock settings: Interaction 43 to 41, Shell 18 to 16, Providers 36 to 33. Secret Lifetime and Record Secret Use are deliberately left visible while Hide Secrets is off, because /secret add reads both on the pre-enable run that turns protection on.
  • A vault that cannot be read no longer stops a session from starting, which had made the command that repairs one unreachable on every surface except the full-screen interface. /secret discard moves a broken vault file aside, but the vault was read while the session was being assembled, so a -p, scripted, or ACP run over a project with a corrupt vault.json exited 1 before any command could be dispatched: veyyon -p "/secret list" and veyyon -p "/secret discard --scope project" both failed with the same message, and that message recommended the command the other run had just refused to execute. The only repair was deleting the file by hand, which is exactly the thing the encrypted store exists to stop you doing casually. Sessions now start without the vault, and /secret list reports the failure with the repair instead of throwing. This is not a wider catch. load() still refuses every failure it refused before, because skipping a scope that failed an origin or integrity check would silently turn a tampered vault into "that scope has no secrets" and drop its entries out of the obfuscator. The failure is absorbed one level up, where the answer is unambiguous, and every scope holding a file is marked unreadable rather than empty, so its placeholders are refused rather than sent as literal text and the operator is told which file to move aside. The mid-session reload that runs before a live #NAME# is expanded still fails closed: the loader takes an explicit mode, and only startup asks to degrade.
  • A project-scope vault can no longer be committed by accident. /secret add --scope project writes an encrypted credential store to <project>/.veyyon/vault.json, which is inside the repository you are working in, and nothing kept it out of your commits: a real untracked vault.json was found in this repo, one git add -A from being published. Veyyon now writes .veyyon/.gitignore on the way to creating a project vault, covering vault.json and the vault.json.unreadable-* name that /secret discard renames a broken vault to. Committing one would not expose the credentials, since the ciphertext is unusable without the machine key, but it would put a credential store in your history that no clone can open, which then breaks /secret for whoever cloned it. Only the vault is ignored, never the directory: .veyyon/ also holds skills and project settings a repo is supposed to track, and ignoring all of it would read as git losing files. An existing .veyyon/.gitignore that does not already cover the vault is appended to rather than rewritten, because a vault stored before this shipped is exactly the case a create-only guard would miss while saying nothing; your own lines are left untouched, and a file that already names the vault is not modified at all.
  • /secret no longer repeats a word you typed back at you when it refuses a command, because on a /secret line that word is often the credential. Every verb except add echoed it: the realistic slip is muscle memory for add with a different verb, which is exactly the moment a secret is on the line, so /secret extend TOK sk-live-..., /secret rm TOK sk-live-..., a value appended to a bare /secret list, and a credential landing where a lifetime or a scope goes all wrote it into an error that reaches the scrollback and the saved transcript. The command whose entire purpose is keeping credentials off the screen was putting one there permanently, and in the saved session it survived the restart. The refusals now name the position that was wrong (the word after the first) and say why the word is not shown, which carries the same correction without repeating anything. /secret log 50 still echoes 50, since a run of digits cannot be a credential worth protecting and the --limit hint is useless without it. Fixed at the source in parseTtl, so a lifetime typed anywhere stops being echoed rather than only on the one path that had noticed. That also let add drop a per-verb rewrite it only carried to blunt the same echo, which had cost it the distinction between "not a lifetime", "expires immediately", and "too large": add and extend now explain the same bad lifetime identically.
  • set_cwd now explains that . in later tool paths and directory headers names the current absolute working directory, while .. names its parent. This prevents the agent from treating a successful re-root as an unexpected move and running a second command to rediscover the same directory.
  • Subagents whose effort is Inherit now receive the parent session's effective effort before their session starts. Previously the inherited value became undefined at the executor boundary, which let the provider default every child to auto even when the parent was running at medium. Explicit subagent efforts, including an explicit auto, still win.
  • /secret add now makes secret protection survive the process it was turned on in. Storing your first credential switches secrets.enabled on and says it was "saved for the next one", but the write was only queued behind a 100ms debounce and nothing on that path flushed it, so any short-lived surface exited before it landed: a -p run, an ACP request, any non-interactive client. The next launch then came up with protection OFF and the credential already in the vault, which is the one state the feature exists to prevent, and the confirmation had promised otherwise. Found by driving the real CLI rather than the test suite: /secret add reported the save, and the very next process reported secrets.enabled as false. A flush that fails now says so in the confirmation instead of overstating what was written.
  • /secret add --from-env now tells you when the variable is set but empty, instead of claiming it "is not set in this process". Unset and set-to-nothing shared one message, so exporting TOKEN= produced a line that was false for the situation you were in and sent you to re-check an export that was already correct. A variable holding only whitespace is refused too, rather than stored as a credential that would expand to blank text inside a command. A value that merely carries surrounding whitespace is still stored byte for byte, since a real token is allowed to and trimming one would corrupt it where nothing could trace the failure back.
  • The Linux, macOS, and Windows installers now ask the staged executable for its version before they replace an existing command or change your alias, shell profile, or completions. A checksum-valid asset that reported the wrong release version previously replaced the working binary and failed only in the final doctor check. The mismatch now stops at the staged file and leaves the installed bytes untouched. Interrupted-install cleanup also removes only installer-owned staging names, so a similarly named user file is never mistaken for debris.
  • Binary self-update now preserves the old executable under a recovery link or copy and replaces the live path with one atomic rename. A hard kill between the old two renames previously left veyyon absent from PATH; it now leaves either the complete old binary or the checksum-verified new one. Automatic update state and lock errors return a visible failure instead of rejecting behind the TUI, completion refresh failures appear in the update notification instead of writing raw text through the live frame, single-quoted source wrappers stay source installs, and the rollback picker continues past a full GitHub page even when drafts or prereleases are filtered out. Windows arm64 now reports that no release asset exists instead of requesting a filename the release train never publishes.
  • A session holding any stored secret no longer has its tool calls refused because the vault's revision moved. The freshness guard asked whether the SESSION held a secret rather than whether the CALL carried a placeholder, so a bash running echo "$HOME" was rejected out of a session that happened to hold one credential, and the reload that would have fixed it was started and thrown away on the line above the refusal. A stale revision now reloads the vault and the call expands against the current values. A refusal survives only where it is real: the text carries a placeholder the runtime would substitute, a reload was actually attempted, and it could not produce a runtime that resolves it. That refusal now names the reload failure and says to retry and check /secret list, instead of blaming another session for a reload of its own that failed.
  • Enabling secrets on a machine where the vault key cannot be created no longer kills veyyon at launch with a bare stack. Key provisioning throws on a key root that cannot be hardened, a symlinked or read-only ~/.veyyon, or anything occupying the key path, and that throw was awaited uncaught during session construction, so veyyon died before drawing a frame and nothing on screen said why. It still refuses to start, because starting without a key would silently switch redaction off after you deliberately turned protection on, but the failure now names the key path, what to check, and the one command that starts veyyon without protection if that is what you want.
  • A vault file that cannot be read no longer lets a placeholder run as literal text, and no longer takes the terminal down on launch. A vault that clears every origin and integrity check but whose decrypted contents will not parse is now skipped so you can still reach /secret to repair it, while a vault that fails any of those checks still refuses to start rather than quietly reporting that the scope holds no secrets. Previously the revision fingerprint read file stats and never parsed, so nothing downstream noticed a skipped scope: #TOKEN# was passed through verbatim and the command ran with those seven characters where a credential belonged. While any scope is unreadable, a placeholder-shaped token that does not resolve is now refused, naming the unreadable file and the repair. Tokens the surviving scopes do resolve still expand, and with every scope readable an unknown #WORD# is still just text.
  • /secret discard --scope <scope> moves a broken vault file aside, which is the repair the unreadable-vault notice tells you to run. The notice and the operation it names had been describing a repair the product could not perform: the method behind it existed and nothing in the tree called it, so the only real route was deleting the file by hand. The scope is required and has no default, because unlike every other use of --scope this one names a file to move rather than a place to store something, and defaulting it would move a working vault out from under the session. The file is renamed, never deleted, and the new path is reported, because a vault that will not parse may still hold recoverable entries sealed with a key you still have.
  • A secret matched by a secrets.yml pattern no longer stops rendering readable for the rest of the session after the vault changes, you move directories, or protection is toggled. Each of those refreshes carries previously seen values forward so redaction can never regress, and it carried them as redact-only entries; because plain values are substituted before patterns are matched, the carried-forward entry replaced the value before its own pattern could match, and the pattern is what makes a placeholder reversible. The value stayed hidden, so nothing leaked, but it rendered as an opaque #0...# token from then on and no later refresh could recover it.
  • Secret expansion no longer breaks by itself while veyyon is running. The vault's revision fingerprint also stat'd the directories the vault files sit in, which are ~/.veyyon, the profile agent directory, and <cwd>/.veyyon, and a directory's timestamps move whenever anything at all is created or removed inside it. veyyon's own SQLite journals, session files, caches, and even the vault's own lock file therefore made the vault look like another process had rewritten it, seconds after a session started and with nothing stored. The fingerprint now reads the vault files themselves, and a write this process makes no longer counts as somebody else's, so storing a credential with /secret add and spending it in the same session works instead of reporting that the vault changed underneath you. A genuine write from another session or process is still detected.
  • A vault that changes under a running session no longer takes the session down with it, and a stored credential is no longer painted onto your screen. Rendering the transcript, an assistant message, or a tool call ran the same expansion the spend path uses, so a vault written by a second window or a rotation script turned the next redraw into a thrown error out of a code path that only ever draws, which left the session unable to accept commands. Display paths now expand what they can and render the placeholder literally when they cannot, with a notice saying why, and the refresh they schedule is awaited on the render paths that can wait. The same seam also stopped restoring values that exist precisely so they are never shown: a secrets.yml pattern match is still restored on screen, while a vault credential and an environment-derived value stay as placeholders in prose, tool arguments, intents, and both rendered transcripts, so they no longer reach your terminal or its scrollback.
  • /secret rm now tells the model the credential is gone. Only add ever produced an agent notice, and a revoked placeholder is no longer substituted, so a model still carrying "use #GITHUB_TOKEN#" wrote that literal text into the next command: the operator saw an authentication failure from the remote service with nothing anywhere connecting it to the secret they had just removed. /secret extend likewise says the placeholder is still good. The extend notice quotes no duration, because a lifetime pinned into conversation history goes stale and then misleads, and the operator already has the exact time left on screen.
  • A revocation notice reaches the model even when secret protection is off. Notice delivery sat below the !secretsEnabled early return, so every notice was discarded in exactly the state where a stale placeholder does the most damage: with no obfuscator nothing is substituted, so every #NAME# still in the model's history reaches the shell verbatim. Notices that OFFER a usable placeholder are still withheld there, since they would advertise an expansion the runtime cannot perform.
  • SecretObfuscator.namedSecretNames() returns its names sorted. It previously returned them in vault load order, which is stable enough for reconciling the live runtime and quietly wrong for the system prompt, where a section whose bytes reshuffle between rebuilds invalidates the provider's prompt cache for no behavioral reason. Every pre-existing assertion on this method was single-element, empty, or a length check, so none of them could see the difference.
  • /settings now keeps the selected row visible in short terminals and shows a non-actionable resize message when the terminal cannot fit a usable pane. Mouse close, outside-click, and category switches now cancel uncommitted theme or text previews before leaving their scope. Clicking or scrolling the settings pane returns keyboard focus from the category sidebar and each category restores its last selected row. Rows shadowed by project config, a --config file, or a runtime override now name that source and remain read-only instead of accepting a hidden profile write. Default Model shows the saved profile choice separately from an active session override. Array-encoded model chains show their primary model and fallback count instead of inherit, and expanded Advanced rows retain their original section heading while you scroll.
  • Session effort overrides remain authoritative across role-model switches until cleared. Clearing one now reveals the active role selector's explicit effort.
  • Pressing Esc to interrupt a turn can no longer take the process down with [Unhandled Rejection] AbortError: The operation was aborted. The interrupt path aborted the session as void session.abort(...), and void discards the value without attaching a rejection handler, so an abort that rejected escaped to the process from a keystroke handler. The reported stack blamed the keystroke rather than the teardown step that actually failed, because nothing had ever caught the error to record it. Detached aborts now go through one owner, abortDetached, which handles the rejection and logs it with the call site that issued it; the same defect is fixed at the four places that had it (both interrupt paths in the input controller, the SDK agent control, the ACP session control, and the extension host control).
  • /settings → Model → Default Model now stores only the model selector. Saved effort has one UI owner, Default Effort. The old second step wrote a hidden :effort suffix to modelRoles.default, which outranked the adjacent Default Effort row and made later edits there appear ineffective. Role models and subsystem chains keep their explicit effort steps.
  • set_cwd now reports a re-root as one readable move, Moved cwd: <from> → <to>, and tells the caller that relative paths moved with it. The old line put the origin in a trailing parenthetical, Session cwd is now <to> (previously <from>), so the half that says whether anything happened was the easiest half to miss, and when either endpoint reached the message unresolved it collapsed to Session cwd is now . (previously .): a successful re-root naming neither directory. An agent reading that cannot tell a move from a no-op, so it either re-issues the call or keeps resolving relative paths against the directory it just left. Both branches now name the directory at each end and point at the one call that lists the new root.
  • TERM=dumb now suppresses bold, italic, underline, strikethrough, and inverse SGR sequences as well as foreground and background colors. NO_COLOR still preserves non-color emphasis.
  • The Windows installer stages downloaded releases and local builds with an .exe suffix. Windows PowerShell 5.1 can run both verified preflight searches inside an output pipeline instead of rejecting .download or .local staging files as documents. Replacement now retries a transient Windows image lock while the staged preflight's last worker or an antivirus scan releases the executable, so an immediate reinstall does not fail after verification. Reinstall and uninstall also reclaim interrupted local staging files, including the legacy suffixless form.
  • A block that folds exactly one line now says "1 more line". Every collapsed tool block, the read tool's continuation notice, the edit preview, the LSP hover, the MCP and eval renderers, ssh output and the Agent Control Center's comms fold wrote the count inline, so all of them said "1 more lines" on the commonest fold there is.
  • Image paste now uses the same chords in the composer as everywhere else. The editor kept its own copy of the shipped defaults and had pinned app.clipboard.pasteImage to ctrl+v, so on Windows the alt+v fallback and on macOS the super+v fallback did not fire there, while the docs, /hotkeys and the settings UI all listed them. There is one table now, in config/keybinding-defs.ts, and the editor reads it.
  • The Agent Control Center's Comms view names the expand key you actually have. Its key handler already read app.tools.expand, but the footer chip and the fold line both said ctrl+o in so many letters, so rebinding the action left the card telling you to press a key that no longer unfolds anything. When no expand key reaches the card the chip is dropped rather than shown, and the fold still reports how many lines it hid.
  • /hotkeys now prints the live chord for every row, including the composer and editor ones. The reference page sends you there for "the live list after your remaps", and half the table was hardcoded: Enter, Tab, Ctrl+U, Ctrl+K, Ctrl+W, Ctrl+A, Ctrl+E and the word motions were literal strings, so rebinding tui.editor.deleteToLineStart in your keybindings.yml left the panel that exists to tell you what you had done still showing Ctrl+U. The rows that stay prose are the ones with no binding behind them: the arrow keys as a family, alt+enter, the push-to-talk Space hold, and the prompt sigils /, !, $ and #.
  • Eight keybinding ids that nothing read are gone. Every id in the table is printed by /hotkeys with a default key beside it and written into the generated keybindings.yml as something you may remap, so an id nothing reads is a documented shortcut that does nothing when pressed. app.session.rename, app.session.togglePath, app.session.toggleSort and app.session.deleteNoninvasive named actions the session selector does not have, app.tree.foldOrUp and app.tree.unfoldOrDown named a tree view that is not implemented, app.session.delete named an action the selector has but reaches by matching the literal delete and backspace keys rather than the binding, and tui.input.copy named a copy the editor does not implement. One of them was a lie rather than a silence: app.session.toggleSort claimed ctrl+s, which is app.session.observe and opens the Agent Control Center, so /hotkeys told you ctrl+s sorted your session list. An entry naming a removed id in your own keybindings.yml is kept and ignored, as it already was.
  • Two functions no longer await import("@veyyon/utils") in a file that already imports it statically. The module was instantiated either way, so the await bought nothing and cost a promise per call.
  • The list of setting TYPE tags has one owner. settings-schema.ts exports SETTING_TYPES and isSettingType, both derived from a Record<SettingType, true> so that adding a definition kind without listing it, or listing one that does not exist, is a compile error. The schema corpus test had kept its own copy of the list, which had drifted both ways: it named an object kind the schema never had, and it was missing modelChain, so compaction.model and subagent.model read as untyped.
  • Class privacy is # everywhere it can be. private and protected are compile-time annotations that vanish at build time, so a private field is reachable from any code holding the object; AGENTS.md asks for the runtime-enforced # and exempts only constructor parameter properties, which have no # spelling. Six members are now #, and the four ChatBlock lifecycle hooks plus the one controller that overrides them are bare methods, because a # member cannot be overridden by a subclass and a hook exists to be overridden. MnemopiSessionState gained a scoped constructor option in the process: the failure suite had been building an instance with Object.create(prototype) and an Object.assign, which skips the constructor and therefore has no private fields at all, so the class now offers the bank bundle as a real seam and the test drives the real class through it.
  • Veyyon no longer discovers SYSTEM.md, .gemini/system.md, or APPEND_SYSTEM.md. A whole-prompt file bypassed the assembled prompt and its settings gates, while the append file duplicated AGENTS.md at fewer scopes. Existing files remain untouched and produce an operator-visible launch notice with their exact path and the supported replacement. The per-invocation --system-prompt and --append-system-prompt flags remain, and PROMPT_SECTIONS/ remains the persistent section-level mechanism.
  • New profiles copy only AGENTS.md in the instruction row. RULES.md never travels during profile switching. The assembled project footer now gives the agent its active profile name, agent directory, skills directory, and the exact global and profile AGENTS.md paths.
  • A model chain is valid written either way. compaction.model and subagent.model hold an ordered chain, and every reader has always accepted both a comma-separated string and a YAML list. The schema declared them string, so a config written as a list was reported as a value that "does not match its declared type" and shown as invalid while the runtime read it correctly, and the list form is what the handbook shows. Both are declared modelChain now, which admits either spelling and still reports a number, an object, or a list holding something that is not a pattern (naming which entry). The settings UI picks the model editor from that type instead of from a hardcoded pair of paths, so a third chain setting cannot silently become a text box.
  • inlineToolDescriptors: auto follows the active model after a model switch. A session that started on Gemini previously kept Gemini's inline catalog after moving to a native OpenAI model because schema pruning was frozen at construction. Prompt placement, provider-schema pruning, side requests, and session dumps now resolve one active-model policy. The full built-in catalog integration saves at least 500 estimated provider tokens after the switch; the measured fixture saved 937.
  • The default built-in system prompt is 742 estimated tokens smaller. Completion, evidence, cleanup, and delegation invariants each have one owner instead of being restated across sections. The default delegation path fell from 862 to 433 tokens, and native tool providers no longer receive a prompt inventory that repeats names already present in their schemas. Inline and non-native tool modes still receive full descriptors.
  • The default system prompt has no monolithic prose template. system-prompt.md is now a checked scaffold containing only {{templateSections}}; statement modules own instruction text, and the section registry owns section order and banners. Assembly joins those sections directly, so prompt text containing JavaScript replacement tokens such as $ remains literal. PROMPT_SECTIONS/` and eval section overrides accept body text only and cannot restyle a registry-owned banner.
  • Prompt overrides now fail closed. An eval statement override is rejected when its gate is inactive or a whole-section replacement would discard it. Body-only overrides cannot inject any registered section banner, blank append files change no bytes, and veyyon prompt --statements prices the effective replacement text instead of the shipped text. An explicitly blank custom prompt remains a replacement and no longer emits an empty provider block.
  • Changing a session's working directory now reloads the destination prompt inputs instead of rebuilding from startup captures. The cwd, AGENTS.md, workspace tree, repository context, project extension skills, rule inventory, and TTSR matchers move together. Non-TUI /move uses the same re-scope owner, so ACP, RPC, and headless sessions also refresh secrets and prompt state.
  • skill:// paths now use one canonical, session-owned resolver in every tool. Bash can no longer follow a child symlink outside the declared skill root, autocomplete and managed-skill collision checks no longer read another top-level session's skill inventory, and distinct authored skills with one name produce an operator-visible collision warning.
  • Profile creation now distinguishes an absent optional seed item from unreadable or malformed source data. An AGENTS.md directory, a file named skills, or a genuine filesystem failure aborts creation with the source path instead of silently producing an incomplete profile.
  • Layered context files no longer resend a less-prominent file when its entire normalized paragraph sequence appears contiguously in a later authoritative file. The comparison keeps paraphrases, noncontiguous blocks, and distinct parent/child instructions. The regression fixture removes 86 provider bytes (22 estimated tokens) per request; a comparison of the active 22KB and 40KB rule files took 0.36 ms during prompt preparation.
  • plugin doctor and the LSP project detector probe the filesystem without blocking. Both ran a run of sequential fs.existsSync calls inside an async function: the detector checks up to six marker files in a row before doing any work, and doctor checks a package, a tools entry, a hooks entry and every extension entry for each installed plugin, so its probe count grows with the number of plugins. On a cold or network filesystem that is a stall the TUI cannot paint through. Both use pathExists from @veyyon/utils now, which also fixes a reporting bug in doctor: existsSync answers false for a path that exists and cannot be read, so an unreadable plugin entry, exactly the broken install doctor is for, was reported as "not found" and sent the operator looking for a file that was there.
  • The two profile dispatchers are named for the surface each one drives: runProfileCliCommand in cli/profile-cli.ts and runProfileSlashCommand in slash-commands/profile-command.ts. Both were runProfileCommand. Their signatures differ enough that importing the wrong one cannot compile, which is why it lasted; the cost was on the reader, since a call site said nothing about which surface it drove and two test files each had a runProfileCommand in scope meaning a different function. test/architecture/command-surfaces-do-not-share-names.test.ts now fails when cli/, commands/ and slash-commands/ declare the same exported name, and DEVELOPMENT.md says what each tree owns. No behaviour change: veyyon profile and /profile do exactly what they did.
  • /agents is the one agent surface. There were four. /agents opened a configuration list of every agent type the project offers, which is the Subagents settings table rendered a second time. /cockpit (alias /hub, the app.agents.hub and app.session.observe keys, and the double-tap-left gesture) opened a separate "Agent Hub" overlay with its own roster, its own ordering, its own status glyphs and its own drill-in. A third roster, the "subagent inbox", sat behind a display.subagentInbox flag with a fourth drill-in. Three of them rendered the same registry three different ways, so "which agents are running" had three answers that could disagree, and the operator had to know which screen they were on to read the one that was right. Every entry point now opens the same card, which has two views: Live, the roster of agents that exist right now, and Comms, the agent-to-agent message stream. /cockpit and /hub are aliases of /agents rather than a command of their own, so there is one description and one help entry.
  • Opening an agent hands the main view to that agent's live session, so you can talk to it. Enter on a roster row retargets the transcript, the composer and the status line at the agent, and Esc there returns you to your own session; a parked agent revives on the way in. Every drill-in before this was a read-only pane inside the card, which could show you a subagent asking a question and gave you no way to answer it. Two agents still open the read-only transcript, because there is no session to hand over: an advisor, which is observability-only and is not an addressable peer, and a collab guest's agents, which live on the host.
  • A roster row says what TYPE of agent it is, next to its call sign. A call sign is memorable but arbitrary, so Kestrel told you nothing about whether the thing burning tokens over there was a reviewer or a scout. The type was already recorded at spawn and was rendered only when the agent had NO activity to report, which is exactly when nobody is looking at the row. Rows sit in spawn order rather than by recency: call signs are assigned from that order, so a recency sort renamed agents as they worked, and the old hub had to freeze its order on open to stay usable.
  • The Comms view streams agent-to-agent traffic as it happens, read from the message bus rather than from session files. A subagent's transcript records what THAT agent received, so a view built from transcripts shows each half of a conversation in a different file and never shows a message that failed to arrive at all. IrcBus now keeps a 500-message log of every delivery with its outcome, because mailboxes are drained on delivery and a peeked mailbox is empty by the time anyone looks. Failed deliveries are marked with the reason. Long messages are folded to their first few lines with a count of what was hidden, and ctrl+o unfolds them, the same key that expands a truncated tool result in the transcript.
  • The card's age column advances again. It is repainted on a five-second ticker, and the pane it repaints had captured Date.now() when it was built, so the ticker paid for a repaint on a fixed cadence and the label it existed to update never moved. The scan for agents persisted by earlier runs now reports how many it registered and only repaints when that is more than zero, instead of rebuilding the roster one microtask after every open to draw the same rows again.
  • Fixed a modal getting SMALLER as the terminal got bigger. computeModalDims took its vertical margin off both ends unconditionally, and the compact path zeroed that margin at 24 rows and under, so the two rules met in the middle of ordinary window sizes: a 24-row terminal gave a full-screen card and a 25-row terminal gave an 11-row card whose body had no room for a single list row. The Agent Control Center on a 25-row terminal, which is an ordinary split pane, showed an EMPTY box, and you needed a 38-row window to see what a 24-row one showed. Card height now has a floor that rises with the padding the card carries, the compact path sheds padding but not the margin, and the height a list gets is a non-decreasing function of terminal rows from 8 to 120.
  • Page up and page down move the Agent Control Center. The card handled up, down, j and k and nothing else, so a fan-out of sixty agents or a stream of five hundred messages could only be crossed one row at a time. They come from the shared tui.select.pageUp / pageDown bindings, so it is the same key and the same distance as every other selector.
  • The Live roster reads as a table. The call sign and type were padded to a column and the rest were not, so running on one row and idle on the next pushed the model and the activity three columns apart down the list. The age column was worse: formatAge treats an age of zero as UNKNOWN and returns nothing for it, so the busiest agent in the roster showed no age at all while one idle for forty seconds read just now, and every row that lost the column slid left. Status and age are now measured and padded like the names, and an agent that acted this second reads just now.
  • One long agent type no longer costs every other row its content. Columns are padded to the widest value in the roster, so a single subagent spawned as a-very-long-agent-type-name padded the type column to 27 cells on every row; on a 56-column card that left nothing for the status, the model or the activity, and the roster stopped answering the question it exists for. No column may now take more than a quarter of the row. The model badge degrades with the space instead of being cut by the row: whole where it fits, claude-son… where it does not, and dropped rather than stubbed once too little of it would survive.
  • A failed kill or hand-over says what happened. Both paths put the exception's own words on the notice line, so stopping an agent whose session could not abort announced ref.session.abort is not a function. (In 'ref.session.abort({ reason: USER_INTERRUPT_LABEL })', ...): no agent, no action, nothing to do next. It now reads Could not stop Kestrel: <reason>, with the underlying reason kept at the end as evidence.
  • The selected roster row is highlighted across the whole row. The fill was wrapped around the row's TEXT, so the band stopped wherever that agent's line happened to end and changed shape as you moved the cursor: a ragged right edge reads as a rendering fault, not as a selection. It now runs to the pane's edge and stops exactly where the scrollbar gutter starts, which is a width only the scroll view knows, so ScrollView answers it (contentWidth) rather than the row guessing. Guessing the full width would have been worse than wrong: the view truncates to make room for the bar, and the cut drops the escape that CLOSES the fill, painting the scrollbar and everything past it.
  • The same ragged band is fixed everywhere else it appeared: the session tree, history search, the extension list (rows and hover), the OAuth picker, the model browser, the model hub and the plan review overlay. One helper pads the row and then tints it, so there is one place to read the rule and one place to change it. Underneath was a width the two owners disagreed about: the list helper reserved ONE column for the scrollbar while ScrollView reserves TWO, a gutter plus the glyph, so every row was built a column too wide and cut on the way out. The helper is gone; a list now receives the width from the view that will render it and cannot restate the reserve.
  • The setup wizard's theme step picks a THEME, and its two modifiers compose with it. "Colorblind colors" and "ANSI-safe" sat in the list as if they were alternatives to a theme, and selecting either one FINISHED the step: you got colorBlindMode: true with your theme left at whatever it already was, or symbolPreset: ascii with theme.dark forced to dark-terminal. Neither is a theme choice, so a colourblind-safe LIGHT theme, or ASCII glyphs on Titanium, could not be asked for at all, and picking a theme afterwards silently reverted the modifier, because the theme rows restored the original state on commit. They are toggles now: selecting one flips it, repaints the preview with the new combination and stays in the step, and every commit writes both of them alongside the theme. The rows are rebuilt after the preview applies as well as before it, because the mark is drawn with the very glyph set the ASCII toggle controls, and building them once left the two checkboxes in unicode while the whole rest of the screen had switched to plain text.
  • Twelve overlays got the modal height fix they were supposed to have. Each carried its own height < 24 instead of asking the shell, and a threshold that depends on the card's own margin and padding is only ever right for one sizing, so when the shared rule was corrected the model hub, the settings picker, the session picker, the extension dashboard, the OAuth picker, history search, the copy and move overlays, the usage reset and message pickers and the plain select list all kept the cliff.
  • The slash-command reference documents /models, /status and /force:. All three are aliases, and /status is the surprising one: it opens the Extension Control Center rather than anything about session status.
  • The Live tab in the Agent Control Center counts the rows it shows. It counted only the running agents while the pane listed every one, so a roster with parked agents read Live (17) above twenty rows, and a roster with nothing running read Live (0) above a full pane.
  • Agents spawned in the same millisecond are listed and named in spawn order. Ties were broken on the agent id compared as text, which put 10-Sub between 1-Sub and 2-Sub, so a fan-out of twenty appeared as 1, 10, 11, 12, 2, 3 and the call signs were assigned in that order.
  • Six glyphs in the unicode symbol preset rendered as empty boxes on a machine without a Nerd Font. (running) is absent from DejaVu Sans Mono, so every busy row in the Agent Control Center drew a box where the status mark belongs, and / (the token in/out icons in the status line) exist in none of the monospace fonts checked. Running is now, in/out are /, and the worktree, gh and disabled marks changed for the same reason. The nerd and ascii presets are untouched.
  • The unread-message badge on an agent row takes its glyph from the symbol preset instead of hard-coding one, so it follows the ascii and nerd presets like every other icon.
  • A second, dead answer to "which model does this session start on" is gone. config/model-resolver.ts exported findInitialModel and restoreModelFromSession, a full precedence chain with its own console.log/console.error output and a process.exit(1) inside a library module. Nothing in the workspace called either one, not even a test: main.ts had grown its own resolution and the two had drifted, and docs/models.md documented the dead order as the live one. Both functions and their registry type aliases are deleted, and the doc now describes what buildSessionOptions actually does, including the deferred resolution for a bare model id an extension may register and the loud substitution when a remembered default is unavailable.
  • An empty icon no longer leaves its space behind. A symbol preset may leave an icon blank and the unicode preset leaves thirty-one of them blank, so a label written as ` ${theme.icon.job} ${count} rendered 5: a leading space and a number with nothing saying what it counts. The status line showed it worst, several metrics side by side with unlabelled numbers between them. The join is one leaf function now, modes/theme/icon-label.ts`, which emits the separator only when there is an icon to separate; it had existed as a private helper inside the status line's segment builders while twenty-nine hand-written copies across thirteen files did not use it, which is why the gap appeared in some parts of a line and not others. Every icon-then-label site goes through it, and a repo-wide ratchet keyed on the SHAPE rather than on a file list fails if the template is written by hand again.
  • Secret protection now closes every provider-bound path, including compaction, commit analysis, benchmarks, evaluation, Hindsight, Mnemopi, memory extraction, title and thinking classifiers, task labels, TTS, image tools, resumed assistant text, and dynamic prompts and schemas. Each physical attempt rebuilds from raw text with the live profile, project, environment, and vault runtime after credential refresh. Authenticated replay fields fail closed when they contain a live value, JSON keys are protected with collision checks, provider-bound images are canonically re-encoded without metadata, and credential-bearing URLs bypass cloud readers. Unnamed values and generated one-way aliases use machine-keyed HMAC derivation; replacement output is atomic; ambiguous regex alternations are refused; and ill-formed Unicode cannot enter placeholder generation. Working-directory rescoping rolls back transactionally. Vault files reject scope aliases, hard links, legacy unbound envelopes, oversized descriptors, and stale-lock reaper races. Audit generations reject hard links and oversized reads, recover non-newline tails safely, protect JSON keys, and escape terminal control bytes. Inline /secret add preserves exact trailing bytes and rejects option-like text after credential data begins.
  • Vault storage now pins open physical scope and key directories for every transaction. Kernel no-replace and exchange publication preserves a racing destination, key creation uses one crash-atomic winner, and interrupted key stages recover idempotently under concurrent readers. Vault authentication includes the canonical path and physical scope-directory identity. Runtime revisions include inode, time, link, permission, and ownership changes. Existing key and vault permissions fail closed on POSIX and Windows, and write preflight rejects encoded plaintext over 6,291,402 bytes before encryption.
  • SDK hosts can set globalConfigRoot in createAgentSession() to isolate the cross-profile vault and machine key from the host user's Veyyon data. The omitted default remains getGlobalConfigRootDir(). Secret runtime setup now captures one root for creation and refresh, and records the vault revision after key initialization so the first provider attempt does not misread key creation as an external vault change.
  • Session loading now refuses a non-empty transcript with a corrupt header without rewriting it. Recoverably malformed later records produce one content-free operator notice with their path, line, byte offset, and shape problem. Working-directory and session-file moves are transactional through file, memory, Redis, and SQL-backed storage, include artifacts, roll back after a failed final header write, and participate in global listing and orphan-backup recovery without requiring a local mirror.
  • Concurrent SDK sessions now own independent secret-notice registrations, so disposing one cannot detach another's warnings. Vault add and authenticated-load boundaries reject ill-formed UTF-16 without replacing ciphertext. Runtime expiry notices distinguish immediate in-memory revocation from the encrypted entry that a later successful vault refresh prunes.
  • Default Effort treats any explicitly stored defaultEffort object, including {} or model-only rows, as authoritative over the retired global value. Scoped model cycling re-reads current per-model defaults unless a selector carries an explicit suffix; alias suffixes replace stored suffixes; role displays resolve the concrete model row; and CLI selector effort no longer leaks into later models as a session override.
  • ACP and text dispatch reject argument tails for commands that do not accept them while /model retains its argument. Extension and custom commands accept every canonical whitespace separator. Compaction dividers and modal selector footers show live remapped keys and omit unbound actions, and extension headers no longer retain a gap for an empty preset icon.
  • Bold, italic, underline, strikethrough, and inverse attributes no longer disappear when color detection is disabled or unavailable. The theme emits its attribute-specific SGR pairs directly, so markdown emphasis, reasoning text, selected tabs, and wrapped inverse diffs remain readable without relying on color support.
  • Compaction retry messages marked queueOnly remain in the user queue rather than starting an idle turn. Image-bearing skill retries preserve their user attribution and queue label after canonical image normalization.
  • Secret runtime refresh now follows the session manager's authoritative working directory. A request that retained an older scope cannot supersede an in-flight move back to that scope, while unchanged vault revisions reuse the exact existing lease. SDK disposal now shares one transaction across concurrent callers, forwards the first caller's shutdown options, and detaches listeners and registries even when audit flushing fails.
  • A misspelled key in a model's compat.reasoningEffortMap is now reported when the config loads instead of being accepted and ignored. The map remaps a thinking level to whatever string a given OpenAI-compatible server calls it, so every key names a level; a key that names none, hihg for high, validated, was carried into the config, and then never matched, so the remap silently did not happen and the level went to the provider verbatim. That is the exact failure the map exists to prevent, and nothing about it was visible: the config loaded, the request succeeded, and the effort was wrong. The schema rejects undeclared keys and names the offending one.
  • The models-config schema no longer keeps its own copy of the effort ladder. EFFORT_ORDER is THINKING_EFFORTS from @veyyon/catalog/effort, and the literal union EffortSchema still spells out (ArkType infers a literal union only from a literal definition, and generating it would infer as string, leaving every defaultLevel, minLevel and maxLevel unchecked) is verified against the owner when the schema is built. Adding a level to the ladder and forgetting this file used to produce no build error and no runtime error either: the new level was rejected as unknown, so the failure arrived as a validation message against the user's own config file.
  • The Unix installer says curl is required and is not installed instead of blaming GitHub. Every fetch it makes is curl, so on a machine without it the first request failed exactly the way an unreachable host does and the install died with "could not reach https://github.com/santhreal/veyyon/releases/latest (network error, or GitHub is down)" while the network was fine. The user then goes looking at DNS, a proxy and a firewall for a missing package. Minimal container images and stripped CI runners are where this happens, and they are also where people reach for wget -qO- ... | sh, which is how you get here with wget present and curl not. The check runs before any install path and names the package manager command; an uninstall does not need curl and is not gated on it.
  • One malformed line in a session file costs its own turn, not the whole transcript. An assistant record written without its usage field threw while the transcript was being BUILT, so the viewer died in its constructor: no rows at all, and nothing on screen saying why. The loader was lenient about lines it could not DECODE and blind to lines that decoded to the wrong SHAPE. Both read paths, the ordinary one and the streaming one that takes over at 8MiB, now check a record against what the readers actually dereference before handing it over, and report what they dropped and why. A rejected record is never repaired: a turn that claims 0 tokens it did not use is a wrong number in the transcript and in every total taken from it, and nothing on screen would ever say it was invented.
  • The Comms stream names agents the way the Live roster does. It printed the raw ids the message bus records, so a room view whose whole point is that you follow a conversation by who is speaking showed 0-Sub → 1-Sub: a pair of spawn-scoped tokens you had to look up on the other view, which is exactly what call signs exist to replace. Both ends now print the call sign, and an agent that has since been released prints its id, because two different departed agents reading as one placeholder is worse than an id.
  • The scroll wheel moves the card. A wheel report over it was decoded, matched against no chrome, and consumed anyway, so the scroll neither reached the card nor fell through to anything else: a roster longer than the card could only be moved by keyboard, and the wheel read as broken rather than unsupported. It now moves whatever the arrow keys move, the roster cursor on Live and the stream on Comms.
  • You can click a roster row to open that agent, and click a name in the view strip to switch views. The card was keyboard-only inside its own borders: every mouse gesture that did anything belonged to the shell around it (the close glyph, a footer chip, a click outside to dismiss), so a row drawn with a cursor on it that did nothing when clicked read as a broken control rather than a keyboard-only one. A row click OPENS rather than only selecting, because the row's one action is "open this agent" and asking for a second gesture to do what the first already said is the friction; Esc in the agent's session returns you to your own, so nothing about it is one-way. A click on the blank space under the last agent, on the chrome, or in the Comms stream does nothing.
  • The card stays readable on a terminal that renders no colour. The selected roster row and the active view tab were each marked by a background tint and nothing else, and a tint is not applied at all under NO_COLOR, on a dumb terminal, or in a piped capture, so on those you could not tell which agent enter would open or which view you were looking at. The selected row now carries the same nav cursor glyph every other picker in Veyyon draws, in a slot inactive rows reserve. The active tab uses brackets while inactive tabs reserve the same width. It also takes the shared overlay tab theme for bold and tint when the terminal permits them.
  • IrcBus.send records every leg it attempts, including one that throws. It logged on the paths that RETURN a receipt, which is every failure the bus knows how to describe, but the registry read that opens a delivery, the waiter hand-off and the mailbox enqueue all sit outside a try. A collab guest's registry is a mirror of the host's and can fail on a read, and a throw there left a message that was really sent absent from the Comms view, which reads as a message nobody sent. The throw is recorded as the failure it is and then rethrown unchanged, since the log is a display feed and must not change what the caller sees.
  • display.subagentInbox is removed, along with the layout behind it. The Subagents settings tab already owned the per-agent table the /agents configuration list duplicated, so nothing is lost with it: /settings is where you decide whether an agent is offered and what model it runs on, and /agents is the live picture.
  • Asking which slash commands an ACP or RPC client can drive no longer loads what they do. Three places needed the same answer, and all three got it by reading command.handle !== undefined off the assembled registry: the command list advertised to a client, the reserved names that stop an extension shadowing a builtin, and the available-commands list. handle is a function, so reading it meant loading all 67 handler bodies. A command now DECLARES textMode beside its name, and the handler table is typed against that flag: a command with textMode must supply a text handler and one without it may not, so the flag cannot drift from the fact it stands for. slash-commands/available-commands.ts went from 959 modules to 192, and the new slash-commands/text-mode-builtins.ts answers all three questions in 4. Dispatch stays in slash-commands/acp-builtins.ts and still loads the handlers, because it runs one.
  • Knowing that a slash command exists no longer means loading what it does. The builtin registry held 67 objects, each carrying a command's name, aliases and description next to the handler body that implements it, and a handler body reaches the model resolver, the collab host, the OAuth providers and the session store. The names now live in slash-commands/builtin-declarations.ts, which reaches 3 modules, and the registry attaches handlers to them through a table keyed by the declared names, so a handler for a command that does not exist and a command with no handler are both compile errors rather than something a test has to notice. The extension loader, which imports the reserved names and nothing else, went from 945 modules to 178, modes/runtime-init.ts from 947 to 219, and veyyon -p from 949 to 221.
  • An apply_patch streaming preview shows its file path instead of the wire handle. The fields a renderer reads mid-stream are listed per tool name, but the renderer they feed is bound to more than one name: apply_patch is the same object as edit, and it had no list, so it fell through to a regex that slices the path out of the raw JSON buffer. That buffer is deliberately left unexpanded, since a handle can expand to text holding a quote or a newline and splicing it in would corrupt the JSON the next frame parses. The two names now share one list, so the heading reads the real path for the whole call rather than after the first full parse, which for a long patch is most of it.
  • Four more modules name the module that declares the function they wanted rather than the @veyyon/ai entry point, which re-exports the whole package and reaches 363 modules. config/api-key-resolver.ts wanted isUsageLimitOutcome, a predicate over a status code that lives in a module with no imports at all, and went from 364 modules to 42; commit/shared-llm.ts from 368 to 112; mcp/manager.ts from 613 to 498; web/search/providers/perplexity.ts from 372 to 327. The two spellings differ by one keyword, since import type is erased and free, which is why this kind of edge accumulates without anything failing.
  • A streaming tool-call preview shows the text a handle stands for. Argot replaces long repeated strings on the wire with §handle fragments and expands them just before a tool runs, so for the whole time a call streamed the preview drew §db where the file body belonged. The codec now reaches both the live reveal and the rebuilt one, and expansion happens on decoded VALUES rather than on the partial JSON they came from: a handle can expand to text containing a quote, a newline or a backslash, and splicing that into the buffer would corrupt the JSON the next frame has to parse. A custom tool's stream is raw text rather than JSON, so both its fields expand. With argot off, which is the default, every path is byte-identical.
  • The session backup name is written and read through one owner. session-storage.ts moves a transcript aside when it cannot rename over it, and session-listing.ts had hand-rolled the inverse parse to recover sessions stranded by a crash between the two renames, one file away from the template it was inverting. The gc glob and the two listing filters that exclude backups now spell the suffix once as well.
  • Declaring the web-search tool no longer loads the credential store. web/search/index.ts is the module eighteen providers sit behind, and it imported discoverAuthStorage statically from a module that reaches the auth broker client, the remote store, the snapshot cache and the SQLite credential store: 347 modules for a function that only runs when a search executes. It is loaded on demand inside the three call sites now, which were already async, and the file went from 517 modules to 252.
  • The usage CLI, the models CLI, the token command and the plugin doctor no longer import the whole application to find out where credentials live. discoverAuthStorage is declared in session/auth-broker-config.ts and re-exported from sdk.ts, and for those four it was the only name they wanted from the barrel; all four are now clean of it.
  • Two more values are read from the module that declares them rather than through a barrel. modes/session-observer-registry.ts subscribes to two task event channels by name and had taken those two strings from the ../task barrel, 1,406 modules to know what a channel is called; it names task/types.ts now and reaches 24. The slash-command browse order moved to slash-commands/category-order.ts, because reaching it through the builtin registry meant importing every command implementation, and the autocomplete that arranges menu headers wanted nothing else from there.
  • The session transcript extension and the advisor transcript name come from @veyyon/utils/session-file. The session manager, session listing, the gc CLI, HTML export, the Agent Hub, the debug bundle, the history:// registry helper, the task executor and the read tool had each spelled .jsonl themselves, in four incompatible forms: a string, a length constant, path.basename(file, ".jsonl") which also strips the directory, and slice(0, -6). The gc CLI's globs and its compressed-session suffix now derive from the extension, so changing it moves the archive form too.
  • Reading a setting no longer means importing the module that writes it. config/settings-instance.ts owns the process-global slot, the settings proxy over it and the test-reset registry, at one module and with no runtime imports; config/settings.ts fills that slot and re-exports the names callers already use. Thirty-one modules imported the 95-module store and used nothing but a value: the vault URL handler asked whether the vault is enabled and paid 32 marginal modules for the question, modes/theme/markdown-theme.ts imported it to register a test-teardown hook, and tools/output-meta.ts reached it for getDefault, which the schema owns and the store only re-exports. The settings store is now off the graphs of tools/read.ts, tools/fetch.ts and web/search/index.ts, and off every internal-URL handler.
  • Settings.init() records the promise that fills the singleton slot rather than the bare load it derives from. The bare load settles first, so a second caller that joined it could resume from await Settings.init() before the slot was filled, see isSettingsInitialized() return false in a process that was initialising correctly, and fall back to a default with nothing thrown.
  • web/search/providers/perplexity-auth.ts re-exports its published OPENROUTER_BASE_URL from @veyyon/catalog/provider-endpoints instead of declaring the host itself.
  • Four values that one module produces and another matches now have one owner each: the legacy shim's __isToolDefinition marker, the default plan file URL, the MCP protocol revision, and Anthropic's web_search tool name. Each was declared on both sides of the boundary, and a drift in any of them is silent. The plan URL was the worst: nine spellings, four of them inline in session/agent-session.ts, which is the module that decides it. Plan protection compares an edit target against that URL, so a drift meant plan mode reporting that it was protecting the plan file while an edit to that exact path went through.
  • The Gemini web-search provider reads the developer API base from @veyyon/catalog/provider-endpoints. Its copy was the third spelling of a URL whose /v1beta segment every consumer has to agree on.
  • tools/eval.ts no longer carries the module that draws eval results. It runs language kernels, and it had two edges to tools/eval-render.ts, which brings Markdown and Text from @veyyon/tui, the theme engine, the markdown theme and the settings store with it: one imported upsertStatusEvent, a ten-line array helper now living in src/eval/status-events.ts beside the event type, and the other re-exported the renderer for consumers that did not need the indirection. modes/components/tool-execution.ts had been importing the whole tool to read a preview-line count and now reads it from the renderer. The tool went from 801 modules to 638.
  • The Perplexity search provider reads its client identity from @veyyon/catalog/wire/perplexity, including the version field of the ask request body, which is the same value the X-App-ApiVersion header carries and was the copy furthest from the header it has to agree with.
  • browser-headers.ts states the Chrome version it claims once, and exports CHROME_DESKTOP_USER_AGENT and CHROME_WINDOWS_USER_AGENT derived from it. The Sec-Ch-Ua client hint and the User-Agent had each spelled the version as a literal, and a fingerprint whose two version claims disagree is what a bot check compares. web/scrapers/types.ts used Chrome 131 as the last rung of its bot-block escalation ladder while this module claimed 149, so the attempt that has to get through announced the stalest browser of the three; it now reads the shared Windows User-Agent.
  • The Codex web-search provider reads the account id out of a token through @veyyon/catalog/wire/codex. It had declared its own copy of the claim namespace while already importing three other constants from that exact file in the same import statement.
  • Reads BEL, SGR_BG_RESET, SGR_INTENSITY_RESET and OSC66 from @veyyon/tui/ansi instead of redeclaring them. \x07 had three local names, BEL in tui/hyperlink.ts, OSC_TERMINATOR_BEL in utils/enhanced-paste.ts and SIXEL_END_BELL in utils/sixel.ts, so the sixel scanner and the paste decoder each decided independently what closes an OSC sequence. modes/theme/shimmer.ts called \x1b[22m BOLD_CLOSE and modes/components/diff.ts called it DIM_OFF, and it is both: cancelling dim inside a bold run also cancels the bold.
  • A long-running eval cell no longer grows its retained output without limit: capExecutionOutputLines in modes/components/execution-shared.ts bounds it at five screenfuls, the same bound the bash block already had.
  • An execution block now says how many output lines it dropped while streaming, in its own note, instead of folding them into the "… N more lines (ctrl+o to expand)" hint. The bash block used to drop the oldest lines and then compute that hint from the already-trimmed buffer, so a five-thousand-line run reported eighty hidden lines and expanding revealed a hundred. The two are different facts: hidden lines are still held and expanding reveals them, dropped lines are gone.
  • The bash and eval execution blocks share one output-line clamp, clampExecutionDisplayLine in modes/components/execution-shared.ts, and it measures terminal columns. Both components declared MAX_DISPLAY_LINE_CHARS = 4000 and both had a private #clampDisplayLine, but bash measured visibleWidth and eval measured line.length, so one named limit meant two different things. Eval's half charged a syntax-highlighted line for the ANSI escape bytes a user cannot see and truncated it while it still displayed far short of the limit, let a wide-character line through at twice the budget because it counted a two-column character as one, and cut at a code-unit offset that can land inside an escape sequence or a surrogate pair. The column measurement is the correct one and is now the only one. The twenty-row preview height both components declared is shared as EXECUTION_PREVIEW_LINES.
  • The three option labels the ask runtime adds to a question (Other (type your own), Chat about this, Next →) are declared once in tools/ask-option-labels.ts, together with the reserved-label predicate. They were declared in three modules under two sets of names, and each is compared by string equality to decide behaviour, so a drift between the module that renders a label and the module that compares it does not fail loudly: the branch never runs and the label is handed back to the model as though the user had typed it. A user who picks the free-text option to answer in their own words gets no prompt, and the model is told their answer was the words "Other (type your own)".
  • The agent surfaces take their three shared interaction timings from modes/components/agent-view-timings.ts. The Agent Hub and the Subagent Inbox had each declared AGE_TICK_MS, DATA_CHANGE_RENDER_COALESCE_MS and LEFT_TAP_WINDOW_MS with the same values, and the inbox's comment on the gesture window said "matching the hub", which names the coupling without doing anything about it. Both views were then replaced by the Agent Control Center and the coupling outlived them: the card owns the age tick and the coalesce window, the input controller owns the double-tap window for the gesture that opens the card, and its own 500ms literal is gone. Drift there is felt rather than abstract: a relative-time column refreshing at two rates, or a gesture needing one rhythm to open a view and another to leave it, which reads as the gesture not working. The owner is a leaf with no imports, deliberately NOT agent-status-display.ts, whose doc makes it the owner of the AgentStatus visual language and which imports the theme engine to do that.
  • The collab relay client takes the fatal close codes and the reconnect send bound from @veyyon/wire/relay. Both were declared here and again in @veyyon/collab-web's browser client, character for character; an unlisted code is transient by definition, so a fatal code that only one client knew about would have made the other reconnect forever against a condition that will never clear.
  • Every eval kernel takes its shutdown grace, its interrupt escalation window and its two naming conventions from eval/kernel-base.ts, which already owned the startup floor for exactly this reason. SHUTDOWN_GRACE_MS = 1_000 was declared in all three language kernels and a FOURTH time in the Julia executor's session reset, so a language given a longer shutdown in its kernel would still have been killed at one second when its session was reset; INTERRUPT_ESCALATION_MS = 5_000 was declared three times, though it describes a user's patience behind Ctrl-C rather than anything about an interpreter. The two conventions matter more than the duplication: VEYYON_<LANGUAGE>_IPC_TRACE is a name a user types and each kernel formatted its own, so nothing stated the convention and a fourth language had no reason to follow it, and the same went for <tmpdir>/veyyon-<language>-runner. Both are now helpers (kernelIpcTraceEnvVar, kernelRunnerCacheDir), the path one joining with path.join as the three call sites did rather than with a slash. VEYYON_RUBY_IPC_TRACE and VEYYON_JULIA_IPC_TRACE existed and were undocumented; both are in the environment-variable reference now, with the convention stated.
  • The Antigravity endpoint switch in session/agent-session.ts reads its two hosts from @veyyon/catalog/provider-endpoints instead of spelling them inline, and tools/image-gen.ts, web/search/providers/gemini.ts, session/agent-storage.ts, session/history-storage.ts, mcp/oauth-flow.ts and modes/components/custom-editor.ts each stopped declaring a value another package owns: the Google Cloud Code and Antigravity hosts, the SQL now-in-seconds expression that stamps the history and model-performance tables, the default OAuth callback path, and the bracketed-paste markers. Behaviour is unchanged; each value now has exactly one place a change to it can be made.
  • Twelve modules stopped importing the @veyyon/utils barrel whole for one or two names, which takes the barrel off tools/read.ts's graph entirely. The barrel is 81 small leaf modules, and an edge to it only leaves a closure when the LAST path does, so this had to be all twelve at once: internal-urls/vault-protocol.ts wanted $which, lsp/utils.ts wanted truncate, tools/render-utils.ts five formatters, web/scrapers/types.ts one. Each now names its owner (@veyyon/utils/which, /format, /byte-truncate, /path-tree, and so on), with the name-to-owner map read off the barrel's own re-export list. Measured: session/session-context.ts 130 -> 82, session/messages.ts 122 -> 74, tools/render-utils.ts 177 -> 139, internal-urls/index.ts 231 -> 205, tools/read.ts 468 -> 449, and 822,349 module instantiations across the test suite.
  • Rendering a code cell no longer loads the theme engine. modes/theme/theme-binding.ts exists so a module can read the ACTIVE theme without importing the loader that sets it, and its doc warns that a value import of modes/theme/theme puts the engine back in front of every reader. That is what had happened one function away: modes/theme/markdown-theme.ts took getSymbolTheme from the engine for one field, and tui/code-cell.ts took highlightCode from it, which the engine merely forwards from modes/theme/highlight (24 modules against 282). A box-drawing character set and a syntax highlighter were carrying theme JSON loading, the hundred embedded theme modules and mermaid rendering into every rendered cell, and through tools/read.ts into every file read. getSymbolTheme now lives in modes/theme/symbol-theme.ts, a two-module leaf beside the binding, re-exported from the engine so the eight callers that already reach it are unchanged; nine modules that render code name the owner of what they use. Measured: modes/theme/markdown-theme.ts 319 -> 175, tui/code-cell.ts 327 -> 220, tools/read.ts 648 -> 542, modes/components/diff.ts 288 -> 181. Three of the nine did not move on the first pass, because the engine also arrived through a different import, and those paths were four hops of forwarding: tools/bash.ts and tools/write.ts took three names from the local tui barrel, which export *s tui/file-list.ts, and that module took getLanguageFromPath from the engine rather than from the one-module table it lives in; modes/components/eval-execution.ts took its symbols through modes/components/execution-shared.ts. With those repointed too: tui/file-list.ts 289 -> 180, the local tui barrel 352 -> 246, tools/bash.ts 504 -> 353, tools/write.ts 536 -> 386, modes/components/eval-execution.ts 299 -> 193, and 832,035 module instantiations across the test suite instead of 859,485.
  • The prompt registry now holds one row module per prompt directory, and prompts/registry.ts aggregates them. It held all 163 import ... with { type: "text" } specifiers itself, so importing it to read ONE prompt reached all 163: a tool renders its own description from a row, and tools/read.ts paid 167 modules for that one string. 95 files in this package had the same edge. Each of the twenty-one directories now owns its rows in <directory>/rows.ts and consumers take the directory they belong to (toolsPrompts["tools/read"]), which is 51 modules for tools/ and 3 for steering/. PROMPTS, PromptId, PROMPT_IDS, promptText, requirePrompt and codingAgentPrompts are unchanged, so no consumer had to change; the three modules that genuinely span directories still take the aggregate. Measured: tools/read.ts 761 -> 647, session/steering-envelope.ts 216 -> 86, and 859,485 module instantiations across the test suite instead of 893,359. The coverage gate now checks the same invariant one level deeper: every .md is imported by exactly one row module, every row module is aggregated by registry.ts, and nothing else in the repository may import a .md.
  • Two re-export shims stopped naming the @veyyon/utils barrel. utils/fetch-timeout.ts forwards five timeout helpers and web/search/utils.ts forwards collapseWhitespace, and both took them from the barrel, so a shim whose whole content is a re-export list put 82 modules on its callers' graphs. They now name @veyyon/utils/scoped-timeout, /abortable and /collapse-whitespace. Every web-search provider is 53 modules cheaper (web/parallel.ts 217 -> 164) and both shims are 2 modules.
  • --ref 1.0.37 installs the same release as --ref v1.0.37 on both installers, instead of refusing a version that exists. Releases are tagged with a leading v and the bare version is what people type, so the old refusal stated a true fact ("release tag not found: 1.0.37") and left the user to guess which of the two spellings this project uses. The tag you named is looked up first and the v form second, and the installer prints resolved 1.0.37 to the published tag v1.0.37 before it downloads anything, so the version being installed is the version on screen rather than something inferred quietly. Only a string that reads as a version gets the second lookup: a branch or a commit is not a version, vmain is a tag nobody has, and a wider search would risk installing a version the user never named. 8 assertions per installer, including that a branch, a commit and a missing v-tag each cost exactly one lookup.
  • The Windows installer stops opening with "Restart your terminal" when the terminal does not need restarting. The documented install is irm https://veyyon.dev/install.ps1 | iex, which executes in the caller's own session: the installer sets $env:Path there, so veyyon works in that window immediately. Leading the closing steps with a restart was both untrue and the first thing the user read. Run as pwsh -File install.ps1 the installer is a child process whose $env:Path dies with it, and the restart is real, so that form still leads with it. $PSCommandPath tells the two apart: a script invoked from a file knows its own path, and code handed to Invoke-Expression as a string has none. Either way the block still says that terminals open elsewhere pick up the new PATH entry when they restart, because that is true in both cases and is a note rather than a step.
  • VEYYON_INSTALL_DIR with a trailing slash installs to the same place as one without, instead of leaving a PATH entry the installer cannot recognize as its own. install_dir() returns the path unchanged, and everything downstream compares it as a string: the membership test asks whether ":$dir:" appears in $PATH, and the uninstall matches the rc line back byte-for-byte. So VEYYON_INSTALL_DIR=$HOME/.local/bin/ wrote an entry that the next run did not recognize, added a second one, and then left both behind on uninstall. Trailing slashes are stripped now, / excepted since there the slash is the directory, and a HOME spelled with one no longer builds /home/you//.local/bin. This is the POSIX half of the same rule Get-NormalizedPathEntry applies on Windows; environments.toml gained a case that installs into the trailing-slash spelling for real and runs the whole contract against it.
  • The Windows installer recognizes a PATH entry it wrote even when Windows hands it back quoted or padded, so a reinstall no longer appends a second copy of the install directory and an uninstall no longer strands one. A real %PATH% is not a clean list: entries arrive wrapped in double quotes, which is legal and is what installers write around a path containing a space, and padded with spaces, which PATH=%PATH%; C:\tools leaves behind. The presence check compared raw strings with only a trailing backslash trimmed, so "C:\Users\you\.veyyon\bin" did not match C:\Users\you\.veyyon\bin and every re-run added the directory again. Get-NormalizedPathEntry is now the single owner of what makes two entries the same directory, and the add, the presence check and the removal all go through it, so they cannot disagree in the direction where one adds an entry the other cannot find.
  • The startup update check no longer calls the GitHub API, so several agents behind one address can no longer lock each other out of updating. api.github.com allows 60 requests an hour per IP without a token, and that budget belongs to the address rather than to the process: an office, a CI fleet or a container host running several sessions spent it on launches alone, and every machine behind that address then reported that it could not check for updates, on a machine where nothing was wrong. The version now comes from where https://github.com/santhreal/veyyon/releases/latest redirects to, which is not on that budget, matching what the installer already does. It is a HEAD request with redirect: "manual", so the release page's own body is never downloaded either. The rollback picker still reads the API, deliberately: a list of every published version has no redirect equivalent, and it runs when someone opens the picker rather than on every launch.
  • The installer no longer calls the GitHub API, so installing repeatedly from one address cannot fail on a rate limit. api.github.com allows 60 requests an hour per IP without a token, and that budget is shared by everyone behind the same address; the release lookup spent one of them on every install, so a CI fleet, an office NAT or a container host doing a few dozen installs in an hour started getting 403 on a machine where nothing was wrong. An adversarial matrix of 39 installs from one container hit exactly that, six times, on an installer that was working perfectly. The newest tag now comes from where github.com/santhreal/veyyon/releases/latest redirects to, and a --ref is checked against its tag page, both on the same host the binary is downloaded from and neither counted against the API. GITHUB_TOKEN and GH_TOKEN are no longer read, because there is no longer a limit to raise.
  • Neither installer downloads the release page it does not read. The tag is in the URL the /releases/latest redirect lands on, and a --ref check only asks whether a page is there, so both requests fetch headers only. A GitHub release page is a few hundred kilobytes, transferred and discarded on every install.
  • The Windows installer works on Windows PowerShell 5.1, which is what irm https://veyyon.dev/install.ps1 | iex actually runs under on a stock Windows box. Three of its defaults broke an install that was otherwise fine. Its SecurityProtocol still includes SSL 3.0 and TLS 1.0, and GitHub has required TLS 1.2 since 2018, so every request failed with "The request was aborted: Could not create SSL/TLS secure channel", an error that names nothing about the real cause; TLS 1.2 is now added to whatever the machine already allows, never assigned over it. Without -UseBasicParsing it hands each response to Internet Explorer's parsing engine, which is absent on Server Core and refuses to run wherever IE's first-launch configuration was never completed, so the download failed with a message about a browser the install never mentioned. And its progress bar repaints per read on a synchronous download, which on a 300 MB binary dominates the transfer; it is suppressed for the download and restored afterwards. The release lookup no longer depends on how the two PowerShells each treat an unfollowed redirect either: it asks HttpWebRequest with redirects off, which answers the same way on both.
  • The doc-path gate stopped calling build outputs rot. A doc that says where the build puts something names a path no clean checkout contains, and the gate reported five of those as docs pointing at nothing: the benchmark's runs/ and repo-cache/, napi-rs's .build/, and both references to tool-views.generated.js. It asks .gitignore now, because the repository already answers "is this generated?" in exactly one place and a second list kept beside it would drift the first time a build output moved. Ordinary rot still fails, and the ratchet baseline is untouched: that list is a promise to remove a dead path, and these paths are alive. Three docs that pointed at brand notes which are local to one machine and never distributed now name the shipped conformance test instead.
  • The Windows installer opens with the sun. install.sh grew the mark and install.ps1 did not, so the same product introduced itself two different ways depending on the platform. It is the same seven cells from the same owner: four bands of the ember ramp in sun.ts, drawn as lower blocks of rising height so the silhouette is a dome rather than a rectangle, and a parity test fails when the two stop agreeing about the color. Windows Terminal gets the colored form; the legacy console host renders those glyphs as mojibake whatever color they are, so it gets the plain ASCII one, and a pipe gets nothing at all. It is not printed over an uninstall.
  • Uninstalling says that the shell it ran in has not caught up yet. A profile is read when a shell starts, so the shell running the uninstall keeps the PATH entry the uninstall has just deleted from the file, and bash and zsh also cache where they last found a command they have run. Typing veyyon straight afterwards answered "No such file or directory" for a path the user can plainly see is gone, which reads as a half-finished uninstall rather than as a shell that has not caught up. The verdict is followed by the reload command, and only when a PATH line was actually removed. Windows says the same thing for the same reason: a PATH entry lives in the registry and reaches a process when that process starts, so every terminal already open still holds the entry the uninstall removed.
  • The installer's closing steps put the shell reload ahead of the command that needs it. A shell profile is read when a shell starts, and the shell running the installer has already started, so an install that had just added ~/.local/bin to that profile then opened with "1. Launch in any repository: veyyon" — the first thing a new user types after a successful install, answered by command not found, which reads as a broken install rather than as a shell that has not caught up. The reload is a numbered step now, first, naming both exec $SHELL -l and the profile to source instead, and the rest renumber behind it. An install onto a directory that was already on PATH adds no such step, because there the command really does work immediately. The Windows installer prints the same block: it had three copies of a one-line closing message, one per install mode, each naming vey unconditionally even on a machine where the installer had just declined to create it because the user owns that command, and none of them mentioning setup or --help at all.
  • The PATH line the installer writes quotes the install directory, so a home directory whose name contains $, a backtick or a backslash no longer expands when your shell profile is sourced. export PATH="$dir:$PATH" put the directory inside a double-quoted string, where the shell expands what it finds: an install under /home/a$PATH/bin wrote a line that on the next login expanded $PATH inside the directory NAME, put a nonsense entry on PATH, and left the user reading veyyon: command not found in a shell whose profile plainly named the right directory. The directory is single-quoted now, with any literal quote in it escaped, and $PATH itself stays outside the quotes where it still has to expand. Uninstall matches the older double-quoted spelling as well as the current one, because every install already in the wild wrote the old form and matching only the new one would strand that line in the profile forever.
  • Every version this install moves to is recorded, not only the ones it moved back to. recordVersionMove was called from exactly one place, rollbackToVersion, so the history file held rollbacks and nothing else: an update from 1.0.30 to 1.0.37 left no trace, and the rollback picker reads that history to mark rows "previously run", which made the version a user is trying to get back to the one guaranteed not to be marked. The recording moved into installRelease, the single owner of the install-method dispatch and therefore the one function every move goes through, so veyyon update, the background automatic update and veyyon rollback all record through it. A forced reinstall of the version already running is not a move and is not recorded, since a history of 1.0.37 -> 1.0.37 rows describes nothing and would push the real moves out of view.
  • veyyon update and veyyon rollback shipped as two halves of one mechanism and neither command named the other. A failed update now points at veyyon rollback, and only where it applies: a source install cannot be rolled back, so the line is suppressed there rather than sending someone to a command that refuses. update --help gained a "Going back:" section. And a successful update prints the changelog for the version it installed, which rollback already did, through the same single changelogUrlForVersion owner so the two commands cannot point at differently-shaped links.
  • veyyon rollback --list no longer pads its lines out with trailing spaces. The columns are padded so they line up, which left whitespace at the end of the header and of every row with no marker: invisible on screen and real in a file, and that output gets pasted into bug reports and piped into diffs, where a line ending in three spaces does not match the same line typed by hand.
  • The install opens with the sun. It is the logo everywhere else — the setup splash, the website, the login page — and the first thing anyone ever saw of veyyon was a line of lowercase progress text. One line now, printed before anything happens: a small ember dome rising over its own horizon, then the name letterspaced in silver, which is the order the setup splash reveals them in. modes/components/sun.ts stays the single owner of the brand ember; the shell quotes its bands and scripts/installer-brand-parity.test.ts fails if the two drift, because two shipped suns that disagree about the brand color are worse than one plain line. Nothing is printed into a pipe or a log, a terminal without color or without a UTF-8 locale gets an ASCII mark instead of mojibake, and an uninstall gets no mark at all: a logo over a removal reads as a sales pitch at exactly the wrong moment. The shape was arrived at by rendering it: shading it with the owner's ░ ▒ ▓ ramp the way the TUI does washed out to a grey swatch, because a terminal draws stipple as a dot pattern that averages to grey over seven cells, and solid blocks fixed the color but left something that read as a progress bar. Lower blocks of rising height are what make it a sun.
  • The installer says what it is doing in color, wraps what it says, and shows the download moving. It rendered in the same monochrome as any package manager, so "Installation complete." looked exactly like the twelve progress lines above it and the one warning worth reading looked like every ok around it. The status glyph now carries the color and the message does not; progress narration is dimmed so the lines recording what the installer DID are what the eye lands on; and the binary download shows curl's progress bar rather than sitting silent for a minute on a slow link, which read as a hang. Long messages wrap on word boundaries with a hanging indent, and a message that indents itself keeps that indent, so a warning longer than the terminal is no longer broken mid-word with its tail starting at column 0. All of it is off unless stdout is a terminal, NO_COLOR is unset and TERM is not dumb, so piped output and CI logs keep the exact bytes they had. Two bugs found while building it: the width lookup asked [ -t 1 ] about its own stdout from inside a command substitution, where the answer is always "no terminal", so wrapping was disabled on every terminal there is; and tput cols answered a literal 0 in a container, which a "did it print anything" check accepted as a width. The terminal question has one owner now, and a width must be digits and at least 24 before it is believed.
  • get.veyyon.dev redeploys when the installer changes, and the endpoint is now verified by content rather than by shape. It is a separate Cloudflare Pages project fed by a separate tree, and site.yml — which triggers on scripts/install.sh — deployed only the marketing site, so an installer change updated the documentation about the installer and left the installer itself on whatever the last release had published. The release job's verification could not see that: it grepped the served body for #!/bin/sh, which is true of every install.sh ever written, so it reported OK while the endpoint served a script hundreds of lines behind main. Everything added in between was unreachable to users, including the alias-clobber protection that stops the installer overwriting a vey of their own, the uninstall that takes its own PATH line back out of a shell rc, and the check that skips a redundant PATH edit when the directory is already on it. site.yml deploys both trees now, and both workflows end with scripts/verify-deployed-installers.ts, which compares sha256 digests of all three documented endpoints against the files in scripts/, retries for propagation, and reports a stale deploy differently from a broken root rewrite. Found by installing from get.veyyon.dev in a clean container and comparing what arrived against what the repository ships.
  • The installer's closing advice no longer promises a diagnostic it does not run. Step 3 read "Run system diagnostics: vey plugin doctor", and there is no doctor command: plugin doctor reports on plugins alone, and on a fresh install prints three plugin slots all "not created yet", so a user following the installer's own third step was told they were checking their system and shown a report about a subsystem they had never touched. The install's own doctor already ran eight lines above. Step 3 is now "See every command: vey --help". The website said the same thing twice ("veyyon plugin doctor checks install health" on the features page, "Health: veyyon plugin doctor" under Platforms on the install page) and now names plugin health, the installer's doctor, and veyyon setup status as three different checks. Found by installing from get.veyyon.dev in a clean container and reading what the installer said at the end.
  • apply_patch refuses a file-op marker that names no path. *** Delete File: trimmed to the empty string and was accepted, and an empty path is not an empty value: every op resolves against the working directory, so the delete targeted the cwd itself and the create wrote to a directory. All three markers now fall through to the same "is not a valid hunk header" error the parser already raises for an unrecognised line, which is what a marker with no path is. A streaming preview still tolerates it, because a partial buffer legitimately holds *** Add File: before the path has arrived and that parse never touches a file.
  • Resolving an internal URL no longer loads the streaming engine. session/session-context.ts took legacyArchiveSourceText from @veyyon/agent-core/compaction, and that subpath barrel re-exports the compaction engine, which imports the @veyyon/ai barrel to summarize a conversation. The function's owner, compaction/legacy-snapcompact-archive.ts, is a self-contained reader for a retired archive format and imports nothing at all. One specifier: session/session-context.ts 407 -> 131 modules, session/session-manager.ts 455 -> 179, and because the URL router reaches the session loader through the history handler, internal-urls/index.ts 497 -> 232 and tools/read.ts 918 -> 761.
  • Reading Settings no longer loads the compaction engine or the streaming engine. Four imports named a barrel where they wanted one value: config/compaction-strategy.ts took resolveThresholdTokens from @veyyon/agent-core/compaction, whose subpath barrel re-exports the summarizer; config/settings-domains/context.ts took the string AUTO_COMPACTION_THRESHOLD from the bare @veyyon/agent-core barrel, 406 modules for a sentinel; thinking.ts and config/model-resolver.ts took ThinkingLevel from the same barrel. All four now name their owners. config/settings.ts 442 -> 136 modules, config/settings-schema.ts 433 -> 58, thinking.ts 407 -> 7, and because settings is imported nearly everywhere, modes/theme/theme.ts 588 -> 282, tui/hyperlink.ts 497 -> 192, tools/fetch.ts 554 -> 369 and tui/code-cell.ts 633 -> 327 for free. The gate in test/architecture/leveraged-imports-stay-cut.test.ts asserted that settings reached neither @veyyon/ai/index.ts nor @veyyon/ai/stream.ts and passed anyway, because its resolution table did not know this workspace's own package names; it now derives the table from the workspace and every ceiling in it has been re-measured.
  • Resolving issue://123 or pr://7/diff no longer loads the github tool or the prompt corpus. tools/gh.ts held two things: the cache-aware issue, PR and PR-diff fetchers, and the GithubTool class with its 38 ops, run-watch poller, worktree-based PR checkout and four search renderers. The class renders its own description from the prompt registry, which is correct for a tool, so internal-urls/issue-pr-protocol.ts reached 355 modules to call six functions. From there it spread the way these always do: the router builds every handler, tools/read.ts consults the router because reading pr://7 is a real feature, and 54 test files import read. The fetchers now live in tools/gh-fetch.ts (81 modules) and the primitives both halves share in tools/gh-format.ts (4), with every name re-exported from tools/gh.ts, so no caller changed. The handler reaches 84 and internal-urls/index.ts 205, down from 418. The shared cache row is unaffected and still shared: open pr://7, then ask the tool for PR 7, and the second read is free because both call the same fetcher against the same SQLite row.
  • prompts/registry.ts takes definePromptRegistry from the module that defines it rather than from the @veyyon/utils barrel: 3 modules instead of 74. 94 files in this package import the registry, and its own code cost was almost entirely that one import.
  • The prompt inventory reports the real renderer of a prompt again. It finds one by matching a registry table indexed by an id, and the pattern accepted only SCREAMING_CASE table names, so splitting the registry into camelCase per-directory row tables made all 95 consumers invisible at once and the inventory claimed the system prompt was rendered by its own row module and two tests. The accepted camelCase names are derived from the registry's own directories rather than matched as any \w*Prompts, so an ordinary local named userPrompts is still not a registry.
  • tools.artifactSpillThreshold now reaches every tool it claims to. "How many bytes of tool output stay in the conversation" had two answers with the same meaning and the same value: this setting governed the centralised spill that runs after a tool returns, while every streaming tool priced itself against a compiled 50KB constant that nothing could reach. They agreed only because both happened to be 50KB, so lowering the threshold to 2KB moved the centralised path and left bash, eval, ssh and the interactive shell at 50KB. eval alone is about 80% of tool-result bytes, so most of the setting did nothing while reading as though it had been applied, which is worse than a knob that does nothing at all. inlineOutputPricing reads it for both paths now, so it also composes properly with tools.inlineOutputFloor, which is a SHARE of this budget: a quarter of 50KB and a quarter of 2KB are different budgets, and only one of the two factors had been settable. Nothing is lost by lowering it — output past the threshold is written as a session artifact and the result keeps a head/tail window plus the artifact://<id> footer that reads the full text back, so it costs a re-read rather than output. The 50KB exists once, as DEFAULT_ARTIFACT_SPILL_THRESHOLD_KB beside the floor default; DEFAULT_MAX_BYTES is that value in bytes, under the name every caller and tool doc already uses. A threshold that is not a positive finite number is refused with a log line and the compiled default, never silently corrected.
  • The bench's worked prompt-section arm did not load. arms/candidate-delivery-terse.sections.yml is the file an operator is told to copy, and its DELIVERY CONTRACT banner carried a two-character underline where the builder requires at least four, so the runner rejected it and every experiment derived from it would have failed once a container was already running. docs-coherence.test.ts had the check that catches this and the bench suite was simply red.
  • A misspelled --arms entry died with a raw ENOENT stack from the config read, which reads as a broken runner rather than as a typo and buries the only useful fact: what the arms are called. It now names the available arms and exits. The check is a pure function so it is unit-testable, since run.ts ends in a top-level await main() and importing it to test anything would run a bench.
  • "Which files in arms/ are arms" was answered in three places and one was wrong. docs-coherence.test.ts counted every *.yml, so candidate-delivery-terse.sections.yml became a phantom arm named candidate-delivery-terse.sections and every coherence check quantified over an arm nobody can run. There is now one owner, isArmConfigFile/armNamesIn, and run.ts refuses an --arms entry that names an attachment rather than parsing a section-override map as a config overlay and benching nonsense.
  • An append-mode section override silently reverted the rest of its section to the copy in system-prompt.md. Appending produces a whole-section override (base text plus the addition), and a whole-section override beats the statements by design, so whichever base the append started from became the section. It started from DEFAULT_TEMPLATE_SECTIONS, the copy sliced out of the template file, which meant a .veyyon/prompt-sections/role.md in append mode replaced ROLE with the template's version of it and kept only the operator's line as intended. Invisible today because that copy and the statements are byte-identical and asserted so, and it would have begun deleting statement edits the moment the two diverged, which is the first thing that happens when someone edits a rule. An append now appends to the ASSEMBLED section, so the base is what the session actually sends; an explicit replacement in the same override set still wins, and the template copy remains the base only for a section that is not assembled from statements. The regression test supplies a base that differs on purpose, because with identical copies no assertion can tell which one was read.
  • Two spacing defects in the shipped prompt, both from conditionals that leave an empty line behind. A one-line inline conditional such as - {{#has tools "ast_grep"}}...{{/has}} is not a standalone block-helper line, so Handlebars cannot remove it and its newline together; when the condition is false the line collapses to an empty line. Next to an existing blank that makes a run of two, which format deletes entirely, so a heading landed directly on the bullet above it. Alone inside a list it survived as a stray blank splitting the list. Statements have no empty line to leave behind, so the class is gone rather than reproduced, and every resulting delta is recorded per matrix point and asserted exhaustive in both directions.
  • TOOL POLICY sent delegated.- A subagent's value as one token to every non-Codex session with delegation required. A {{#has}} nested inside an {{#if}} across a line boundary jammed the close tags together with the bullet that followed. The statement rows put the bullet on its own line. The byte gate would have reported the repair as drift, so the template side of the comparison applies it explicitly and asserts it in both directions, and the affected matrix points are listed so a new one appearing fails the gate.
  • statement-registry.test.ts now exists. The registry's header had cited it as the enforcement for its own contracts for weeks, so the granularity rule, the closed condition vocabulary and the disjointness of gate variables from session facts were documented and unchecked. The disjointness check caught a real duplicate on its first run: hasSubagentSpecialists was declared a session fact while the subagent.agents gate row already claimed it.
  • Three of the thirteen rows in the prompt gate registry named template variables that do not exist, and the registry's own test protected the error. The rows are contracted to name the template variables a setting decides. subagent.maxConcurrency named taskMaxConcurrency, which is the builder option's name; the template is handed MAX_CONCURRENCY, so the row described a variable no conditional could read. includeModelInPrompt and includeWorkspaceTree each named themselves, as though the template contained a conditional on them, when both actually decide whether a runtime section is assembled at all. tools.intentTracing named only half of what it decides, leaving the intentField parameter name claimed by nothing. The consequence was not cosmetic: a statement's condition is validated against these rows, so a wrong row rejects a correct condition. Rows now name template variables only, a new field carries the runtime-section route for the two settings that gate a section, and a test cross-checks every row against the template. The old test required each gate to name a variable, which is precisely why two settings that gate sections stayed mislabelled.
  • The statement registry now reaches the model. conventions and role had been converted to statements and gated for byte identity, but nothing outside the tests imported the registry, so every session was still served the Handlebars copy and the tests looked identical either way. buildSystemPrompt splices the converted sections in through the section-override seam, ahead of your own overrides so a .veyyon/prompt-sections/ replacement still wins. Wiring it revealed a one-byte bug: the assembler followed the convention where a section separator sits between sections, while the template slicer keeps it inside, so the whole prompt came out two bytes short and the missing bytes were the blank lines before ROLE and RUNTIME.
  • An MCP server's error that names no request now reaches you instead of turning into a timeout. JSON-RPC lets a server answer with "id": null when it found the problem before it could read which request the problem belonged to, which is what a parse error is. Both streaming transports dispatched on the id being non-null, so such a reply matched no branch and was dropped in silence, and every call in flight waited out its own timeout and reported that the server had not answered. The server had answered. Now every call on that connection fails with the server's own code and message, for example MCP error -32700: Parse error, and the transport logs the server, the code, the message and how many calls it killed. Veyyon's own memory server emits exactly that shape, so this was losing parse errors between two parts of veyyon.
  • Disposing a session no longer lets one stuck subsystem leak the rest. Releasing owner-scoped resources was four calls in a row followed by the browser-tab release, so the first one to throw skipped everything after it: a Python kernel that would not close leaked the Ruby and Julia kernels, the JavaScript eval subprocess and every browser tab the session had opened, and you saw one error with no mention of the leaks. Each subsystem now registers its own cleanup, all of them run whatever the others do, and the failures are reported together.
  • A vault://, memory:// or local:// URL rejected for an absolute path or a .. no longer tells you about skill:// URLs. The check was shared but its messages were not.
  • The two memory tools that write now honour a cancellation, and the two that only read already did. retain and memory_edit took no abort signal at all, so a call issued just before you pressed Escape wrote to the store afterwards and nothing could have stopped it. Both now refuse before writing anything. retain with the Mnemopi backend writes one memory per item, so it also stops between items and the abort names what was already stored, what was not, and that the stored ones were not rolled back. Neither races the signal, because rejecting the caller while the writes continue is worse than not honouring the cancellation at all.
  • Changing a setting the system prompt depends on now takes effect. modes/controllers/selector-controller.ts carried a hand-written case per setting deciding which flips rebuild the prompt, and it had two of the nine: subagent.batch, subagent.delegation, subagent.maxConcurrency, subagent.maxRecursionDepth, subagent.agents, includeModelInPrompt and tools.format all change prompt text and had none, so flipping one saved the value and left the model reading a prompt that described the previous configuration until an unrelated rebuild happened to fire. Nothing was logged. The trigger is now derived from system-prompt-builder/gate-registry.ts, so registering a gate is what makes it take effect and there is no second list to forget.
  • Flipping a prompt setting a running session cannot pick up now says so. Three gates are read once at startup (inlineToolDescriptors deliberately, includeWorkspaceTree and tools.intentTracing as a consequence of where sdk.ts reads them), and the settings screen showed the new value either way, so there was no way to tell an applied change from one that did nothing.
  • Cancelling a github op that writes no longer walks away from it. Every op ran inside a helper that races the work against the abort signal, so the moment you pressed Escape the tool rejected and the git commands kept going: a pr_checkout carried on creating worktrees and branches with nobody waiting for them, and a pr_push was left mid-push. The three writing ops (pr_create, pr_checkout, pr_push) are now awaited, and they already pass the signal into every git call, so a cancellation still takes effect promptly. Reads and run_watch keep the old behaviour, which is correct for them: nothing was changed, so returning at once is the point.
  • A cancelled pr_checkout now tells you which worktrees exist. It reported the bare sentence "Operation aborted" and dropped the list, even though the checkouts that finished had created real directories and local branches, so the next checkout of the same PR found a branch nobody had mentioned and refused without force. The abort now names each worktree and its branch, names the PRs it did not reach, and says the worktrees were left in place.
  • A collab guest no longer receives the host's private session-header fields. The welcome frame's header was the host's own, sent verbatim, and the host's header carries three fields the wire contract does not declare: titleSource, parentSession, and providerPromptCacheKey. Extra fields satisfy a narrower type, so nothing complained. The guest writes the header it receives as the first line of its own replica session file, so the provider prompt-cache identity and the id of the session this one was forked from were being persisted on every guest's machine, read-only viewers included. The frame now names the wire type and the host projects onto it field by field, so a field added to the host's header cannot start shipping on its own.
  • The generated prompt inventory was scoped to three of the five prompt directories and missed two registries' templates, and its call-site scan named two of the five registry tables. Both were hand-maintained lists inside a tool whose whole purpose is that the set of prompts should not be hand-maintained: it reported @veyyon/ai's fourteen format guides and @veyyon/hashline's tool description as rendered only by their own registry module, and left them out of the orphan check altogether. The directory list now comes off the registry descriptors, so it grows when a package adopts a registry rather than when somebody remembers, and the scan matches any SOMETHING_PROMPTS table. prompt-inventory.test.ts pins every table name in use against that pattern, in both directions, because the obvious generalisation of it silently stops matching the bare PROMPTS and drops 189 call sites at once.
  • The /settings description of the plan approval mode said write and exec tools "require confirmation", but the mode has always denied exec outright and only asks about writes while a plan-mode session is active. The description now says what the mode does: read tools are auto-approved, write asks only inside an active plan-mode session, and exec is blocked.
  • The check that keeps every prompt registered was narrower than its own name. It scanned two src trees and, inside them, only flagged an import landing in a directory it already knew was a prompts directory, so the predicate deciding "is this a prompt" was "is it already registered": seventeen unregistered prompts in @veyyon/ai and @veyyon/metaharness were invisible to it, and scripts/bench-title-models.ts imported a registered prompt's file by relative path without being seen, because scripts/ is not src/. The rule is now the general one, over every .ts under packages/: a .md-as-text import is a registration and may only appear in a registry module, with two named exception lists (the registries themselves, and @veyyon/hashline's tool description, which its own package exports) that are each asserted to still describe a real import. A relative path reaching into another package's tree is refused even for a file that is otherwise allowed.
  • Re-rooting the session no longer tells the model to call a tool it does not have. Two places advise it -- the <working-directory> block of the system prompt and the hint appended to a tool result once you have touched three files outside the working directory -- and neither checked whether set_cwd was in the toolset. It usually was not: set_cwd is a discoverable tool, so under tools.discoveryMode: all it is deliberately kept out of the initial tool list and found through search_tool_bm25. The advice was right, the model followed it, and the call named a tool absent from the request, so nothing happened -- and re-rooting appeared to work only in sessions where something else had already activated the tool. The hint now activates set_cwd before it recommends it, and when it cannot, it says the tool is missing and names the way to get it instead of recommending a call that cannot land. The prompt block gained the same conditional sentence, keyed on the live tool list.
  • The re-root hint no longer picks which directory to name by how long its path string is. When several directories cross the threshold in one call (a multi-path grep, a glob, a patch spanning projects) the hint used to go to the longest path, which within one project is accidentally the deepest and between two projects is arbitrary: /srv/averyverylongprojectname beat /srv/a/b/c/d while being four levels shallower. It now names the deepest directory by path segments, breaks a tie by which one you have worked in more, and resolves a full tie the same way every time rather than by the order files happened to be read. The shared parent of two unrelated projects can no longer win despite holding the sum of both.
  • set_cwd no longer reports that no rule files are in effect when you ask for the directory you are already in. A real move computed the rule-file counts from the loader; a no-op asserted its own empty answer with rulesUnchanged: 0, which is untrue -- your user-level AGENTS.md applies from every directory, so a session that never moved still has rules governing it. Both cases now read the same describer, so the counts in the details and the sentence beside them cannot disagree, and a no-op whose rule files cannot be read says which instructions are in effect is unknown rather than claiming a change that did not happen.
  • veyyon -p "prompt" no longer hangs forever when stdin is a pipe nobody writes to. Reading piped stdin waits for EOF, and a supervisor or CI runner that spawns the CLI with an inherited pipe it never writes to never sends one, so the run stopped before it started with the prompt sitting unused on the command line. When the prompt is already on the command line, the wait for the FIRST byte is now bounded (10 seconds, VEYYON_PIPED_STDIN_WAIT_MS to change it, 0 to wait indefinitely) and giving up is reported on stderr. Once any byte arrives the wait is unbounded again, so a slow or large piped document is still read in full: truncating it would let the model answer about content you believe it read.
  • A directory that cannot be read no longer reads as "nothing configured". Agent discovery and the managed-skills sweep listed optional directories with .catch(() => []), so a .veyyon/agents that exists but cannot be listed produced the same empty result as one that was never created: the agents disappeared from /agents with nothing in the log. Both now go through the shared readdirIfPresent, which stays quiet when the directory is simply absent and logs the path and what it was looking for when it is there and unreadable.
  • Two local auth services starting at the same moment can no longer end up with two different bearer tokens. The token file is created with an exclusive create so the loser of the race re-reads the winner's token, but an exclusive create makes the file exist before its contents are written, and an empty file read as "no token yet" -- so a caller that read inside that window minted a second token, and a client holding the first was then rejected by the service that issued it. The loser now waits for the creator to finish writing. If the file is still empty after the wait, which means a creator died between creating it and writing to it, the caller takes ownership and logs that it did, since the file it replaces may already have been handed out.
  • An isolated task no longer mistakes a directory it cannot inspect for ordinary content. discoverNestedRepos decides which directories are their own git repositories, and an isolated task treats each one as a boundary it does not snapshot. The check swallowed every error, so a .git the process could not stat -- an unreadable parent, a restricted mount, an I/O error -- read the same as "no repository here", and the walk descended into a repository it had failed to recognise and captured its files as the parent's. A directory that could not be listed hid every nested repository beneath it with nothing logged. A missing .git is still a plain no; anything else is now logged with the path and treated as a boundary, so the walk stops instead of reaching into a tree it cannot see.
  • Startup no longer loads the interactive TUI on runs that never render one. main.ts loaded interactive mode with a dynamic import specifically so -p, --rpc and ACP runs would not pay for the modes/components subtree, and two static edges pulled the same subtree in anyway: the four extensibility loaders each held import * as PiCodingAgent from "../../index" to hand extension authors the package as api.pi, and the package barrel re-exports every mode; main.ts also imported two small functions from the welcome component. So veyyon -p "hi" with no extensions installed loaded the settings overlay, the plugin-settings panel and the interactive mode it would never construct. The pi namespace now loads on demand from one owner, the launch tip moved to a module that pulls no component with it, and a session with no custom tools no longer builds a custom-tool API at all. Interactive mode's inclusive load went from 402ms to 75ms and the instrumented boot wall from about 2.0s to about 1.1s. test/startup-module-graph.test.ts resolves the static graph and fails if any of those modules re-enters it.
  • A piped prompt that cannot be READ no longer vanishes silently. veyyon -p with input on stdin returned no prompt on a read failure and continued as if nothing had been piped, which ends as exit 0 with no output and no explanation -- the same disappearing-prompt symptom the code already guarded against for a different cause. A failed read now prints what went wrong on stderr before continuing.
  • A GPU probe that cannot RUN is reported. nvidia-smi/lspci/wmic failures were swallowed to "no GPU information", and the prompt's environment section then simply omits the GPU -- which on a machine that has one is a configuration bug, not a fact. A missing probe binary stays quiet at debug level (slim containers and trimmed Windows installs genuinely lack them); anything else warns with the command and the error, so a workstation does not quietly describe itself as GPU-less.
  • A theme whose file cannot be read no longer produces an export in default colours with no explanation. getThemeExportColors returned "this theme sets no explicit colours" for both a theme that sets none and a theme that could not be loaded; the second case now logs the theme name and the error.
  • A tool block's spinner stops itself and warns if the active theme cannot supply spinner frames, rather than throwing from inside its 80ms timer. A throw there has no caller to catch it, so it surfaced as an unhandled error attributed to whatever happened to be running and then repeated twelve times a second for the rest of the process. One unrestored global theme in a test suite cost 12 failures in three suites that render nothing.
  • The Essential Tools Override setting description now names the real default list. It showed six tools (read, bash, edit, write, glob, eval) while the actual always-loaded default has seven, launch included, so an operator writing an override list from the description would have silently dropped process launching.
  • bench/session-tree-nav.bench.ts runs again. buildSessionContext moved to session/session-context.ts and the SessionEntry union to session-entries.ts, and the script still imported both from session-manager, so it died on a SyntaxError and published nothing while saying nothing about it. It reports 0.0460ms per navigation before the dedupe fix against 0.0071ms after, an 84.6% reduction, and it uses the shared bench harness instead of its own timing loop now that the harness runs the warmup this script always knew it needed.
  • veyyon bench/throughput <TAB> completes model ids again, and completes a comma-separated list of them. The completion generator keys its positional tables by <command>.<arg>, and the benchmark's was listed under bench while the command registers as bench/throughput, so the key matched nothing and the positional fell through to "no candidates". The argument is also declared repeatable and was pinned to a single value, which stopped offering candidates after the first selector. A key that matches no command cannot fail on its own, so every key in those tables is now checked against the real command list and the arguments each command declares.
  • A provider whose model discovery produced no catalog now says why, for every provider rather than only the ones that failed by throwing. The registry keeps per-provider discovery state and reported a reason only when discovery THREW, so a returned null was silent: an endpoint that refused the connection, one that answered 401, and one that answered with an unrecognized payload all left the same result, a picker missing models you pay for with nothing anywhere explaining it. The reason arrives with a stage, which is what decides where to look: request at the network, status at credentials, body or payload at whether the endpoint still speaks the protocol. It goes through the same reporter as a thrown failure, so it is deduplicated per provider instead of repeating on every refresh, and an endpoint that answers with an empty list still produces nothing.
  • A harness profile now refuses a promptSectionOrder or tools list containing an entry that is not a name, instead of dropping that entry and applying the rest. A misspelled section name already rejected the whole list, with a comment explaining that a silently dropped entry would apply an order the operator did not write; a non-string entry three lines above hit a continue and did exactly that. tools matters more, since it DENIES tools: tools: [read, 42, bash] gave the model two tools instead of three with nothing naming the one that vanished. A harness-profiles.yml that cannot be read or parsed is also reported now, with the path and the reason: the loader had an ENOENT branch and a fallthrough that both returned no profiles, so a YAML syntax error dropped every per-model profile and started the agent on the defaults in silence.
  • A /proc/cpuinfo that exists and cannot be read is now reported at the same volume as the GPU cache beside it. Both omit a line from the prompt's environment section, and both are the same fact — the file is there and the read failed — but one warned while the other used logger.debug, a level nobody runs with. A missing file stays silent, since it is Linux-only and absent in some containers.
  • veyyon prompt --prompt <id> builds its stand-in section row against the real section type. A prompt with no declared sections is described by one synthesized row, and that row was missing name with nothing to say so, because the function happens not to print it; it would have started rendering undefined the moment it did.
  • A git status autoresearch cannot read is no longer reported as a clean worktree. tryGitStatus answered any failure with "", which parses to "no dirty paths", and three things acted on that: a discard reported "nothing to revert" while the experiment's changes sat in the tree, log_experiment recorded an empty modified-path list so the scope-deviation check passed vacuously against off_limits, and run_experiment recorded an empty PRE-RUN dirty set, which claims the tree was clean and would attribute the user's own uncommitted files to the experiment. The probes now propagate and each caller reports through the error channel it already had. A cwd outside a repository is still answered with "", decided by resolving the repository rather than by a failed command, because autoresearch may run there and then has no tracked changes.
  • init_experiment treats an unreadable git status as dirty rather than clean. False took the branch that skips committing harness changes, which is the branch whose own warning says "discard may not preserve uncommitted harness files".
  • A debug adapter config that exists and cannot be read or parsed is reported. Six filenames are probed per directory, so absence is silent, but a dap.json with a trailing comma used to be indistinguishable from no dap.json: the configured adapters simply were not there and the debugger fell back to its defaults without a word. Same for an unparseable lspmux config.toml, where a typo silently reverted every language server to the direct, unmultiplexed path.
  • Detecting a repository inside the working directory says when it could not look. An unreadable cwd produced the same empty listing as one with no repository in it, so the prompt and the status line both showed nothing with no sign the check never ran. The "exactly one direct child" rule also has one owner now instead of a copy in each of the asynchronous and synchronous paths.
  • Cancelling a fan-out of agents part way through now reads as cancelled instead of failed. Stopping a five-agent call after three finished produced the same result the parent sees when two agents crash: isError was set, the text said nothing about the stop, and the three transcripts you did get sat under a claim that something had gone wrong, so the model would re-run work you had just cancelled. The result now opens with what became of the batch (2 of 5 agents completed, 3 cancelled.), names the spawns that never started, still returns everything the finished agents produced, and reserves the error flag for agents that actually failed. A genuine failure still reads as one, alongside any cancellations.
  • A subagent calling set_cwd no longer moves the working directory of your session and every other subagent. Subagents run inside the same process you do, and re-rooting one used to chdir that whole process, reload settings for its project, and reset the shared capability and plugin caches. Tool paths hid it, because they resolve against each session's own directory, so the symptom was a command or a bare relative path running in a project nobody had opened, with nothing on screen to explain it. A subagent now re-roots itself alone: its paths and its system prompt move, and yours do not. Your own session still moves the process as before.
  • Clicking a tool result's image thumbnail in the web view now reports a blocked or unavailable popup instead of doing nothing. The click decoded the image and opened it inside one try, so a popup blocker was swallowed by the same catch that exists for undecodable image data, and the button looked live while nothing happened. The try now covers decoding only, which is the one failure the broken thumbnail beside it already explains.
  • Every version from 1.0.0 to 1.0.36 has a changelog entry. The file described one release out of thirty-seven, so a user could install eight published versions it said nothing about. The sections are reconstructed from git history and dated by the commit that cut each version, and the site build now fails on any published release with no entry rather than printing a warning and exiting 0.
  • A mistyped --system-prompt path is now an error instead of becoming the system prompt. The option takes either a path or the prompt text, and any read failure returned the input unchanged with ENOENT explicitly excluded from the warning, so --system-prompt ./promtps/main.md handed the model a system prompt whose entire content was that string: every rule, tool policy and workflow gone, the session behaving nothing like it should, and nothing on screen connecting it to a misspelled directory. --append-system-prompt and TITLE_SYSTEM.md resolved the same way. A value that fails to read is now refused when it has no spaces and either contains a path separator or ends in a prompt-file extension, naming the option, the path and the error. Prose is untouched: a one-line prompt containing a slash or ending in a dotted word is still used as written.
  • looksLikeFilePath is one function in @veyyon/utils rather than a private copy in the Anthropic provider. It decides whether an option that accepts "a path or the value itself" was written as a path, and the provider's copy was the only place asking; prompt resolution had no shape check at all, so the same class of typo was an error for a certificate and silent for a system prompt. The extension list is passed by the caller, since .md names a prompt file and means nothing in a certificate option.
  • A PROMPT_SECTIONS/ directory that exists and cannot be read is now refused instead of read as having no overrides. Any error from listing it returned the same empty list a missing directory does, so a directory that became root-owned after a sudo edit, a broken symlink, or a path that is a file dropped every section override the user wrote and the agent ran the shipped prompt with nothing logged. Absence is now ENOENT/ENOTDIR alone, through the shared isMissingPath, and anything else names the directory, the underlying error, and what would have happened. A file the listing just reported and the reader cannot open is refused the same way: it used to be skipped silently, which left a file on disk that had quietly stopped doing anything. Both judgements moved to the loader itself, so a reader injected by a test or an embedder is held to the same contract.
  • The error refusing a PROMPT_SECTIONS/<section>.md replacement now says how long the underline has to be. It named the banner, so a file refused for its underline read as though the section name were wrong, and the width it did mention was prose written by hand at the throw site rather than the constant the check applies — a change to the accepted width would have left it telling you to write the length that no longer works.
  • A plugin directory that cannot be read is reported instead of loading as a plugin with nothing in it. resolveDirectoryEntries answered a failed readdirSync with [], which is also what a directory holding no loadable files gives, so an unreadable one registered no tools, hooks or commands and said nothing. The empty list is still returned, because one unreadable directory must not stop the rest of the plugin from loading.
  • A plugin package.json that exists and cannot be parsed is reported. It used to reach the same null an absent one does, so dependency resolution gave up and the import failed with "cannot resolve <specifier>", which sends the reader hunting a missing dependency rather than the malformed manifest in front of them.
  • doctor --fix says why reinstalling plugins failed. The exit code and bun install's output were drained and discarded, so a network failure, a lockfile conflict and a missing bun on PATH all showed up as the same red check.
  • /review no longer presents a git failure as an empty repository. Listing branches or commits answered any failure with an empty list, so the command reported "No git branches found"; worse, an unreadable repository made the status probe return "" and the review ran on an EMPTY diff and reported no findings, which is a false clean bill of health rather than an admission that git could not be read. Each failure now names its reason through the same notice the command already used for a failed diff.
  • Search providers say when stored credentials cannot be read. findCredential answered any failure from the credential store with null, which is also "no credential configured", so a search key the user configured silently dropped out of the provider chain and the search quietly ran on whatever was left. Reported once per provider set, since the failure belongs to the store rather than to the query.
  • The result-anchor rule for HTML search providers has one owner, resolveExternalResultUrl. Ecosia, Mojeek and Startpage each had a copy, and the three had already drifted in the part that matters: one rejected the engine's host and its www. spelling, one rejected every subdomain, one did both for a single domain. Ecosia's copy would have returned images.ecosia.org as an outbound result. Subdomain matching is now the rule everywhere.
  • The GitHub, GitLab, Hugging Face, Sourcegraph and Vimeo URL classifiers no longer wrap their whole body in a catch that returns null. The parse verdict already belongs to tryParseUrl, so that catch could only swallow a bug inside the classifier, and it surfaced as "this is not a URL I handle": the structured reader was skipped and the page came back as anonymous HTML with the issue, PR and Actions handling silently gone.
  • A prompt part is now identified as a section only when it actually carries a banner. applyPromptSectionOrderToParts read the part's first line and looked it up, with no check for the ==== underline beneath it, while the splitter cutting the same document required one. So any part whose first line happens to read like a banner name was ranked as that section: it could be ordered ahead of the real one, and a promptSectionOrder naming a section no part carries was reported as known instead of warned about. Both sides ask leadingBannerName now, which is also what startsWithBanner is written in terms of.
  • Failing to save a tool's full output as an artifact is reported. saveOutputArtifact is the one owner of the artifact://<id> spill, and it answered a failed write with the same silent undefined a session with no artifact store gives, so the caller printed its bounded head/tail window with no footer and no trace that the full bytes had existed. The window is all there is: the raw text is kept nowhere else. undefined is still returned, since the visible result is correct without the artifact, and the session's own bash-original save now reports through the same reportLostOutputArtifact owner instead of swallowing the failure privately.
  • AgentSession's plan-file listing is the shared listLocalPlanFileUrls rather than a third copy of the same readdir-filter-stat-sort walk. All three copies ended in catch { return [] }, so an unreadable session-local root looked like a session with no plans in each of them, and the fix had to be made in each of them.
  • Settings search now treats a multi-word query as an AND of its words: every word must match the label, path, group, description, or declared keywords, and word order carries no weight. The one-needle scorer it replaces looked for the words as a literal pair, space included, so auto compaction matched nothing — the label reads "Auto-Compaction Threshold" and contains neither the space nor the pair — which is exactly the query you type after reading the label. Stray punctuation between words is ignored, so auto - compaction is still the two-word query.
  • Added a Subagents settings area: a tab of its own (Delegation, Agents, Models, Limits, Isolation) that owns every question about a spawned agent. Everything about subagents used to be spread across fifteen task.* keys, a subagent.model field on the Model tab, a task model role in the role table, and two agent-keyed maps with no UI at all (task.disabledAgents, task.agentModelOverrides), so "is this agent on" and "what model does it run" each had two owners that could disagree. The Agents group lists every agent the project has, with the model each resolves to and the setting that decided, and opens one agent at a time to set whether it is offered, its model and its effort — the two agent-keyed maps it replaces could only be edited by hand, and nothing showed what a spawned agent would actually run. /agents edits the same rows through the same resolver. Your config is folded onto the new keys on load, including modelRoles.task becoming subagent.model, and the old keys are dropped from the file. Handbook: [Subagents](docs/handbook/src/features/subagents.md).
  • Added docs/settings-reference.md: every setting that appears in /settings, with its key, type, default, and what it does, grouped exactly as the tabs are. 201 of the 313 settings a user can see and change had no documentation anywhere, and nothing failed when a new one shipped undocumented. The page is generated from the schema by scripts/gen-settings-reference.ts, and CI fails when the committed file and the generator disagree, so it cannot fall behind the code. docs/settings.md keeps the hand-written narrative and links to it.
  • Ruby and Julia eval cells can now run on a kernel of their own: ruby.kernelMode and julia.kernelMode, with the same session and per-call values as python.kernelMode and the same session default. per-call starts a kernel for the cell and shuts it down when the cell finishes, including when it fails, so nothing the cell defined survives it. Python has had that choice since the setting existed while both other runtimes always kept one kernel per session, so a run that needed a clean slate could only get one in Python. A fresh Julia kernel recompiles, so per-call costs more there than anywhere else.
  • veyyon prompt --sections now lists the sections that are NOT in the prompt, each marked optional or REQUIRED, and exits 1 when a required one is missing. The breakdown could only show what rendered, so a prompt missing a section for a good reason (a feature is off) and one missing it because assembly broke produced the same output and the same zero exit. The subagent prompt has always had that distinction; the system prompt, which is larger and where 86 of 272 template lines are conditional, did not. --json carries the same information in a missing array, present even when empty.
  • Added a build-time guard on the system prompt's cached prefix. Block 0 is the byte-stable text a provider serves from its prefix cache, and nothing failed when an edit changed it, so a wording change shipped a one-time full re-read of every user's conversation with no sign of it in the diff. system-prompt-cached-prefix-stability.test.ts records the digest, so moving those bytes is now a deliberate line in a review, and separately fails if a runtime section lands inside the prefix, if arming a feature perturbs it, or if two builds of the same inputs differ.
  • Plan approval now reports a session-local plan directory it cannot read, instead of behaving as though you had written no plans. The interactive path and the ACP path each had their own copy of that listing and both answered any failure with an empty list; there is now one owner, listLocalPlanFileUrls, and an unreadable root is named while the empty list is still returned so approval keeps working.
  • A theme that fails to load is now reported with its name and the underlying error. getThemeByName returned nothing for both a name that does not exist and a custom theme file with a syntax error, so a broken theme sent you looking for a typo.
  • Code that cannot be highlighted is now reported once per language rather than silently rendered plain, which is also what an unsupported language looks like.
  • A harness promptSectionOrder that asks a runtime section to come before a template section is now reported instead of quietly half-applied. Runtime sections always follow the cached prefix, so the request cannot be honoured; the warning names the section and the reason. Only a name matching no section at all was reported before, so an order naming two real sections produced a prompt in a different order from the one asked for with nothing logged, and an eval arm testing whether teaching the shorthand notation first changes behaviour would have run the control and recorded it as the treatment.
  • Prompt sections are now described by one type instead of two. system-prompt-builder/section-registry.ts (then named prompt-blocks.ts) and prompts/registry.ts each exported an interface named PromptSection, so importing "the" one gave you whichever your editor offered, and each had grown a field the other lacked. They share one declaration now, which is how the system prompt gained optional.
  • Every banner in every prompt is now underlined the same way. Three widths shipped at once: the system prompt's template sections used 14 =, its runtime sections used 35, and the subagent prompt used 35, so one prompt showed the model two different banner styles. The width had no owner, being split between a two-character stub in the registry and a "=".repeat(33) in the assembler. Registry rows declare only the banner NAME now and one function renders it, so a section cannot ship a width of its own. The system prompt's cached prefix is byte-identical; the subagent prompt's banners changed once.
  • A PROMPT_SECTIONS/<section>.md replacement whose underline is too short is now refused instead of accepted and then ignored. The validator required only that the text start with the registry's banner field, which ended in two =, while the splitter needs four to cut on a banner. A replacement underlined with two or three = therefore passed validation and landed in the prompt as ordinary text: the section stopped appearing in veyyon prompt --sections, promptSectionOrder could no longer move it, and a later override addressed the wrong span. Both sides ask one function now. Underlines of four or more are accepted, so a hand-written file that does not match the shipped fourteen still works.
  • Adding a prompt section that arrives as a builder option no longer demands an entry in the map of sections the builder computes itself. The type keying that map named the option-backed sections by hand (Exclude<RuntimeSectionId, "shorthand" | "shorthand-handles">), so a new option-backed section failed the build asking for text that has no source, and the two ways to silence it — a bogus entry, or widening the map — both bring back the failure the map exists to prevent. The type is derived from each section's own declared input now, so it tracks the registry.
  • A prompt section pointing at an option that does not exist is now a compile error naming the key. The check meant to prove it cast its own input (section.input.key as StringOptionKeys), and a cast asserts rather than checks, so the check passed for every key: a mistyped option read undefined, the section rendered nothing, and nothing failed. The assembler's two text lookups were cast as well and are not now, so a section reclassified as computed without a matching entry is also caught at the index. Contributor guide: [System Prompt Customization §10](docs/system-prompt-customization.md).
  • An MCP authorization-server metadata document whose issuer is not a URL is now refused instead of trusted. The check that keeps a grant from being routed to an unverified /authorize endpoint compared the issuer against the URL the document was found at, and accepted the document whenever either value failed to parse, so a junk issuer skipped the check that a well-formed mismatching one had to pass. An unverifiable issuer is now treated as a mismatch: discovery moves on to the next well-known path, exactly as it already did for a mismatch.
  • Protected-resource metadata that cannot be read is now reported. It carries the scopes the grant should ask for, and a failed fetch produced the same "no scopes advertised" answer as a document that lists none, so the authorization request went out without them and came back as an opaque invalid_scope or server_error with nothing pointing at the cause.
  • veyyon grievances no longer tells you to enable auto-QA when the database is there and cannot be opened. The two cases produced the same message, so advice that could not help hid the fact that your reported tool issues were being dropped. The message now names the path, push --json reports reason: "unreadable_db" rather than "no_db", and the explanation goes to stderr in --json mode so piped stdout stays parseable.
  • An unreadable artifact directory no longer presents as an artifact:// reference that does not exist. Following the recovery link under a truncated tool output said the artifact was missing when the directory holding it could not be listed; the failure is now reported with the directory and the artifact id.
  • A read that could not be summarized now says so. Failing to build the outline returned the same value as a file with nothing worth folding, so you got the whole file with no indication that an outline was attempted and lost. Cancelling a read stays quiet, because that is not a summarize failure.
  • A session file that exists and cannot be read no longer presents as a session that is not there. peekSessionInit, the lock-free peek a cold subagent revives from, caught every failure and returned null, which is also its answer for a path that was never written, so an unreadable transcript was reported to you as a missing one. It still returns null, because that is the caller's contract, and it now warns first with the path unless the file is simply absent.
  • An unreadable artifact directory is now reported instead of reading as a session with no artifacts. Artifacts are what an artifact:// URL resolves against, so an empty listing made every truncated tool output in the session unreachable with no error to explain it. The listing still returns empty, since a session has to keep running when its artifacts are unreachable, and the warning names the directory.
  • A tool call whose arguments cannot be serialized now renders its argument names in the history:// transcript. A circular reference or a BigInt makes JSON.stringify throw, and the summary fell back to an empty string, so → write() was printed for a write that had a path and a body. It now reads → write({unserializable: path, content}).
  • An unreadable sessions directory no longer looks like having no sessions. The cross-project scan behind the session picker and --resume, and the per-directory listing under it, answered any failure with an empty list, so a permissions problem presented as a user with no history: the picker came up empty, --resume had nothing to offer, and nothing was logged to disagree with. An absent directory is still an empty list in silence, because that is a project you have not opened yet. One that is there and cannot be read is now reported with the path, and the empty list is still returned so a picker that cannot list comes up empty rather than crashing.
  • A click by selector that failed because the page re-rendered no longer reports it as an invisible element. Clicking examines every match and keeps the ones it can see; an element also leaves that list when the probe THROWS, which is what happens when the node is detached between the query and the check. That case was swallowed, and the timeout then said no-visible-candidate, which sent you to inspect CSS and stacking contexts for an element that was on screen the whole time. The timeout now distinguishes the two, counts the failed probes, and names the first error: every candidate probe failed (3 of 3): Execution context was destroyed ..., or, when both happened, no-visible-candidate, and 2 of 5 probes failed: ....
  • Seven packages' test suites are executed again. scripts/ci-test-ts.ts replaced the old bun run --workspaces test fan-out with three hand-kept lists of package paths, and argot, stats, deepswe-bench, metaharness, collab-web, tool-render and swarm-extension were never added to any of them, so 82 files and 1274 tests ran in neither CI nor a local full run. They all pass, in about seven seconds. scripts/workspace-test-coverage.test.ts now checks the lists against the tree in both directions, so a package cannot ship tests that nothing runs and a list entry cannot outlive the package it names.
  • Formatting and import order are checked in CI. The lint job ran biome lint, which covers lint rules only, beside a step labelled "Biome check" that ran the TypeScript type checker and no Biome at all, so the formatter and the import-organization pass were gated by nothing: 39 files were unformatted and 25 had unorganized imports while both jobs reported green. bun run check:tools is now its own step and the mislabelled one says what it does.
  • Benchmarks are linted. packages/*/bench/** was outside Biome's file list, so six unused-binding findings sat there unreported. The deliberately frozen key-parser baseline at packages/tui/bench/_jskey.ts stays excluded by path, because its unused declarations are the snapshot it exists to preserve.
  • tsc -p tsconfig.json at the repository root no longer tries to compile the benchmark repositories downloaded into packages/deepswe-bench/repo-cache/. The root config is a solution file that owns no sources of its own, but it omitted files: [], so include defaulted to everything and the check failed on third-party syntax.
  • Compaction has exactly two strategies. The settings enum has said summary | handoff for a while, but the type behind it still admitted context-full, shake, and off, so the code disagreed with the setting. The first two were engine actions rather than things you choose; off was a second way to spell compaction.enabled: false, which let two fields disagree about whether compaction runs. Turning compaction off is now compaction.enabled and nothing else. A config file that still says strategy: off keeps working: it loads as handoff with enabled: false, so compaction stays off.
  • Extensions no longer see a compaction action that cannot happen. The auto_compaction_start and auto_compaction_end events typed their action as context-full | handoff | shake, and nothing has ever emitted shake, so an extension with an exhaustive switch was handling a case that could not occur. All three copies of that union now reference the one type that owns it.
  • Constants that named one thing while meaning another. STARTUP_TIMEOUT_MS in the MCP manager is not a timeout: nothing is aborted when it elapses, cached tools are served while the connections keep running, so a name reading "an MCP server gets 250 ms to start or it is dropped" described behaviour the code never had; it is now STARTUP_TOOL_WAIT_MS. Four more pairs shared a name across unrelated subsystems and now say what they bound: a log rotation threshold against how much of a log tail a bug report may read, a debug session's in-memory output ring against the subagent return budget that has to match VEYYON_TASK_MAX_OUTPUT_BYTES, and a loopback broker connect budget against the network relay one. No behaviour changed; the numbers were always right, only the names were shared.
  • The three eval sandboxes each spelled out their own environment allow-prefix list under one shared name with three different values, so widening the base that all of them need meant three edits and nothing failed if you made two. The base now has one owner and each runtime extends it with its own language prefixes. The Python and Ruby kernels likewise each typed the same startup timeout, with Julia typing a longer one; the shared value now lives in one place and Julia expresses its need as base plus margin, so raising the floor raises Julia's too.
  • veyyon auth-broker could hand out a token it then rejected. Two brokers starting at the same time each minted a bearer token and the second overwrote the first, so a client told to use the first token was refused by the service that issued it. The broker also created the token file with the default mode and narrowed it afterwards, leaving it briefly readable by any local user, and on Windows, where that narrowing does nothing, permanently. The auth gateway had already solved both (an exclusive create at mode 0600, and treating a lost race as "read the winner's token"), and the two services now share one implementation with the gateway's behaviour.
  • Fixed the rule that nudges you to set_cwd into another project delivering nothing most of the time. Three separate defects hit the same path. Its body was folded into the tool result unrendered, so the model was shown template markup that named neither directory and advised a tool that is off by default. The rule was marked as delivered the moment it was queued, and a turn that aborted or errored dropped the queue without taking that mark back, which retired the rule for the rest of the session. And rules fire once per session by default, which is right for a rule stating a convention and wrong for one whose advice applies again to the next directory, so a rule can now set repeatMode and repeatGap in its own frontmatter; this one repeats after eight messages. Your global ttsr.repeatMode and ttsr.repeatGap are unchanged.
  • Fixed the bundled rule that nudges you to load a project's Argot shorthand never running. It shipped in every install as a file nothing loaded, and it could not have worked if it had been: it carried no trigger, and its body was gated on the argot feature being on rather than on the dictionary being missing, so with argot on it would have told the model to load a dictionary it had already loaded, on every edit. It now triggers on an edit in a project whose shorthand is not loaded, and only then. Two related fixes came out of it: a rule whose body renders to nothing is no longer delivered as an empty reminder, and every bundled rule file is now checked to be registered, so the next one cannot sit unloaded.
  • Fixed a rule whose condition is blank matching everything instead of nothing. An empty pattern compiles to a regex that matches every character, so a half-written rule fired on every tool call and every line of prose for the whole session. A blank pattern is now skipped and reported, and a rule left with no trigger at all is refused with a message naming the file, instead of being loaded, listed by /rules, and silently never watched.
  • Fixed a rule whose scope names a tool that does not exist loading as if it worked. A bare scope token is read as a tool name, so a typo like raed registered a rule that could never match, and that is indistinguishable from a rule whose condition simply never came up. Veyyon now reports it once the tool list is known, naming the rule, its file, and the closest tool it does know. The rule is not disabled, because a rule scoped to a tool an extension registers later is legitimate.
  • Fixed inherit-able model settings (subagent.model, compaction.model, role models, the default model) being effectively one-way: once a model was assigned, the only path back to unset was the forward-Delete key, which many keyboards do not have, and Backspace fell through as a silent no-op. These pickers now lead with a visible (inherit main model) row (the default model's reads (auto-select on launch)) that clears the assignment like any other selectable row, Backspace clears alongside Delete when the search is empty, the footer names both paths, and an assigned slot opens with its model preselected so a quick Enter re-picks it instead of clearing.
  • Fixed model switches bypassing defaultEffort. A session-only effort still wins, then an explicit selector suffix, then the new model's saved row, the any-model row, and the model default. Choosing Default clears the session override and immediately restores that chain.
  • Fixed a mouse drag over the transcript doing nothing without a word of explanation. Scroll isolation holds the mouse so the wheel scrolls the transcript with the prompt pinned, which means selecting is shift+drag, and that tradeoff was stated only in a settings description. The first swallowed drag now names shift+drag, /copy, and tui.scrollIsolation=false, and a gated tip says the same before you hit it.
  • Fixed the composer being replaced by a scroll readout while reading history: the contextual chip row under the prompt was overwritten with "N rows up / click to go to the bottom" whenever the transcript was frozen, so scrolling up during a run silently removed the esc interrupt chip. Scroll position is now drawn on the right edge of the transcript by the renderer, and the composer zone renders the same bytes whether the view is frozen or following.
  • Fixed Task subagents calling MCP tools through a rebuilt raw request, which bypassed the source tool's harness-intent stripping, local-URL resolution and reconnect retry, and marked MCP-backed tools non-strict so the server owns validation.
  • Fixed a configured modelRoles.default on a discovery provider being replaced at startup by an unrelated authenticated provider's default, when a cold catalog cache meant the role could not yet resolve.
  • Fixed session_stop extension hooks running when a prompt was aborted or the session was disposed, so an abort could still trigger stop-hook work and its continuation state.
  • Fixed a provider error being pinned behind the plan review overlay, which left the error invisible and the input unusable until the plan was answered.
  • Fixed the tools.maxTimeout ceiling being ignored whenever a tool call omitted timeout, so a configured global cap did not bound a tool running on its default budget.
  • Fixed isolated branch merge-back rejecting committed agent edits when the parent had unrelated uncommitted changes in the same file; dirty-baseline blobs are now seeded into the parent object database and replayed with a 3-way synthetic-tree apply (#40).
  • Fixed Esc aborting an ongoing agent turn instead of overlapping TTS playback, leaving speech uninterruptible.
  • Fixed the remapped TypeBox compatibility shim omitting Type.Unsafe, which crashed extensions such as vey-mcp-adapter when they registered tools from raw MCP input schemas.
  • mnemopi: validate the source name in renameBank to close a traversal.
  • settings-test: drop wrong SettingPath cast that broke the release typecheck.
  • coding-agent: make file moves crash-atomic; hoist mode-preserving atomic write.
  • install: compare whole PATH entries on Windows, add gated ps1 function tests.
  • edit: preserve a UTF-8 BOM through an old_text/new_text edit.
  • lsp: don't silently swallow format-on-write failures.
  • website: serve get.veyyon.dev root from a dedicated install-only tree.
  • release: reclaim hashline perf bullet into [Unreleased]; keep new [Unreleased] below fork notice.
  • natives: declare the four new loader-state exports in the .d.ts.
  • compaction: count retained custom/branch tokens in keepRecent budget.
  • compaction: cut past the crossing entry when keeping everything dead-ends.
  • tool-render: deep-import formatCount so the collab-web browser bundle stops pulling Bun-only utils.
  • plugins: plugin doctor reports ok for the fresh-install state.
  • session: clearer no-model/no-key guidance pointing at /login and veyyon setup.
  • release: skip changelog diff when no baseline ref exists (first release).
  • cli: fail fast on non-TTY interactive/empty stdin; consume piped prompts.
  • A secret vault changed by another session or process no longer kills the running session. Every display and render path (the streamed assistant message, the tool-intent working line, the transcript the TUI repaints, the state rebuild after a compaction or a branch move) now shows the placeholder unexpanded and raises an operator notice while it re-reads the vault, instead of throwing a codec error that unwound the whole session and left the TUI refusing every command. Text with no placeholder in it is never checked for freshness at all, so a command like echo "$HOME" can no longer be refused over a vault it does not touch.
  • plugin-cli: stop doubling the 'Error:' prefix in plugin command errors.
  • errors: remove remaining doubled 'Error:' prefixes; generalize the source-lock.
  • install: warn instead of silently skipping a failed shell completion.
  • edit: trim apply_patch marker paths so the write matches the approved path.
  • hashline: guard NodeFilesystem.move against deleting the file it just wrote.
  • hashline: guard the production move adapter against deleting the file it just wrote.
  • hashline: guard InMemoryFilesystem.move against dropping a same-key move.
  • coding-agent: make the recent-sessions recency order deterministic.
  • ci: restore collab:web:build root script (unblocks release check gate).
Removed
  • Removed five directory barrels that nothing imported: src/exa/index.ts, src/mcp/transports/index.ts, src/mnemopi/index.ts, src/modes/acp/index.ts and src/repair/index.ts. Every consumer already reached the module directly, so each barrel was a second, silently incomplete list of its directory's exports. The subpath exports @veyyon/coding-agent/exa, @veyyon/coding-agent/mcp/transports and @veyyon/coding-agent/modes/acp are gone with them; import the module you want instead (@veyyon/coding-agent/exa/tools, @veyyon/coding-agent/mcp/transports/stdio, @veyyon/coding-agent/modes/acp/acp-agent), which is what every in-tree caller and every example already did. @veyyon/coding-agent/mnemopi/index and @veyyon/coding-agent/repair/index resolved through the wildcard subpath rather than a declared entry, and are likewise replaced by the direct module (.../mnemopi/backend, .../repair/schema-repair). A new barrel that nothing imports now fails scripts/barrel-files-are-imported.test.ts.
  • Removed the remoteCompaction option from models.yml. It opted a provider or model into provider-native compaction, which was removed, so setting it configured nothing and gave you a session whose compaction was not what your config said. A config that still carries it is now refused at load with the provider, the model, and the replacement named: set compactionModel on the model, or the compaction.model setting. Deleting the key from your config is the whole migration.
Fixed
  • veyyon update now updates source installs for real: it fast-forwards the checkout, reinstalls dependencies, and regenerates build artifacts, instead of refusing with advice to run git pull yourself.
  • A source checkout missing its generated tool-views bundle (any freshly pulled or cloned checkout) no longer dies at launch with a raw module-resolution error: the launcher regenerates the bundle before starting, and fails with the exact fix command if it cannot.
  • The setup wizard now paints its own pure-black ground across the full frame (splash, scene transitions, and outro), so the launch sequence looks the same on every terminal background instead of inheriting the terminal's color.
  • The Windows binary is now built as a modern (AVX2) Bun target instead of baseline. Baseline Windows standalone builds crash in the Bun runtime at startup before any Veyyon code runs (oven-sh/bun#32684), which made every published veyyon-windows-x64.exe exit with a segmentation fault on launch. The modern target requires a CPU with AVX2 (Intel Haswell 2013 / AMD Excavator 2015 or newer).
Fixed
  • Error messages no longer show a doubled Error: prefix. A failure while adding, removing, updating, installing, uninstalling, linking, or toggling a plugin or marketplace, applying a personality, or changing the Mermaid rendering setting now reads Failed to …: <reason> instead of Failed to …: Error: <reason>.

1.0.35

2026-07-24 pending release
Changed
  • slash-commands: drop unnecessary 'as SettingPath' casts.
  • atomic-write: allowlist hashline's deliberate node:fs-only temp+rename.

1.0.34

2026-07-24 pending release
Fixed
  • update: rich release-binary download-failure message.
  • update: verify self-update binary checksum, fail closed (parity with installers).
  • update: honest --check --force message when already up to date.

Veyyon is a fork of oh-my-pi 16.5.2 (MIT, by Can Boluk). Everything before the fork is upstream history, not a veyyon release. See oh-my-pi's releases for it.

History

Built on oh-my-pi.

Veyyon is a fork of oh-my-pi (MIT) by Can Boluk, shipped as its own product. Full commit history and per-release detail live on GitHub.