Changelog

Every agentty release — added, changed, and fixed. Sourced live from CHANGELOG.md in the repo.

[Unreleased]

[0.7.0] - 2026-09-04

Added

  • Ctrl+B — loop mode: send a message, and keep sending it until you stop. The "keep hammering on this prompt" workflow — iterate a refactor until it builds, re-run a failing test, poll until something converges — without retyping or holding Enter. Ctrl+B sends the composer's message and re-sends it automatically after every completed turn, until you press Ctrl+B again. Arming snapshots the payload rather than re-reading the live composer, so what repeats is what you armed. While armed the composer keeps displaying that prompt and becomes read-only — dimmed text, parked caret, every mutating keystroke dropped at the reducer's single entry point — because a box you could edit would let the display say one thing while agentty sent another; Ctrl+B is the deliberate exception so the mode is never a trap (Esc still cancels the turn). The composer grows a ⟳ LOOP ×N chip and takes a brand-tinted border while armed, so the gap between auto-sent turns never looks like nothing is happening, and an unbounded loop stays distinguishable from a hang. Arming on an empty composer is refused rather than entering a state that can never fire, and an explicitly queued message still outranks the standing loop.

The auto-sent message is indistinguishable from one you typed: it goes through the same submit path, so the model sees a plain user turn with no marker on the wire, in the transcript, or in the saved thread.

A loop backs off instead of hammering when a turn fails. Re-sending a rate-limited turn instantly is how a 429 deepens into a longer one, so a failure parks the next send behind a deadline: the provider's own Retry-After is obeyed verbatim when it sends one, and otherwise a consecutive-failure counter escalates a per-class schedule (rate-limit/auth 30s doubling to a 10 min cap; transient blips 5s to a 2 min cap) that resets on the next success — so a hiccup costs seconds while a sustained outage decays to a slow poll instead of spinning. Only Esc stops the loop outright. While waiting, the chip counts down (⟳ RETRY 24s) so a deliberately paused loop never reads as a wedged one. Design: docs/design/loop-mode.md.

  • Ctrl+/ inside the model picker scopes the list to one provider. Highlight any row and press Ctrl+/ to collapse the list to just that provider's models (title reads Models · GitHub Copilot only); press it again to go back to every provider. Your recents from that provider are kept, and the browse band retitles to <PROVIDER> MODELS.

Fixed

  • Custom OpenAI hosts show their models again (#30). A custom provider such as z.ai's GLM Coding Plan (https://api.z.ai/api/coding/paas/v4) came up with an empty model picker after 0.6.0, with no error. agentty's HTTP client had no content-encoding decoder, and that gateway gzips its /models JSON even when no Accept-Encoding is sent — so the model list arrived as compressed bytes, the JSON parse failed, and the picker was silently blank. (Chat still worked, which is why it looked provider-specific: the streaming transport already forced accept-encoding: identity; the model-list request did not.) Fixed in two layers so the whole class of bug is closed: every OpenAI-family request now sends accept-encoding: identity from the one place headers are built, and the client now decodes gzip/deflate responses anyway — a self-contained RFC 1952/1950/1951 inflater with no new dependency, a 64 MiB decompression-bomb cap, and a safe fall-through (unknown or corrupt encoding keeps the raw body and logs, never drops the response).
  • agentty no longer crashes on exit when stdin is a closed pipe (Windows). Launching under MSYS2/mintty with stdin already at EOF (type NUL | agentty.exe) faulted with an access violation. init() kicked the @ file-list walk and the # symbol scan on detached threads, which were still running when the process teardown freed the state they had captured. Both prewarms now own a joinable thread that main() joins before teardown — the same discipline the TLS-prewarm dial already followed.
  • Ctrl+C quits from anywhere. Every modal picker's key handler returns unconditionally, so a Ctrl+C pressed while the model picker, thread list, command palette or any other overlay was open got swallowed — you had to Esc out first. The quit check now runs before overlay routing, matching the documented contract that Ctrl+C is the one app-exit key.

Changed

  • The model picker browses one flat, alphabetical list. The "from this provider" / "from all other providers" split is gone: every non-recent model sorts alphabetically across providers under a single ALL PROVIDERS band, so finding a model no longer depends on knowing which provider you happen to be on. Section headers are now uppercased and hued per section (recent / models / not signed in) with a dim right-pinned count. Digits type into the filter like any other character — the 1-9 quick-select is removed, so glm-4, gpt5 and o3 search normally.

Internal

  • The 2,000-line view/pickers.cpp is split into view/pickers/{model,nav,tool,misc}_pickers.cpp over a shared pickers_common.hpp (viewport sizing, tier hue, the reasoning footer, a reusable section_header()), so touching one picker no longer recompiles them all. The public API is unchanged.
  • The CI perf gate no longer flakes on a loaded runner: the live-tail build-ratio check now also requires a meaningful absolute cost before it fires, and the mid-run frame gate keys on the median (the steady-state cost a user feels) instead of a p99 that one stalled frame can spike.

[0.6.0] - 2026-09-03

Changed

  • Smart Mode is now one switch and three slots — four rows, down from eleven. It shipped with a master toggle plus seven sub-layer toggles, which is a product hedging rather than an opinion. Three of those (Internal routing, Orchestration, Subagent routing) are folded into the master switch: nobody rationally ran Smart Mode with orchestration off, and "send my compaction summaries to the flagship instead of the cheap model" is strictly more expensive for no benefit. A toggle earns a row only where a reasonable user would reasonably choose either way. The other four (Learned routing, Outcome feedback, Speculative, Plan recall) are deleted: self-supervised loops that mutated routing from persisted per-workspace state, never measured against the fixed policy, each carrying a real correctness surface (two on-disk stores, a regret denominator a tool-heavy turn could inflate 5×, a decay schedule, a blend rule). The useful half of the signal survives where it costs nothing — the session cascade still reads delegation count, build failures and next-turn corrections, still decays and clamps, and now dies with the process instead of ratcheting one week's cost into the next. Net −1,012 lines of runtime code. Existing .agentty/routing_memory.tsv and the seven dead settings.json keys are simply ignored — no migration, nothing deleted from your disk. Developer escape hatches: AGENTTY_SMART_NO_INTERNAL, AGENTTY_SMART_NO_ORCHESTRATE, AGENTTY_SMART_NO_SUBAGENTS. The palette's "Reset Smart Mode learning" command is gone with the stores it reset.
  • AGENTTY_SMART_ENABLED is gone; AGENTTY_SMART_MODE is the one Smart Mode env pin. The two names were aliases with a precedence rule, which is a second source of truth and a question users shouldn't have to ask ("which one wins?"). AGENTTY_SMART_MODE=1|0 forces the master switch on/off for one process, as before; 1/true/yes/on are on, 0/false/no/off are off, unset = your saved setting. The Var::SmartEnabled registry row is deleted — env.hpp's every_var_has_row() bijection proof caught the leftover enumerator at compile time.
  • There is now exactly ONE model picker. Ctrl+/'s cross-provider picker absorbed the last job of the old single-provider one — Smart Mode role→model assignment — and the old picker is deleted. Opening a Smart Mode slot (Ctrl+S → Strategic / Implementation / Utility) now descends into the same picker in slot-assign mode: the title reads Smart Mode · pick Strategic model, Enter pins the role instead of switching your model, Esc goes back to the Smart Mode overlay at the row you came from, and the list is scoped to the active provider — because a slot's model is dispatched to whichever provider is live at turn time, so a cross-provider pin could never stream. Ctrl+/ inside the picker now closes it (it used to toggle to the second picker); Ctrl+R (toggle reasoning display) moved over. Deleted: the ModelPickerMsg domain and its 11 message leaves, model_picker_update, ui::model_picker, on_model_picker, ov::ModelPicker and its scroll state — one Msg domain fewer (19→18) and one fewer surface to learn. (smart_slot_picker_stack_test covers pin/pop/scoping; see docs/design/unified-model-picker.md §Consolidation.)
  • The unified model picker also tunes reasoning effort. Ctrl+/'s picker gained the reasoning controls — / cycle the highlighted model's effort tier, Ctrl+E toggles its per-model thinking override (with a live effort chip in the footer) — so switching a model and tuning how hard it thinks is one surface. The provider picker's ^/ cross-hop opens it too.

Fixed

  • Image paste over SSH works, and a large paste no longer freezes the UI. Four separate defects sat between Ctrl+V and a pasted screenshot, each of which alone was enough to break it. (1) A 1.2 s deadline. The clipboard read armed a 1200 ms timer and, on expiry, reported "your terminal didn't answer" unless a complete paste had landed. That is right for a local text read (a few hundred bytes, answered in microseconds) and hopeless for an image — base64 PNG measured in megabytes, streaming through ssh and tmux — so the bigger the screenshot the more certain the failure. The deadline now scales to the link (6 s when SSH_CONNECTION/SSH_TTY says there is a network to cross), and maya publishes clipboard_rx_bytes() so the timeout re-arms while bytes are still arriving instead of declaring silence mid-transfer: a transfer gets as long as it needs and self-terminates when the bytes stop. (2) A 256-byte read buffer. read_raw() took one 256 B read per poll cycle, and the loop around it is poll → read → parse → maybe render — so a multi-megabyte paste became ~16k poll/read round-trips with no frame drawn between them. Measured on a 4 MB payload: 16,384 poll-cycles → 1. Parsing was never the cost (4 MB parses in ~20 ms); the syscall count was. Both POSIX and macOS now drain the fd in 64 KiB chunks, bounded by a 4 MiB burst cap so a nonstop peer cannot starve rendering. (3) A stale capability verdict. maya probed tmux once per process, but #{client_termfeatures} describes whichever client is attached — so detaching a desktop terminal and reattaching from a phone (or simply fixing tmux.conf and reattaching) left every answer describing a terminal that was no longer there, and no amount of correct configuration could clear it short of a restart. Capabilities are now keyed by client identity with a cheap refresh_if_client_changed(), called at the one moment it earns a round-trip: a clipboard read has just failed and we are about to explain why. (4) mosh was invisible behind a persistent tmux. The mosh check walked our ancestry, which cannot work when the tmux server is launched by systemd — the mosh client attaches later, so mosh-server is a sibling of the server and never an ancestor of a process in a pane. A real mosh session was therefore diagnosed with the tmux wording. It now walks the tmux client's ancestry, where the transport actually lives. (maya terminal/{tmux,input}.cpp, platform/{posix,macos}/terminal.hpp; runtime/app/update/composer.cpp.)
  • An ordinary text paste no longer scolds you about kitty permissions. Every Ctrl+V over SSH was answered with "pasted text — kitty needs clipboard READ permission: add clipboard_control …", including right after adding exactly that and getting image paste working. Two mistakes compounded: clipboard_wanted_image is set for any paste (Ctrl+V is the only paste key, so "the user asked for an image" was never something we knew), and over SSH the local clipboard probes always fail, so every text paste fell through to the terminal query and landed in the arm meant for "asked for an image, got text". We cannot see what was on the clipboard, so a text reply is only suspicious when the terminal has no image dialect at all; if OSC 5522 is available, a text answer simply means the clipboard held text. The surviving message is now about a missing capability rather than permissions — and the kitty branch is gone, because it advised fixing a setting based on the false premise that a denied read is answered EPERM (kitty answers nothing, which is precisely why this was hard to diagnose). A warning that fires when nothing is wrong is worse than no warning: it trains people to ignore the one that matters.
  • Docs: clipboard.md leads with the fix and can diagnose itself. The page already contained the answer and still cost a full debugging session, because every layer here fails the same way — silence — and the page explained the mechanism before it gave the remedy. It now opens with the two-line kitty fix, adds the missing clipboard_max_size 0 (without which a screenshot can exceed the cap and be dropped, again silently), says loudly that kitty.conf must be edited on the machine kitty runs on (editing it on the remote host does nothing — the most expensive mistake available here), and ships a verified probe that isolates the failing layer in ten seconds: send DA1 and an OSC 52 read, compare byte counts. A control reply with a silent clipboard proves the terminal is refusing, which no error message can tell you — agentty can only observe that nothing arrived, so it names the likeliest gate and can point at innocent tmux settings.
  • The status bar and sparkline no longer collide or drift. At the tight width the activity row painted 1.9sCTX — the phase chip's elapsed tail flush against the context gauge's label, with no column between them. The middle slot was a bare spacer() (grow:1, natural width zero), which distributes slack but holds nothing open once there is none; the fit test asked only whether phase + right fit, never that anything separated them. The seam now reserves a real column, counted in the fit and drawn, so a rung that cannot pay for it sheds instead — collision is never the cheaper option. Separately, the token rate read 0.0 t/s at low values and 105.2 t/s at high ones: the number was left-aligned in a fixed field with the unit appended, so the field's slack landed between a quantity and its unit — one word, with a hole in it that changed width every frame. The number is now formatted tight, the unit glued with exactly one space, and the whole <number> t/s token padded as a unit; total width is unchanged, so the spark still never moves.
  • Smart Mode showed the wrong model: the badge said "Mistral" while every token came from GLM. Under orchestration the turn is dispatched on the resolved role model (strategic_profile.model), but both the status-bar chip and the assistant turn header read Model::d.model_id — the picker selection — so the UI contradicted the debug log, and the log was right. It lied retroactively too: because the header was derived from live state at render time, switching models relabelled every turn already in the transcript. A turn's provenance is a property of the turn, so it now lives there: Message::served_model / served_role are stamped once at StreamStarted, survive persistence, and are mixed into the per-message render key. The status chip names the in-flight routed model while a Smart Mode turn streams and falls back to the selection once it settles. (turn_provenance_test.)
  • Smart Mode delegations were invisible in the logs. The smart log channel existed but nothing ever wrote to it, so a debug file contained only the Strategic turn's wire traffic — subagent and utility dispatches appeared (if at all) as anonymous requests with no role, no parent, and no sign they were delegations. AGENTTY_LOG=smart=debug now yields the complete trace: one route.turn line per turn (role, model, effort, complexity, orchestrate/subagents/compacting flags) and one route.subagent line per worker launch (agent type, whether Layer 3b or the tier auto-router chose the model, parent model, chosen model).
  • Each Smart Mode turn is now labelled with the model that produced it, with a per-role accent so a scrolled transcript reads as a delegation trace: strategist (bright magenta), implementer (blue), utility (bright cyan) lead the turn's meta strip next to the model name.
  • ^E in the model picker wrote junk overrides for Claude/GPT models. The family gate — present in the old picker — was dropped when the arm was ported to the fused one, so pressing ^E on Opus or GPT-5 persisted a reasoning_effort_overrides entry that silently shadowed the family-decoded ladder instead of showing the "model-managed here (←/→ to set the tier)" hint.

Added

  • Nested skills: group folders below a skill root are now discovered. Skill discovery walked only one level (skills/<slug>/SKILL.md), so a library organized with group folders — skills/embedded/startup/SKILL.md — was silently invisible. Discovery now walks each root up to four levels deep: a directory holding a SKILL.md below the root is a skill, named by its path below the root (embedded/startup/embedded-startup). The derived name is what you type as /name, so it is held to the spec's a-z0-9- charset rather than assumed to match it — case is folded, anything else becomes -, runs collapse, and the result is length-capped; a path that sanitizes to nothing is skipped rather than given an invented name. Flat layouts keep their names unchanged; hidden directories are never descended into; on a collision the shallower skill wins within a root (ordered by depth explicitly — plain lexicographic order sorts a/b before a-b, which would have let an unrelated group folder silently displace an existing flat skill) and project still shadows user; the mtime cache signature covers every discovered SKILL.md, so adds/removes of nested skills invalidate as before. Both the depth cap and an in-walk entry cap keep discovery bounded: a skills root that happens to contain a large tree is no longer traversed in full on every cache miss. A nested skill's SKILL.md is no longer listed among its parent's tier-3 resources, and lint compares the frontmatter name against the spec-derived slug instead of the leaf directory (a nested skill never matches its leaf dir alone). (skills_engine_test.)
  • Cross-provider model switching is now first-class: one picker spanning every provider you're signed into. Ctrl+/ opens a single fuzzy list where each row is provider · model — type son for every Sonnet across Anthropic/Copilot/…, gpt for every GPT — and Enter switches provider, model, and account atomically in one step (no more "pick provider, wait, pick model"). A recent section (the models you actually toggle between) sits on top with the active model pinned and marked; providers you're not signed into appear as dim sign in to … rows that drop straight into that provider's login and return you to the picker. Ctrl+Tab quick-swaps to your previous model without opening anything. The switch is atomic by construction: commit_provider_switch gained a desired_model so the exact chosen model is installed instead of a per-provider recall/default. Catalogs for every authed provider load lazily and concurrently on open (the active provider is seeded instantly from the live list; each other resolves in place), and the recents MRU persists across restarts. Ctrl+P stays the surface for managing backends (custom hosts, accounts). Design: docs/design/unified-model-picker.md. (domain/catalog.hpp types, provider/auth_state.*, runtime/fused_models.hpp pure ranking core, FusedPickerMsg domain + fused_picker_update reducer, cmd::fetch_models_for, ui::fused_picker view; fused_models_test + fused reducer cases in provider_model_switch_test.)

Fixed

  • No more -Wunused-result warning from the log sink on fortified (distro) builds. Distros that inject _FORTIFY_SOURCE (Arch makepkg uses =3) activate glibc's warn_unused_result attribute on write(), and GCC deliberately ignores a plain (void) cast for such functions — so the one best-effort (void)AGT_WRITE(...) in logx::emit warned on every hardened build while upstream CI (no fortify) stayed quiet. It now uses the codebase's established (void)!write(...) idiom (same as the crash handler), which counts as using the result. (util/logx.cpp.)

[0.5.0] - 2026-08-28

Added

  • Tool cards render with real precision, prioritized by how often each tool is used. grep results are re-synthesized from the tool's markdown into maya's grouped GrepMatches table — path shown once per group, each match on a right-aligned row with its true file line number (derived from the search blocks) instead of the old dim tail-only preview that showed the closing code fence. read cards strip the tool's [showing lines A-B of N] footer and `SUCCESS: sym defined at … header (previously numbered *as file content*, skewing every line below) and mine them for the true first line — so **symbol reads** (no offset arg) and **tail reads** (negative offset) get correct gutters, and the header names the symbol (file.cpp · foo()). Edit/diff cards get a **line-number gutter parsed from @@ hunk headers** (per-side counters, blanked across an elided gap and resumed at the next hunk so a shown number is always true); a streaming write's tail slice numbers rows by their real file position instead of restarting at 1. The processstart/poll/stop trio now shares bash's **terminal-output** body (tail-anchored, exit codes, test/compiler-summary extraction) rather than the generic fallback, and head-heavy tools (repomap, outline, listdir, search) show their **head** instead of the pagination footer. (runtime/view/thread/turn/agenttimeline/{listbody,readbody,writebody,toolbodypreview,toolhelpers}.cpp, maya toolbodypreview.hpp; tooltimelineadapter_test`.)
  • Structured diagnostic log (AGENTTY_LOG). A designed logging system replaces the ad-hoc dbglog (fopen-per-line under a global mutex) and the scattered AGENTTY_*_PROF sites. RUST_LOG-style filtering across 11 channels × 5 levels (AGENTTY_LOG=warn,wire=trace,auth=debug), gated by one atomic load per call site so a disabled statement costs ~1 ns and formats nothing. The file opens once (O_APPEND, atomic appends, no write-path mutex) and rotates at 32 MB; AGENTTY_LOG=debug alone defaults to ~/.agentty/agentty.log. An always-on flight recorder keeps the last ~256 warn+ events in a lock-free ring even with file logging off, and the SIGSEGV/SIGABRT handler dumps it to stderr after the backtrace using only async-signal-safe writes — so every crash ships with what led up to it. AGT_SPAN adds RAII scope timing. util::dbglog forwards here (its ~40 catch-sites now feed the flight recorder); AGENTTY_DEBUG_LOG still works. New doc: Logging & diagnostics. (util/logx.{hpp,cpp}, util/dbglog.cpp, runtime/main.cpp; logx_test.)
  • AGENTTY_DEBUG_API now traces every wire. The raw request/response dump lived inside the Anthropic SSE header, so debugging a custom OpenAI-compatible host — the case that needs it most — logged nothing. Hoisted to a shared provider/debug.hpp; the OpenAI-family transport now dumps the request line, body (4 KB), status, and every chunk (2 KB). (provider/debug.hpp, openai/transport.cpp, anthropic/sse.hpp.)
  • Multiple accounts on one custom host via a #name tag. https://ollama.com/v1#work and …/v1#personal are distinct entries (separate API keys, models, picker rows); the fragment is stripped before dialing but kept on the row so accounts are distinguishable. Keyless local hosts are now saved (they used to vanish from the picker after a switch), and trailing-slash spellings normalise to one key. (openai/transport.cpp, runtime/app/update/login.cpp, runtime/view/login.cpp.)
  • AGENTTY_SMART_MODE / AGENTTY_SMART_ENABLED session pin. Force Smart Mode on (=1) or off (=0) for one process without touching settings.json — for CI, benchmarks, bisecting. Never persisted; the ^S overlay shows the pin and an in-app toggle becomes a hinted no-op while active. (domain/smart_tuning.hpp, util/env.hpp, runtime/app/init.cpp.)
  • Scroll inside a huge hunk in the review pane with ^D/^U (or PgDn/PgUp) — rows past the 24-line cap are reachable, bracketed by "↑ N above" / "… N more" indicators. (runtime/view/thread/turn/turn.cpp, runtime/app/update/diff.cpp.)

Fixed

  • The custom-host / llama.cpp “dead loop when prompted”, root-caused across seven mechanisms (field report). (1) Bare URLs now default the path prefix to /v1 (was /chat/completions, which every real local server 404s); explicit paths kept verbatim; list_models probes /v1/models on a 404. (2) llama.cpp streams failures as a named event: error (or a bare {code,message}) under HTTP 200 — previously dropped silently, fabricating (empty response) and re-firing forever; now surfaced as a typed StreamError (a 400 is terminal on the first try, a 503 keeps its budget). (3) A monotonic no_progress_failures latch (cap 6, reset only by real model output) stops a decay-window reset from looping a broken endpoint forever — applied on the main and compaction retry arms. (4) Local idle/stall timeouts stretched to 10 min (silent prompt processing on big models); the phase chip reads “processing…” until the first token. (5) Stale per-spec model recall is refused cleanly instead of 400-ing. (6) Server-neutral 404/connection hints (were Ollama-specific). (7) The connect-time model fetch surfaces “no response from host:port — is the server running?” immediately. (openai/transport.cpp, runtime/app/update/{stream,modal,cmd_factory}.cpp, domain/session.hpp, provider/error_class.hpp, runtime/view/{pickers,status_bar/phase_chip}.cpp; openai_transport_test.)
  • Reasoning UI/UX + streaming across all providers. Multi-block thinking capture (interleaved thinking emits several signed blocks — the old single-pair merge 400'd the replay); redacted_thinking blocks captured + replayed; Codex summary parts/items get paragraph separators; Ollama <think> tags carried across NDJSON frame boundaries; OpenAI empty-reasoning_content no longer shadows a populated reasoning; hidden-reasoning capture downgraded to heartbeats to avoid session bloat. (domain/conversation.hpp, runtime/msg.hpp, provider SSE parsers, io/persistence.cpp.)
  • Smart Mode: payload-aware routing + per-thread hygiene. The classifier read only composer text, so “fix this” + a 500-line paste under-routed as Simple — refine_with_payload now lifts the tier on attachment size/images. Complexity momentum, cascade bias, and the outcome signature no longer leak across thread switches; the overlay renders effective (not raw) layer state. (domain/complexity.hpp, runtime/app/{cmd_factory,update/picker,update/meta}.cpp.)
  • Every modal's open key toggles it closed (^P/^//^K/^S/^T/^O/^G/^R) — previously the dispatcher swallowed the key, and ^K even typed an invisible control byte into the palette query. Model ↔ provider pickers cross-hop. Login sub-modals Esc back one level (custom host → host input → key prompt), restoring typed state, instead of collapsing to the composer. (runtime/app/subscribe.cpp, runtime/app/update/{picker,login}.cpp, runtime/view/login.cpp.)
  • Permission + review panes made honest. Permission prompt is quittable with ^C (was swallowed); “always allow” says it persists and how to revoke it (via profile cycle). Review-pane ^X (revert everything) is a two-press confirm; Esc's commit semantics are named in the footer; the implicit-accept on submit and the turn-end “N files edited — ^R to review” are surfaced. (runtime/app/subscribe.cpp, runtime/app/update/{tool,meta,diff,modal,stream}.cpp, runtime/view/diff_review.cpp.)

Fixed (earlier)

  • Reasoning effort now works for hosted OpenAI-Chat reasoning models, and is user-configurable per model (issue #20). The effort chip (←/→ in the model picker) and the top-level reasoning_effort payload were gated on the Claude/GPT Family ladder only, so every compat reasoning model decoded to Family::Unknown and silently dropped effort — even though the Chat transport already emits reasoning_effort. Two layers now fix this:
  • Inference (zero-config default): a new orthogonal ModelCapabilities::reasoning_compat flag (decoded in from_id, kept independent of family so tier/context/output ceilings are untouched) recognizes Mistral (Small 4 / Medium 3.5 — mistral-small*, mistral-medium*), DeepSeek-Reasoner/R1, xAI Grok (grok-4\, grok-3-mini), Gemini *-thinking, and o-series and opens supports_effort() for them. These expose the 3-level low|medium|high enum, so a stale max/xhigh pick degrades to high instead of 400ing. Non-reasoning / native-reasoning siblings are excluded: grok-code-fast, codestral, deepseek-chat, and — crucially — magistral-*, which reasons NATIVELY and *rejects reasoning_effort (HTTP 422).
  • Per-model override ("configure it myself"): press ^E in the model picker to cycle the highlighted model's override auto → forced on → forced off → auto. It persists to Settings.reasoning_effort_overrides (settings.json), is pushed into the catalog at startup, and resolves via resolved_caps(id) with precedence per-model override > AGENTTY_FORCE_EFFORT env > catalog inference. Claude/GPT stay family-gated (the override only opens/closes the compat lane; it never fabricates max/xhigh). The picker footer names the current override state. (catalog.hpp, store.hpp, io/persistence.cpp, runtime/app/init.cpp, runtime/app/update/picker.cpp, runtime/app/subscribe.cpp, runtime/msg.hpp, runtime/view/pickers.cpp; model_caps_test — "compat reasoning effort (chat wire)" + "per-model reasoning override registry".)

[0.4.0] - 2026-08-25

Added

  • Six more built-in providers: native Kimi (Sign in with Kimi — OAuth device flow, no API key), plus API-key rows for DeepSeek, xAI (Grok), Mistral, Google Gemini, and Fireworks (--provider kimi|deepseek|xai|mistral|gemini|fireworks, or pick in ^P). The API-key providers each get a dedicated Endpoint::from_spec arm for their host/path (DeepSeek at the root, no /v1; Gemini's OpenAI shim nested under /v1beta/openai; Fireworks under /inference/v1) and read their key from the provider env var (DEEPSEEK_API_KEY, XAI_API_KEY, MISTRAL_API_KEY, GEMINI_API_KEY/GOOGLE_API_KEY, FIREWORKS_API_KEY), -k, or the in-app prompt. Capability inference now recognizes the deepseek-v4/-reasoner/-chat, grok, gemini, and magistral families as native tool-callers, and hosted providers ship a small bundled model seed so the picker shows models before a key is set. (registry.hpp, openai/transport.cpp, catalog.hpp, selection.cpp; openai_transport_test.)
  • Provider picker (^P) now has a live search filter — start typing to fuzzy-narrow the list (kimi, grok, deepseek…), matching on id, label, and blurb; Backspace trims. Mirrors the model picker (^/). The picker's rows (built-in providers + ACP agents + saved custom hosts + "Custom host…") are now one ordered, single-source row model shared verbatim by the reducer and the view, so the cursor and selection can never disagree — no more parallel index arithmetic.
  • Device-flow login modals copy BOTH the code and the URL. In the Copilot / Kimi sign-in modal, c copies the one-time code and u copies the verification URL (both via OSC 52, so they work over SSH); o re-opens the browser. The device-flow login path (state, worker, panel, key handling, completion) is now provider-generic — one DeviceWaiting state, one device_login_async worker, one panel_device_waiting — instead of duplicated Copilot/Kimi copies. agentty login now lists GitHub Copilot and Kimi alongside Claude / ChatGPT.

Changed

  • SSE anti-buffering headers are now single-source. The three directives that stop gateways from buffering/compressing a stream (cache-control: no-cache, no-transform, pragma: no-cache, accept-encoding: identity) were hand-written in all three streaming transports. Hoisted to http::sse_no_buffer_headers() / append_sse_no_buffer(); Anthropic, OpenAI-Chat, and Codex/Responses all route through it so the set can't drift. (io/http.hpp; pinned by openai_transport_test.)
  • Reasoning is now uniform across every wire (SSOT). Reasoning effort and streamed chain-of-thought were only wired on the Anthropic (thinking) and OpenAI-Responses (reasoning) transports; the OpenAI-Chat wire — which every hosted API-key provider uses — silently dropped both. Now: effort is copied once in lower_shared (no per-transport hand-copy); the OpenAI-Chat body encodes top-level reasoning_effort (o-series, DeepSeek, Grok, Groq, Magistral, Gemini-compat), gated by the same upstream effort_wire_for; and the Chat SSE parser reads reasoning_content/reasoning deltas into the shared StreamThinkingDelta event — so DeepSeek/Grok/Gemini thinking now streams and renders exactly like Claude's, with no Anthropic-wire detour. The duplicated build_tools (byte-identical in the OpenAI + Ollama transports) is hoisted to wire::openai_chat_tools, and the null-schema guard is shared with the Responses encoder. (provider.hpp, msg_shared.hpp, openai/transport.cpp, ollama/transport.cpp, chatgpt/responses.cpp.)

Fixed

  • Kimi sign-in works end to end. A cluster of fixes to the native Kimi device-flow OAuth: the device-code grant now uses the RFC 8628 URN (urn:ietf:params:oauth:grant-type:device_code) Kimi requires (was rejected as unsupported_grant_type); the verification URL is rewritten off the deprecated, dead-end www.kimi.com host onto www.kimi.ai; the modal shows and opens the code-embedded URL so it works over SSH/mosh where the browser can't auto-open; and Kimi's X-Msh-* device-identity headers are sent on the OAuth and the API (chat + models) requests, matching the official kimi_code_cli — without them the model catalog came back empty. When a Kimi Code account is out of balance the API returns an opaque HTTP 500; agentty now probes /usages and surfaces a clear "Kimi credits exhausted" message instead. (src/provider/kimi/*.)

[0.3.1] - 2026-08-21

Added

  • Tool-heavy turns finish faster — parallel batches + speculative reads. Independent tool calls in one turn now run concurrently (only genuine conflicts serialize; the effect- and path-aware scheduler makes a wide batch always safe), and a pure read-only tool starts the instant its arguments finish streaming — while the model is still writing the rest of the turn — so its I/O overlaps the remaining stream instead of waiting for the turn to end. On a multi-tool turn this hides seconds of file/search time inside the model's own generation. Neither changes results, only when the work happens. The system prompt now nudges the model to fan out independent calls into one message. Optional per-turn telemetry (AGENTTY_CACHE_PROF=1/tmp/agentty-cache-prof.log) records prompt-cache hit ratio, per-model TTFT, and tool batch-width. (src/runtime/app/update/stream.cpp, src/runtime/app/cmd_factory.cpp, src/provider/anthropic/prompt.cpp; speculative_dispatch_test.)
  • The connection re-warms itself after an idle pause. agentty already opens the TCP+TLS+HTTP/2 connection while you type so the first request skips the handshake; now, if a session sits idle long enough for the pooled connection to lapse (~90 s), the next keystroke silently re-dials in the background — so a message after a break is as fast as one mid-flow. (src/runtime/app/update/composer.cpp.)

Fixed

  • Rewinding to a checkpoint now removes files the agent created after that point. A rewind is meant to restore the worktree bit-for-bit to the checkpointed instant, but files the agent created after the checkpoint were silently left behind — the tree came back "touched files put back," not truly restored. The internal file listings used git's NUL-separated (-z) output, and the shared subprocess runner scrubs terminal-control bytes from all captured output — including those NUL separators — so both the snapshot and current-file sets parsed to empty and the delete-half of the restore never ran. The listings now use newline separation with raw (unquoted) UTF-8 filenames; a filename literally containing a newline is skipped rather than mis-deleted. Edited files rewind, deleted files return, created files are removed, and git-ignored files (build output, caches) are never touched. New end-to-end checkpoint_test runs the full create→mutate→restore lifecycle against a real scratch repo. (src/workspace/checkpoint.cpp; checkpoint_test.)
  • Tool results no longer vanish onto a dead card when a provider reuses tool-call ids. Some OpenAI-compatible gateways mint a deterministic id per (tool, index) — literally "bash:0" for every bash call on every turn — instead of a unique ToolCallId. One agent turn holds several assistant messages in the live tail, so the next sub-turn's "bash:0" collided with the previous one's (already Done): the old id lookup matched the first one, so the result was stamped onto the dead card, the real call stayed Pending, and its card hung until the step timeout. Result/progress/timeout/permission routing (with_live_tool) now prefers the first non-terminal call carrying the id, and streaming assembly is already scoped to the current sub-turn's message — so every tool card completes with its own output. (include/agentty/runtime/app/update/internal.hpp; dup_tool_call_id_test.)

[0.3.0] - 2026-08-14

Added

  • GitHub Copilot as a first-class native provider (agentty login → GitHub Copilot, or --provider copilot). Use your existing Copilot subscription's models — no API key. Sign-in is GitHub's device flow, fully in-TUI: pick GitHub Copilot in the provider picker (^P) and a modal shows the one-time code + opens the browser (c copies the code via OSC 52 so it works over SSH), polls in the background, and switches on approval — exactly like the Anthropic / ChatGPT OAuth flows. Everything is uniform with the other native providers: the picker row reflects real sign-in state (⚠ sign in / ✓ signed in · accounts), and Enter on the active row opens a full multi-account manager (switch / add / remove GitHub accounts). Auth is a short-lived Copilot proxy token exchanged from a persisted (encrypted) GitHub token and auto-refreshed mid-session (skew-safe, single-flight, cross-process-locked), routed to the account's own inference host (endpoints.api — Individual/Business/Enterprise). Model listing is entitlement-aware: it reads the account's real plan + quota (/copilot_internal/user) and shows only the models the subscription can actually use, usable ones starred on top. And it implements Copilot's “Auto” mode — the server-side router that lets Free/Limited plans reach premium models (Claude, GPT-5) that a direct request would reject — as a first-class Auto (best available) entry plus the auto-reachable models, driven by the /models/session + Copilot-Session-Token protocol. (src/provider/copilot/; copilot_token_test.)
  • Smart Mode — a self-supervised orchestrator that routes each turn and gets better at your repo (Ctrl+K → Smart Mode, config overlay on Ctrl+S). A role-based execution router built on the orchestrator-workers pattern (Anthropic's multi-agent research + the RouteLLM/cascade literature), with everything toggleable and off = a byte-for-byte no-op. You pin three models to three roles — Strategic (your flagship: the thinking), Implementation (mid: writing code), Utility (cheap: grep/read/summarise) — or leave them to zero-config auto-fill from your live catalog. Nothing ever checks a model name; one pure, tested resolver (agentty::smart) maps role → (model, effort). Layers, each an independent toggle in the overlay:
  • Internal routing — engine-internal utility work (the auto-compaction summary) runs on the cheapest capable model, never the flagship.
  • Orchestration — the main turn runs on Strategic and a <smart-mode> directive teaches it to keep the decisions and DELEGATE mechanical work to subagents (task explorer/coder), with a complete brief, in parallel, wide-then-narrow.
  • Subagent routing — each subagent's model resolves by its role (explorer→Utility, reviewer→Strategic, coder/tester→Implementation).
  • Complexity-scaled effort — a local classifier rates each turn Trivial/Simple/Standard/Complex and scales the Strategic model's reasoning effort accordingly (conservative: ambiguity biases up).
  • Cascade feedback — the effort heuristic self-corrects within a session from what the orchestrator actually did (heavy delegation ⇒ it was harder than rated).
  • Learned routing (learns across sessions) — a per-workspace prior (.agentty/routing_memory.tsv, Beta-smoothed) remembers whether each class of turn was under/over-rated in this repo, so the router improves the more you use it — something a stateless router structurally can't do.
  • Outcome feedback — the ground-truth signal: a failed build/test in the turn, or a user correction on the next turn ("no", "that's wrong", "revert"), is a routing regret that re-rates that class of turn.
  • Speculative (opt-in) — on Complex turns, a detached local retrieval warm-up runs while the lead thinks, so the workspace grounding is hot by the time it delegates.
  • Plan recall — successful decompositions are captured per-workspace (.agentty/decompositions.jsonl) and the closest past one is injected into the delegation prompt as a concrete few-shot, so the orchestrator reuses what worked instead of re-deriving it.

Every orchestrated turn surfaces its routing DECISION as a first-class 🧠 Smart Mode card in the transcript (routed model · scaled effort · complexity · active layers); the delegations themselves render as ordinary task tool cards. Persisted to settings.json; single-provider; single-model/Opus-only accounts degrade to no-op per layer. Design: docs/design/smart-mode.md. Tested: smart_mode_test, complexity_test, routing_memory_test, decomposition_memory_test.

  • A single "RAG" picker for proactive retrieval (Ctrl+K → RAG). One decision instead of a wall of toggles: On (inject retrieved context before every turn) / First turn only (ground the first turn, then stay quiet) / Off. Persisted; the advanced retrieval knobs remain env-tunable but out of the UI. Proactive <retrieved-context> blocks no longer leak into the composer's ↑/↓ history recall.
  • Fork a thread (Ctrl+K → Fork thread) — escape a full context window for near-zero tokens. Branches the current conversation into a fresh thread (records forked_from provenance) that carries almost no context: the parent's full transcript is exported to disk and the fork holds only a small pointer the model reads on demand — so forking is O(1) in tokens no matter how big the parent got, with nothing lost (the transcript is verbatim, not a lossy summary). The new thread opens with a ⑃ Forked card and you pick its proactive-RAG behaviour (per turn / first turn only / off). The original thread is saved untouched. The exported transcript is bounded (512 KB, recency-biased, per-message clip) so even a maxed-out parent forks cheaply. Design: docs/FORK.md. Tested: fork_test, transcript_bound_test, compaction_wire_test.

Fixed

  • A running tool card no longer stays invisible while the reply is still revealing. A tool card that had already flipped to Running could show as running in the status line but have no card in the transcript. The turn view defers a freshly-arrived tool panel off-screen for a beat so it doesn't pop in mid-reveal-glide — but the defer flag was cleared by a per-frame state machine, so if the stream went quiet and no follow-up frame fired, the flag stuck and the running card stayed hidden until some unrelated event (a tick, a keystroke) nudged a repaint. The panel is now never deferred once any of its tools is actually executing (Running/Done/Failed) — deferral only exists to smooth the brief prose-glide-vs-fresh-Pending-card window, so an executing tool's card shows immediately regardless of the defer machine's state. Fixed at both panel-append sites (the single-message body path and the run-merge path). (src/runtime/view/thread/turn/turn.cpp; tool-boundary / frozen / seam / midrun guards all green.)
  • Multiple open instances no longer thrash into an OAuth refresh loop when the token expires. With several agentty windows open, an expired OAuth token sent them all refreshing at once. Refresh was guarded only by a process-local mutex, so each instance fired its own refresh POST — and because Anthropic (and ChatGPT/Codex) rotate refresh tokens, the first refresh invalidated the shared token, the losers refreshed against a now-revoked one, and every instance kept seeing a token it didn't mint and refreshed again: the loop. A cross-process advisory file lock (auth::CrossProcessFileLock — POSIX flock / Windows LockFileEx on <creds>.lock) now serializes the refresh across processes, and a double-checked re-read after the lock lets the losers adopt the winner's freshly-saved token instead of refreshing again. Best-effort: if the lock can't be taken the old behaviour stands (no worse). Wired through every concurrent refresh path — the per-request refresh (subagent workers), startup refresh, and 401 recovery, plus the ChatGPT/Codex path. (src/io/auth.cpp, src/provider/chatgpt/codex_oauth.cpp, src/runtime/app/cmd_factory.cpp; new fork-based cross_process_lock_test proves a second process blocks on the lock until the first releases.)
  • The streaming markdown reveal no longer pops a whole paragraph into view in one frame. A long, soft-wrapped paragraph would materialise smoothly and then, the instant its closing newline arrived, dump its entire remaining body at once (measured as a +172-cell one-frame jump at a paragraph→blockquote seam). The reveal clip rounded up to the end of the completed source line — but prose is one source line per paragraph that wraps at render time, so “the completed line” was the whole paragraph. The tail is now gated exactly at the reveal cursor (with a block-boundary cap so a finished block stays the reveal's last leaf), which is scrollback-safe because the uncommitted tail is redrawn in place every frame. Measured on the deterministic replay harness: worst streaming frame dropped from 172 to 22 cells (tour fixture) and 27 to 6 (smoke), both under the 24-cell CI gate; smooth- and bursty-feed reveal probes pass. A running tool's event-header elapsed is also now frozen (the live seconds live only in the seam-safe footer) so a growing tool panel can't rewrite a committed row's timer. (maya streaming/build.cpp; src/runtime/view/thread/turn/agent_timeline/agent_timeline.cpp.)
  • Tools now resolve relative paths against the active project, not the access boundary — fixing widened---workspace launches. agentty keeps two distinct roots: the access boundary (workspace_root(), the security gate that file tools refuse to cross, widenable all the way to --workspace /) and the active project (project_root(), the launch cwd clamped inside the boundary). A class of tools conflated them, resolving “the project” against the boundary — so under --workspace / a model's read src/foo.cpp resolved to the nonexistent /src/foo.cpp, repo_map tried to map the entire disk, diagnostics/test ran cmake --build /build / --manifest-path /Cargo.toml, and the @-file picker + symbol index scanned all of /. All of these — plus grep/glob/list_dir defaults, find_definition, git tools, checkpoints, and project-scoped remember — now route through the new centralized util::project_root(), so relative paths and project-scoped defaults land in the launched project regardless of how wide the boundary is. Every path is still containment-checked against the boundary afterward, so widening it can never let a relative path escape. By default the two roots are the same directory, so ordinary launches are unchanged. (mcp-cpp fs_helpers/git/diagnostics/repomap; src/tool/util/fs_helpers.cpp, src/workspace/{files,symbols,checkpoint}.cpp, src/tool/memory_store.cpp; 18/18 mcp-cpp + agentty tool tests green.)
  • grep and find_definition no longer crawl — or return hits from — build/vendor/_deps trees. The ripgrep-backed grep passed no directory excludes, so on a cold cache it stat + gitignore-checked every generated file in out-of-source build trees (tens of thousands in a repo with build-*/_deps dirs), and in a repo with no .gitignore it returned matches straight out of build artifacts. Both backends now prune the same skip-list (build*, cmake-build*, node_modules, _deps, vendor, …) the built-in walker already used, passed to ripgrep as -g '!…' excludes. Measured: on a tree with non-gitignored build dirs a symbol grep dropped from 4 hits (3 generated) to the 1 real source hit, and the walk of the generated trees is skipped entirely. (mcp-cpp search.cpp/fs_helpers.cpp.)
  • Thread list is ready instantly at startup (was ~1 s on a large history). The thread picker only needs each thread's title + timestamps, but those keys sit after the multi-MB compactions/messages arrays in every thread file, so the metadata-only load still had to stream every byte of every file to reach them — ~1 s for a 247-thread / 281 MB history, during which opening the picker (Ctrl+J) or cycling threads stalled. A small threads/index.json sidecar now caches that metadata (id → title/created/updated + file mtime); startup reads that one ~30 KB file and only re-parses threads whose on-disk mtime changed, cutting the warm load from ~1000 ms to <1 ms (~1400×). The index is refreshed on every save/delete and self-heals if missing or corrupt (delete it to force a cold rebuild). (src/io/persistence.cpp.)
  • Kill-to-end-of-line in the composer works again, now on Alt+K. The readline-standard Ctrl+K kill-to-end binding was dead: Ctrl+K is claimed app-wide for the command palette before the composer sees the key, so the composer's kill-to-end arm was unreachable. Rebound to Alt+K, which pairs with Ctrl+U (kill-to-start) the same way Alt+D (delete word forward) pairs with Ctrl+W (delete word back). (src/runtime/app/subscribe.cpp; new reducer tests in composer_edit_test, 20/20 green. Full composer keymap now documented in the README and the keybindings page.)

[0.2.12] - 2026-08-04

Fixed

  • Composer editing hardened — a queued-message data-loss bug, per-keystroke undo, and three input papercuts. A sweep of the message composer across its reducer, view, and widget layers fixed six issues. (1) Editing a peeked queued message could silently delete the wrong one. After Alt+↑ loaded a queued message for editing, the peek index survived an ordinary keystroke, so pressing Enter sometimes removed a different queue slot than the one on screen; a stray edit now cleanly drops the peek and becomes the live draft (symmetric with how history-walk already behaved). (2) Undo now rewinds word-by-word, not character-by-character. Ctrl+Z used to undo a single character and a long sentence blew the whole 64-deep undo history; consecutive typing now coalesces into one undo unit, broken on whitespace and on any non-typing op (paste, delete, cursor move, undo/redo, history/queue walk), so one Ctrl+Z after a paste actually reaches the pre-paste state. (3) The live token / word / line counters were wrong whenever an attachment existed — they counted the short chip caption ([Pasted text · 412 lines · 14 KB]) instead of the payload, so a 400-line paste read as “1 line, ~10 tok”; the counter now measures the expanded attachment bodies, i.e. what actually goes to the model. (4) Ctrl+←/→ word motion now steps over a run of punctuation as one unit ()))) was four presses). (5) The / command palette opens on any line-leading slash, not only on a completely empty composer, matching shell muscle memory and the existing @/# word-boundary rule. (src/runtime/app/update/composer.cpp, src/runtime/view/composer.cpp, maya widget/composer.hpp; six new reducer tests in composer_edit_test, 17/17 green, no flicker regression.)

Added

  • ACP bash cards now always show their output, and edit/write cards render identically. Two card-rendering defects when agentty runs as an ACP agent: (1) a bash run through the live-terminal fast path attached only an ACP terminal content block — and because agentty releases the terminal the instant the command finishes, Zed dropped the released widget and the completed card showed nothing. The completion now also carries the captured stdout/stderr as a fenced text block ((no output) for a silent command), so the output is always visible and survives session reload; the internal (sandboxed / no-terminal-capability) path fences command output as a monospace log instead of markdown-escaping it. (2) write announced its diff with a null oldText (a special "new file" affordance) while edit announced a normal before/after, so the two looked different in Zed; write now announces an empty-string oldText, giving both the same diff-card shape, and the authoritative on-disk diff still replaces it on completion. (src/acp/server.cpp: run_bash_via_terminal() completion, result_content_block() execute-kind fencing, announce_diff(); covered by a new bash-output assertion in acp_integration_test.)
  • ACP shell parity with Zed's native agent — slash-command menu, model picker, sign-in, and live thread titles. Running agentty as an ACP agent now lights up the same panel affordances Zed gives its own agent, closing the "second-class external agent" gaps: (1) session/new + load emit an available_commands_update so Zed's composer / menu is populated with /compact, /new, and every installed skill as /<skill-name>; (2) a model config_option_update advertises the provider's model catalog, so you can switch models from Zed's per-session dropdown instead of relaunching with -m (the existing session/set_config_option already applied the choice — now it's discoverable); (3) when agentty has no credentials, initialize advertises an authMethod, so Zed renders a "Sign in" prompt instead of erroring on the first turn; (4) the first message of a session pushes a session_info_update with a derived title, and restored sessions re-announce their title on load, so Zed's thread sidebar always shows a meaningful name. (src/acp/server.cpp: available_commands(), model_config_option(), emit_session_config(); covered by new assertions in acp_integration_test.)
  • ACP tool cards render like the native agent — Read results are line-numbered, tool output is markdown-safe, and every card has a body. When agentty runs as an ACP agent (driven by Zed or any ACP client), a completed read now sends its card body as a fenced, tab-numbered excerpt (1\talpha, 2\tbeta, …) anchored at the read's offset, matching claude-code-acp so Zed renders a clean gutter-numbered file view instead of a raw blob. All non-diff tool result text is now markdown-escaped, so literal *, backticks, #, |, and < in file/grep/command output render verbatim in the card instead of being parsed as bold/headers/tables/HTML. Announce-time card bodies were widened to match claude-code-acp so no card renders as a bare title: bash/test/diagnostics show the fenced command, web_fetch shows its prompt, task shows the sub-agent prompt (now also on replay), and **any MCP / unknown tool pretty-prints its raw input as a ``json block**. The same shaping is applied on session replay, so a restored thread's cards look identical to when they first ran. (src/acp/server.cpp: resultcontentblock(), markdownescape(), widened commandcontent(), promptcontent() wired into maketoolcall; covered by assertions in acpintegration_test`.)
  • The RAG engine now shows its work — a retrieval “funnel” in every search_docs/search_code result. Retrieval used to be an opaque black box: the model (and you) got a ranked list and a terse (mode: hybrid+ctx, reranked) label, with the per-stage trace hidden behind an env var nobody set. Now every result is headed by a readable funnel that walks the candidate set through each stage it actually passed, with the real counts the engine recorded — e.g. hybrid: 47 candidates ↳ reranked top 30 ↳ dedup 30→24 ↳ stitch: merged 3 adjacent ↳ autocut 24→8 ↳ top-8 — plus a one-line headline naming the retriever, the fusion method, and the confidence. You can see why eight passages came back instead of trusting a label. (AGENTTY_RAG_TRACE=0 restores the compact one-line header.)

Changed

  • Retrieval quality upgraded to rag-cpp's measured-best pipeline. agentty's hybrid search now defaults to adaptive convex (TM2C2) fusion instead of plain reciprocal-rank fusion — rag-cpp benchmarks it as beating RRF on NDCG because it preserves the score distribution RRF discards, and the adaptive variant additionally shifts per-query weight toward whichever retriever (lexical vs. dense) is more confident on that query. Two new refinement stages from rag-cpp's Pipeline::best() are wired in: near-duplicate dedup (folds paraphrase/boilerplate copies so an LLM context window isn't spent re-reading the same passage) and relevance autocut (trims the low-relevance tail at the score knee, so a query with three strong answers returns three, not k padded with weak matches). All are on by default and individually toggleable (AGENTTY_RAG_FUSION=rrf, AGENTTY_RAG_ADAPTIVE, AGENTTY_RAG_DEDUP, AGENTTY_RAG_AUTOCUT). Also picks up rag-cpp's BlockMax-WAND BM25, robust (winsorized) fusion, AVX-512/VNNI kernels, and a cache-backed rerank stage under the hood.
  • Retrieval spends fewer tokens for the same answer — five research-backed frugality levers on the search_docs/search_code output path. Retrieval is the one place agentty spends model-context tokens on your behalf, and the output budget was previously flat and split evenly by passage count — a rank-8 hit at confidence 0.11 got the same byte allowance as the rank-1 hit at 0.88, i.e. equal tokens on signal and noise. The output path now: (1) applies a relevance floor (AGENTTY_RAG_RELEVANCE_FLOOR, default 0.30) that drops the low-confidence tail the model ignores anyway before spending any tokens on it; (2) allocates the budget by score-proportional water-filling (AGENTTY_RAG_BUDGET_GAMMA, default 1.5) so confident passages get room to be complete and marginal ones get a tight excerpt; (3) scales the total budget by CRAG confidence (AGENTTY_RAG_CONF_BUDGET_FLOOR, default 0.45) so a barely-passing retrieval injects a cheap block instead of reserving the full ~3k tokens; (4) compresses oversized prose passages with a model-free, LLMLingua-style extractive pass (AGENTTY_RAG_EXTRACTIVE, on by default) that keeps the highest query-overlap sentences and drops the filler between them — something the old contiguous line-window couldn't (code/config keep the line-window, where contiguity is load-bearing); and (5) caps the unprompted proactive <retrieved-context> block independently and more tightly (AGENTTY_RAG_PROACTIVE_BYTES, ~6 KiB) since it's spent without the user asking. On a realistic 161-file corpus these cut retrieval output ~13% (1,845 → 1,605 est. tokens/query) with identical ranking (recall@10 1.000, MRR 0.968, nDCG@10 0.976) — savings scale with passage size. A new AGENTTY_RAG_MEASURE=1 agentty rag-bench mode runs queries through the real output path and reports bytes/estimated-tokens so you can quantify any lever on your own corpus by toggling it and diffing. Every lever is on-by-default-and-tunable via env, no rebuild. The Retrieval and Configuration docs cover each knob.

[0.2.11] - 2026-08-03

Fixed

  • Model picker now sorts by actual strength, not a hardcoded family bucket. The previous sort ordered models by a fixed family position (Opus, Sonnet, Haiku, Fable, Mythos, in that order), so the Fable/Mythos lane — Anthropic's newest flagship-tier models, same strength class as Opus, just a different codename — always sank to the bottom of the picker regardless of how new or capable it actually was. The new agentty::model_picker_less comparator (include/agentty/domain/catalog.hpp) is the single source of truth for picker ordering: ModelCapabilities::tier() descending (Flagship — Opus and Fable/Mythos — always leads, then Mid/Sonnet, then Cheap/Haiku, then Weak), then newest generation/revision within a tier, then a family tie-break for stable grouping among same-generation peers. Any future family name now sorts by what it is, not by where its name happened to land in a hand-written list.

Added

  • Smart, tunable auto-compaction + entitlement-safe 1M-context model variants. Auto-compaction used to fire at a fixed absolute margin (context_max - 17k), which was inconsistent across window sizes (98% on a 1M window, 91% on 200K) and forced a summarization pass long before a big-window model actually needed one. It now fires at a percent of the window (StreamState::compaction_threshold(), default 90%, clamped to always leave 20K tokens of output headroom) that's user-tunable from the command palette's new Compaction depth entry (75% Aggressive → 90% Balanced → 95% Deep, persisted to settings.json). Separately, the summarization request itself now runs on the cheapest capable model on the active provider instead of the flagship model you're chatting with — compacting used to mean a full ~context-max-sized flagship-priced input on every trigger; it now costs a Haiku-class summary. On the model side, the picker offers a (1M context) variant right below every Sonnet/Opus/Haiku 4+ model when signed in with Claude Pro/Max OAuth — matching Claude Code's own model catalog (verified against its shipped binary: the base window is always 200K, and 1M is an explicit, entitlement-gated picker row, never auto-detected from account tier). Selecting it widens the tracked context window to 1M and requests Anthropic's extended-context beta; the picker marker never reaches the wire.
  • repo_map is pollution-proof against nested projects. The ranked codebase skeleton could surface sibling-project source (submodules, vendored checkouts, unrelated repos living under or alongside the workspace). The walk now stops at any nested repository boundary (a subdirectory carrying its own .git/.hg/.svn/.jj — including a submodule's gitlink file, not just a directory) and re-asserts every accepted file is genuinely inside the workspace before it becomes a graph node.
  • Subagents route to the cheapest capable model on the active provider. Read-only task roles (explorer, reviewer) no longer spend flagship-model tokens on fan-out exploration — they run on the cheapest model that still passes a capability floor (tool support, dispatchable asset, non-Weak tier); tester/coder/general keep the parent model since they mutate the workspace. Model strength is derived provider-relatively from the id (no vendor ships a comparable power number), the router never routes up, and a single-model or Opus-only account sees no change. Subagents also never enable reasoning/thinking beyond the parent's setting.

Changed

  • Provider transport ingress is fully unified across all four transports (Anthropic, OpenAI-compat, Ollama-native, ChatGPT/Codex Responses) — no more per-transport copies that could silently drift. Retry-After backoff parsing, strict UTF-8 scrubbing, the leaked-tool-call prefix sniffer, all three token-usage wire shapes, and OpenAI-family auth-header emission each now live in exactly one shared helper. Fixed real bugs found while unifying: Ollama's Retry-After parsing was silently ignoring server backoff entirely, and two transports accepted invalid overlong/surrogate UTF-8 that the canonical scrubber now rejects.
  • State-of-the-art MCP authorization — interactive OAuth 2.1 + PKCE login for remote servers (spec 2026-07-28). agentty can now sign in to an OAuth-gated MCP server end-to-end: agentty mcp-login <server> probes the server, walks the RFC 9728 protected-resource → authorization-server metadata chain, registers a client, opens your browser at the PKCE (S256) authorize URL, catches the redirect on an ephemeral loopback callback server, and exchanges the code for an issuer-bound token that's sealed at rest (chmod 600, ~/.agentty/mcp_tokens/<server>.json) and auto-refreshed on expiry — the HTTP transport injects a fresh Bearer per request. mcp-logout <server> clears it; mcp-status lists every configured server and which are authorized. The full 2026-07-28 hardening is implemented in the dependency-free, unit-tested mcp-cpp auth layer: RFC 9207 issuer validation (SEP-2468 — the authorization response's iss is checked against the AS issuer before the code is redeemed, closing the AS mix-up attack), application_type=native DCR (SEP-837, so the loopback redirect a CLI needs isn't rejected), issuer-bound credentials (SEP-2352 — a token is stamped with the AS that minted it and never replayed elsewhere), and CIMD (Client ID Metadata Documents — present a stable https:// URL as the client_id with no DCR round-trip). Client registration falls through three paths in 2026-07-28's preferred order: a --client-id <https-url> / mcp.json "client_id" CIMD URL, a pre-registered public client_id (also via $AGENTTY_MCP_CLIENT_ID), else Dynamic Client Registration — so login works even against an authorization server that offers no DCR endpoint. A 401 from a server is parsed (RFC 9728 WWW-Authenticate challenge → resource-metadata URL, with a /.well-known/oauth-protected-resource fallback) into an actionable error telling the user to run mcp-login. Portable SHA-256 (FIPS 180-4 KAT-verified), base64url, and PKCE are all in-tree; the loopback server is Winsock/BSD-portable and the whole path is Windows-verified. The stateless MCP server helpers were hardened alongside it (CBOR-based binary-safe request-state codec that no longer crashes on non-UTF-8, endianness-stable MAC, require_capabilities(), SEP-2243 header-routing validation) and the protocol surface caught up to 2026-07-28 (tasks/update, subscriptions/listen, a deprecated-method registry).

[0.2.10] - 2026-07-27

Fixed

  • Windows: the MSI now installs per-user with no admin / UAC prompt. The installer was perMachine — it wrote to %ProgramFiles% and the system PATH, so a plain double-click hit a UAC elevation wall. It's now perUser: agentty installs to %LocalAppData%\Programs\agentty, edits only your PATH, and registers a per-user Add/Remove-Programs entry — nothing needs administrator rights (the binary is self-contained; the PATH edit is yours). The winget manifest declares Scope: user to match, so winget install agentty never tries to elevate either.
  • Windows builds again (and the whole release is green on all six targets). The rag-cpp retrieval engine isn't yet MSVC-portable (POSIX headers and GCC-only SIMD attributes); rather than block every Windows package, agentty degrades gracefully on MSVC — CMake skips retrieval and the adapter compiles a no-op fallback, so the Windows binary ships with every non-retrieval feature working. The macOS standalone build no longer dies configuring rag-cpp's Metal backend (forced off — agentty's retrieval is CPU-only).
  • Package channels can no longer silently fall behind a release. Every downstream publisher (AUR / Homebrew / scoop / winget) is gated behind a build leg, so one failed/slow leg used to skip its publisher — the tag went public but the package stayed stale (which is exactly how an AUR out-of-date flag and a winget hash-mismatch happened). reconcile-manifests.yml now re-pins AUR/Homebrew/scoop from a release's SHA256SUMS automatically after every release run and weekly; the winget submission gates on checksums-final and verifies the MSI hash against SHA256SUMS before opening a PR, so it can never submit a hash that drifted from the released asset.

Changed

  • Homebrew is a clean two-line install (brew tap 1ay1/tap && brew install agentty); the formula now installs by the release asset's real name and prints a first-run hint.

[0.2.9] - 2026-07-27

Added

  • Retrieval got faster without getting weaker — one batched embed round-trip, and two research-backed vector-cost levers. A latency + throughput pass over search_docs that changes nothing about default result quality (the fast wins are pure plumbing; the new precision knobs are opt-in and rescore back to full fidelity). (1) One /api/embed round-trip per source, not N. A single search fans into many dense probes — the query, conversation-carryover + multi-hop facets, RAG-Fusion paraphrases, a HyDE passage — and each used to embed in its own blocking round-trip, serially (5–8 on a fully-expanded query). They now batch into ONE call per source (/api/embed already accepts an array): Corpus::search_fused pre-embeds every variant together, and — the load-bearing half — KnowledgeRouter::retrieve_multi now asks each source once for all variants (via a new KnowledgeSource::retrieve_multi seam that CorpusSource/McpResourceSource override) instead of looping single-query retrieves, so the batching actually reaches the funnel. (2) Matryoshka ANN truncation (AGENTTY_RAG_ANN_DIM, off by default) — nomic-embed-text-v1.5 and the e5/BGE MRL models pack their signal into the leading dims, so the HNSW graph can be built + walked on a dimension prefix (e.g. 256 of 768): ~2.3× faster graph walk at ⅓ the graph memory, with the full-dimension rerank stages recovering any precision. A changed value auto-rebuilds the cached graph; no cache-format bump. (3) Binary quantization (AGENTTY_RAG_BINARY, off by default) — walks the graph on 1-bit-per-dim sign codes with popcount Hamming, then rescores the returned pool with the exact float cosine (the HuggingFace “binary embedding quantization” pattern): binary recall, float precision (~0.97 recall@5 in-repo), ~2.5× faster (≈ 3.4× stacked with truncation). Sign codes are derived from the stored vectors on load, so the on-disk cache is unchanged and the flag toggles with no rebuild. (4) Relative Score Fusion (AGENTTY_RAG_FUSION=rsf) — an alternative to the default rank-based RRF that min-max-normalizes each list and weighted-sums, preserving the score magnitude RRF discards; A/B it on your corpus. Everything is off-by-default or behaviour-preserving; the Retrieval and Configuration docs cover every new knob, and a pluggable set_embed_backend() seam makes the batching testable offline (the suite proves N variants → exactly one embed call).
  • Advanced retrieval: the engine now learns, converses, hops, and measures. Five capabilities that move search_docs beyond a static funnel — all pure C++/STL, zero new dependencies, default-on where deterministic, and each degrading to the previous behaviour on any failure (src/rag/advanced.cpp). (1) Learning loop — agentty closes the feedback loop no other terminal agent closes: every surfaced passage counts a "use"; when the agent follows up by reading the file a passage pointed at, that's a "win" (implicit relevance judgment, hooked at the tool-dispatch seam). The Beta-smoothed per-passage win-rate persists to .agentty/rag_feedback.tsv and folds into ranking as a bounded multiplicative nudge (×0.85–×1.15, neutral with no history — a fresh workspace ranks byte-identically) so retrieval gets measurably better the more you use it (AGENTTY_RAG_LEARN=0 off). (2) Conversation carryover — a recency-decayed salience pool over recent queries lets a vague follow-up ("how does it handle errors?") gain the entities under discussion as an EXTRA RRF probe; deterministic, never replaces the original query, recall can only rise (AGENTTY_RAG_CARRYOVER=0). (3) Multi-hop decomposition — compositional questions ("how X works and how Y blocks Z") split on clause connectives into per-facet probes riding the existing multi-query fusion, gated conservatively (≥2 clauses × ≥2 content terms) so ordinary queries pass untouched (AGENTTY_RAG_MULTIHOP=0). (4) Late-interaction reranking — ColBERT-style sentence-level MaxSim upgrades the chunk-level embedding rerank tier at the same cost class (one batched /api/embed round-trip): the query aligns to each candidate's BEST sentence (blended with the runner-up for corroboration) instead of the blurred whole-chunk vector (AGENTTY_RAG_LATE=0 falls back to chunk cosine). (5) GraphRAG-lite — the author-curated relevance graph hiding in markdown links: top hits' outbound ](doc.md) targets are followed one hop and the linked documents' lead chunks join the context as supporting material, scored below every direct hit (AGENTTY_RAG_GRAPH=0).
  • agentty rag-bench [dir] — the eval harness that makes every stage provable. Retrieval engineering without measurement is vibes; this makes the funnel's anatomy inspectable on the USER'S corpus, offline, in milliseconds: it synthesizes known-item queries from sampled chunks (most-discriminative terms by tf×idf — deterministic, reproducible, no LLM), then reports recall@k / MRR / nDCG@10 / mean-µs across the retrieval ladder (BM25-only → hybrid+PRF → +feature-rerank → +MMR), so a regression or a win on any corpus is attributable to exactly one stage and every env toggle can be tuned against numbers instead of guesses.
  • State-of-the-art local retrieval engine behind search_docs — now documented, and better by default. A sustained pass took agentty's RAG from "good" to frontier-grade for a fully local, dependency-free, offline-capable engine, and made the default path reflect it (not just the fully-tuned one). New this cycle: (1) Pseudo-relevance feedback (RM3-lite) query expansion, default-on — an initial BM25 pass harvests the most discriminative terms from the top hits (feedback frequency × corpus rarity) and fuses in a second, down-weighted BM25 probe over {query + those terms}, recovering vocabulary-mismatch hits (synonyms, the exact spelling the docs use) with zero model/network cost (deterministic, sub-ms; AGENTTY_RAG_PRF=0 disables). (2) Parent-document (small-to-big) retrieval, default-on — each surviving small chunk is stitched back into its adjacent siblings from the same document so a precise hit is read in context, without widening the probe (AGENTTY_RAG_PARENT). (3) HyDE — Hypothetical Document Embeddings (AGENTTY_RAG_HYDE, opt-in) — the LLM hallucinates a short answer-passage whose embedding lands the probe near the real answers. (4) Source-agnostic multi-query fusion — query expansion and HyDE now help every knowledge configuration (docs, skills-only, memory-only, MCP, any mix), not only when a docs folder exists, via a new KnowledgeRouter::retrieve_multi that fans every probe across every source and fuses all ranked lists in one RRF pass. (5) Graded cross-encoder rerank rubric — the opt-in generative reranker now scores against an anchored 0/2/4/6/8/10 relevance scale that judges answering over keyword overlap. Full engine (all local, no dependencies): hybrid BM25 + dense embeddings + RRF, HNSW ANN, contextual-retrieval breadcrumbs, PRF, feature-fusion + embedding-cross-encoder rerank, MMR, extractive compression, parent-document expansion, corrective retry, per-turn cache, and proactive pre-turn injection. A brand-new Retrieval doc page walks the whole funnel, and the Configuration table now covers every AGENTTY_RAG_* / BM25_* knob. Degrades gracefully at every stage — no embeddings → BM25-only, Ollama unreachable mid-search → the affected stage no-ops and retrieval continues.
  • --auth-header NAME — custom auth header for OpenAI-compatible gateways. Custom --provider host[:port] endpoints (and presets) could only authenticate with the hard-coded Authorization: Bearer <key>, which locked out self-hosted / enterprise gateways that expect the key under a different header name (e.g. X-API-Key). The new session-scoped flag overrides the header name; the key (from -k / the in-app paste / OPENAI_API_KEY) goes out raw under it, on every OpenAI-family request (chat completions, /v1/models listing, the Ollama capability probe), and survives live provider switches (^P). Unset keeps the standard bearer header; the Anthropic path is untouched. (#5)

[0.2.8] - 2026-07-16

Fixed

  • The prebuilt Linux binary itself is now a real standalone binary — v0.2.7's curl \| sh crash is fixed at the root, not just papered over. v0.2.7's agentty-linux-x86_64 was a musl dynamic-PIE masquerading as static: readelf -d showed NEEDED libc.musl-x86_64.so.1 and it carried no PT_INTERP, so the kernel mapped it at a random base and jumped to an unrelocated entry point → instant SIGSEGV (exit 139) on any glibc host (it only ran on Alpine, where the musl loader happens to sit at the baked path). Root cause: Alpine's default-PIE musl GCC does not pull libc from the static archive under -static-pie — it emits a loader-dependent dynamic-PIE. (Confirmed empirically: -static-pie alone, -static-pie -static (link error — non-PIE CRT), and -Wl,-Bstatic --no-dynamic-linker all fail on Alpine 3.21 / GCC 14.2.) The fully-static Linux build now links with -static -no-pie, which pulls libc from the archive and emits a classic ET_EXEC with no NEEDED and no PT_INTERP — a true standalone binary that runs on every Linux userland (glibc Debian/Ubuntu/Fedora, musl Alpine, 64-bit Raspberry Pi OS). Termux/Android (which needs a PIE) is available via the opt-in -DAGENTTY_STATIC_PIE=ON on a suitable musl toolchain. Backing all of this: a build-time ELF-shape assertion (cmake/assert_static_pie.cmake, a POST_BUILD step on every fully-static build — release CI, local AGENTTY_FULLY_STATIC=ON, and the installer's --build / auto-fallback) hard-fails the compile if the result has a NEEDED entry or a PT_INTERP, so a downgraded, un-runnable artifact can never be packaged or shipped again regardless of pipeline. Verified: the guard fails (exit 1) on the actual broken v0.2.7 binary and passes on the new -static -no-pie binary.
  • Agent tools hardened against wedging, injection, and silent corruption. A robustness pass over the tools the agent leans on hardest — no new features, four concrete correctness/safety fixes. (1) bash can no longer run forever. The command timeout was an idle timer (it reset on every line of output), so a steadily-chatty command (yes, tail -f, ping, a progress-spamming loop) never tripped it and ran until the capture cap, then kept spinning. There's now a hard wall-clock ceiling from spawn (default max(timeout×20, 10min)) enforced with SIGTERM→SIGKILL, so a long chatty build is never cut short but nothing runs unbounded. (2) find_definition no longer touches a shell — it built a sh -c string and interpolated the symbol between single quotes, so a symbol containing ' could break out; it now runs ripgrep via a literal argv like grep always did. (3) edit can't silently corrupt on a rolled-back batch — when one edit in a multi-edit batch tripped its expected_replacements check, the rollback re-ran the fuzzy matcher on the prior edits, which could land a different region; it now restores an exact pre-edit snapshot. (4) git_diff/git_log reject a ref starting with - (a smuggled git option like --output=… in the revision slot).
  • Checkpoints keep working under --workspace / (full-power launches). The checkpoint layer resolved the enclosing git repo from util::workspace_root() — but that root is the filesystem access boundary, which power users routinely widen with -w / for unrestricted disk access. Probing git -C / rev-parse there fails (/ is never a repo), so every checkpoint silently died: no snapshot on submit, no divider, and "Rewind to checkpoint" toasted "checkpoints need a git repo" even inside a real project. The repo is now discovered from the process cwd — the directory agentty was launched from (i.e. the project), which it never chdir's away from — so git rev-parse walks up to the true enclosing repo no matter how wide the sandbox gate is. -w / now widens access without destroying the project identity that checkpoints, git tools, and diffs key off. Falls back to the workspace root only if cwd is unreadable.

Changed

  • Compaction and checkpoint markers now read as real turns, not floating chrome. Two seams looked broken. (1) The Conversation compacted summary was rendered as a bare full-width rule with no speaker identity — a stray divider hanging in the transcript. It's now a genuine minimal system turn: a glyph, a muted rail, a Compacted header + timestamp, and a one-line body (“Earlier conversation summarized to reclaim context.”). The raw summary prose is still elided from the view (it's written for the model, can be many KB) but the model receives it on the wire unchanged. (2) A checkpointed user turn drew a separate full-width ─── [↺ Restore checkpoint] ─── CheckpointDivider above the rail, which read as a hard interruption. The marker now lives inline in the turn's own meta line as a subtle · ↺ checkpoint tag — part of the turn, not a banner over it. Both changes flow through the shared turn_config, so the frozen and live builders stay byte-identical at the freeze seam (seam symmetry test green, 442/442). The inter-run wire boundary divider (a true between-runs marker) is unchanged.
  • Picker rows never overflow, at any terminal width. The rewind-checkpoint picker feeds free-form prompt text as the row's primary label and an async N files +A −B diffstat as the secondary — two potentially-long cells on one row. maya's Picker row now lays leading and trailing out with real flex-shrink weights (leading grows to fill and is first to truncate; trailing holds its natural width and only shrinks reluctantly), both ellipsis-clipped, so a paragraph-length preview meeting a fat diffstat degrades gracefully instead of spilling past the border. The preview is also pre-clamped to a readable length (UTF-8-safe) and the picker's min_width was brought in line with the rest of the family.

maya

  • New | shrink(factor) DSL pipe. The flex engine already supported flex_shrink end-to-end (FlexStyle, the Yoga solver, BoxBuilder::shrink) but there was no way to set it from the declarative DSL — only | grow. Added shrink() as the exact mirror of grow() (runtime pipe tag + factory + WrappedNode plumbing in both build branches), so responsive rows can now say text(a) | grow(1) | shrink(3) to control which cell yields space first when a row gets tight.

Added

  • agentty now identifies honestly to Anthropic instead of impersonating Claude Code. The OAuth/subscription transport used to spoof the official Claude Code CLI byte-for-byte to get subscription tokens accepted — a fake user-agent: claude-code/2.1.113, x-app: cli, the full Anthropic JS SDK x-stainless-* platform fingerprint, the x-stainless-helper: BetaToolRunner tag, and a Claude-Code-shaped metadata.user_id — which is exactly the masquerade pattern that gets subscription accounts flagged under Anthropic's ToS. The transport now announces itself: user-agent: agentty/<version>, x-app: agentty, no x-stainless-* SDK fingerprint, no BetaToolRunner tag, and a plain {device_id, session_id} agentty client id (no fake account_uuid). It keeps only what the API functionally requires — anthropic-version, the anthropic-beta feature flags (including oauth-2025-04-20 for subscription tokens), and the auth header. If Anthropic's edge ever hard-requires a first-party client signature, that's a ToS boundary agentty surfaces to the user ("use an API key or another provider"), not one it quietly circumvents by masquerading.
  • Release binaries are now smoke-tested on a foreign libc before they ship. v0.2.7's Linux prebuilt was a musl static-PIE binary that segfaulted immediately on glibc/Debian, and nothing in the release pipeline caught it: the workflow only ran readelf ELF-shape checks (and only on aarch64, as non-fatal WARNs) and never executed the binary. A binary can pass every readelf check and still crash at startup on a foreign libc. Each Linux build leg (x8664 / aarch64 / i686) now (1) hard-fails — not warns — if the static-PIE link degraded (a PT_INTERP or NEEDED entry, or a non-ET_DYN type), and (2) runs a --version smoke test inside a clean Debian (glibc) *and* Alpine (musl) container (matching i386 images for the 32-bit leg), failing the release if the just-built binary won't launch on either. The aarch64 smoke test runs on real ARM silicon (no QEMU); i686 reuses the QEMU already set up for its build. The x8664 release -march baseline was also pinned to sse2 (the universal amd64 ISA) for intent-clarity — the GCC/Clang path never emitted the old avx2 value anyway, so codegen is unchanged and the binary keeps running on pre-Haswell CPUs.
  • install.sh --build source-build path + automatic fallback for broken prebuilts. When a release binary can't run on the target system — e.g. v0.2.7's musl static binary with no PT_INTERP segfaulting immediately on Debian/glibc — the one-liner installer used to be a dead end: it downloaded the broken artifact, chmod +x'd it, moved it into place, and left the user stuck. Now the installer (1) accepts a --build flag that skips the prebuilt download and compiles from source (git clone --recursive at the requested ref → cmake -DAGENTTY_STANDALONE=ON → build → install to $PREFIX/bin), with clear preflight errors when git/cmake/a C++26 compiler is missing; and (2) auto-falls-back to that same source build when a downloaded binary fails to print its version (segfault / exec failure / ABI mismatch), instead of installing something that doesn't run. README + --help document both. --build is a no-op-safe addition — the default fast path (download prebuilt) is unchanged.
  • Rewind to any checkpoint, with a diff preview. Every user turn inside a git repo already pins a worktree snapshot and draws a checkpoint divider above it; now all of those points are reachable, not just the newest. "Rewind to checkpoint" in the command palette opens a picker listing every checkpointed turn (turn number + prompt preview + relative time), and each row shows a N files · +A −D summary of what the worktree has changed since that point — computed asynchronously (tree-vs-tree git diff --numstat against a scratch index) so opening is instant even on a big repo and a rewind is never blind. ↑↓/j/k move, Enter rewinds (the existing destructive files-and-transcript revert, with the old prompt refilled in the composer), Esc cancels. Gated on an idle session and a real git repo, with a friendly toast otherwise.

[0.2.7] - 2026-07-12

Added

  • First-run onboarding starters. On a genuine first run — thread history has loaded and there are no saved conversations yet — the welcome screen now shows a compact "New here? Try one of these" card with three concrete example prompts (understand the codebase / find-and-fix a bug / add a feature and run tests). It teaches the three things agentty is for so a brand-new user isn't staring at a blank composer wondering what to type. A returning user with any history never sees it, so the welcome stays clean. Gated on !threads_loading && threads.empty(), so it can't flash during the async thread-load at startup.

Changed

  • README refreshed. Full install matrix (apt/dnf/zypper/AUR/apk/brew/scoop/winget + curl one-liner + from-source), a first-run note in Getting Started, the Windows-native Ctrl+G shell dispatch, and a maintainer "Releasing" section documenting the one-command cut-release flow.

Fixed

  • The agentty-linux-aarch64 binary now runs on Termux/Android and 64-bit Raspberry Pi. The standalone aarch64 binary is built -static-pie (passed to both compile and link steps, working around the Alpine/musl GCC spec bug where link-only -static-pie picks Scrt1.o over rcrt1.o and drops the program header). The result is a fully static ET_DYN with no PT_INTERP and a valid PT_PHDR — so it loads on Android's PIE-only linker (no more unexpected e_type: 2), needs no external loader on Termux (no more Could not find a PHDR), and stays portable across every arm64 core down to a Cortex-A72 (armv8-a baseline via MAYA_NATIVE_TUNING=OFF). One file runs on glibc, musl, Termux, and 64-bit Pi OS alike.
  • Release assets no longer silently vanish behind a draft. gh release view succeeds on a draft release, so a draft vX.Y.Z left by a prior cancelled run (or auto-created on tag push) would absorb every gh release upload while staying invisible — public download URLs 404 and the unauthenticated API omits it, making a green CI run look like it produced nothing. The release job now always gh release edit --draft=false --prerelease=false right after ensuring the release exists, so a leftover draft can never swallow uploads again.

[0.2.6] - 2026-07-07

Added

  • One-command release cut (scripts/cut-release.sh / .cmd). scripts/cut-release.sh X.Y.Z (or cut-release.cmd X.Y.Z on Windows) is now the entire manual release ritual: it bumps project(agentty VERSION …) in CMakeLists.txt (the single source of truth), promotes CHANGELOG's [Unreleased] section to a dated [X.Y.Z], commits release: vX.Y.Z, creates the annotated tag, and pushes branch + tag. The tag push triggers .github/workflows/release.yml, which builds every binary + OS package and submits to winget/homebrew/scoop/AUR (nix/snap/gentoo manifests attached to the release) with zero further input. Guards refuse a downgrade, a duplicate version, a dirty tree, or an existing tag; --dry-run previews the exact diff without writing anything, --no-push stops after the local commit+tag. The Windows .cmd wrapper runs the POSIX script through Git-Bash (or WSL as fallback).

Changed

  • Ctrl+G now runs PowerShell and cmd blocks natively on Windows. The code-block runner is platform-aware: a ``` `powershell `` / pwsh / ps1 block is executed through powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand (the body is UTF-16LE-base64-encoded, so arbitrary quoting and multi-line scripts survive the cmd.exe wrapper intact), a cmd/bat/batch block and bare fences run through cmd.exe, and on POSIX sh/bash/zsh/shell/console/terminal (and bare fences) still go to /bin/sh. A block in a language the current platform can't run (e.g. powershell on Linux) no longer masquerades as runnable — Run shows a toast and edit/copy stay available. The Run gate, the runnable-block nudge counter, and the runner all consult one shellforlanguage()` classifier so they never disagree. This means the install commands agentty itself suggests on Windows (scoop, winget, PowerShell one-liners) are one keystroke away from running.
  • The aarch64 Linux binary builds on a native ARM64 runner — minutes, not an hour+. The release build used ubuntu-latest + docker --platform linux/arm64, which ran the entire C++26 compile under QEMU emulation (~10-20× slower; a single release could sit at 1h+ and occasionally stall). It now runs on GitHub's native ubuntu-24.04-arm runner (free for public repos), so aarch64 finishes in roughly the same wall-clock as x86_64 — no emulation. Every downstream package that pins the arm64 binary's checksum (Homebrew, AUR, nix) is unblocked in minutes instead of hours. The standalone build is still portable (armv8-a baseline via MAYA_NATIVE_TUNING=OFF), so a Graviton/Neoverse-built binary runs on any arm64 down to a Cortex-A72.

Fixed

  • yay -S agentty (and any SHA256SUMS consumer) no longer 404s on a fresh release. The checksums job that publishes SHA256SUMS was gated behind every build including the slow aarch64 leg, so for that whole window the release had no SHA256SUMS asset — and the AUR PKGBUILD, which verifies the downloaded binary against it, aborted with a 404. SHA256SUMS now publishes in an early pass gated only on the fast x86_64/macOS/Windows legs (a second checksums-final pass refreshes it once the remaining arches land), so the file is present within minutes. Combined with the native-ARM aarch64 build above, the gap it was papering over is largely gone anyway.

[0.2.5]

Added

  • Installable via every major Linux package manager. New packaging manifests for Alpine (apk add agentty), Nix (nix-env -iA agentty), Snap (snap install agentty), and Gentoo (emerge agentty) join the existing Debian/Ubuntu (apt-get), Arch (pacman/AUR), Fedora/RHEL/openSUSE (dnf/yum/zypper) and macOS/Windows (brew/scoop/winget) targets. Every manifest is a template with the version rewritten from the single project(agentty VERSION …) line in CMakeLists.txt at release time — no hardcoded versions anywhere. The release workflow now auto-publishes to the Homebrew tap, scoop bucket, and AUR (each gated on a secret, skipped when absent), builds the .apk, and attaches pinned nix/snap/gentoo manifests to the release. See packaging/README.md.
  • Image paste over SSH with zero remote setup (kitty). Ctrl+V on a remote agentty session now pulls a screenshot straight off your local clipboard through the terminal itself: when every host-side probe comes up empty, maya asks the terminal for its clipboard — under kitty it now speaks OSC 5522 (kitty's multi-format clipboard protocol), whose reply carries real image bytes (PNG/JPEG/WEBP/GIF chunked base64, reassembled into one paste; image outranks text when both are on the clipboard; EPERM/EBUSY/ENOSYS abandons silently, like a terminal that never replied). Every other terminal keeps the OSC 52 read — text-only by protocol, so on iTerm2/WezTerm/foot/Ghostty images over SSH still go via AGENTTY_CLIPBOARD_CMD or agentty airgap --clipboard-relay. Works on every platform on both ends — the bytes ride the pty, no wl-paste/xclip/pngpaste/PowerShell on the remote.
  • Ctrl+←/→ also quick-cycles threads. Same deck order as Alt+←/→ (← newer, → older), and now on the welcome-screen shortcut row. Fires only while the composer is empty and no agent turn is running — with text in the box Ctrl+arrows stay jump-by-word, and mid-turn the keys fall through to the composer so a live stream can never be yanked out from under you.

Fixed

  • Diff cards no longer render as garish full-bright green/red rows over SSH. Two compounding maya bugs: (1) color detection was too conservative — SSH doesn't forward COLORTERM (it's not in sshd's AcceptEnv), so a remote session fell to 16-color ANSI and the dark saturated diff-row bands quantized to solid bright green/red/blue blocks with washed-out text. TERM-based detection now recognizes truecolor terminals that identify via TERM (kitty, ghostty, wezterm, alacritty, foot, iTerm, konsole, -direct variants) and treats any xterm-*/screen*/tmux* as 256-color-capable. (2) Even on genuinely 16-color terminals (vt100, linux console) there's now a graceful floor: the write/edit/git-diff previews drop the background bands entirely and fall back to the classic fg-colored diff (green/red +/- text, blue @@ headers) — exactly what git diff itself looks like there. Override with MAYA_COLOR=truecolor|256|16.
  • Streaming markdown reveals uniformly across every block type — code blocks, tables, lists, headings all glide like prose. Structured blocks used to pop in whole the instant they completed: a finished code fence / table / list committed to the widget's static prefix immediately, and the typewriter cursor was snapped forward past it — teleporting up to ~200 cells onto the screen in one frame while surrounding prose typed out at ~2–6 cells/frame (measured by the new reveal_smoothness_probe). Now (maya) block commits are gated on the reveal cursor: a completed block stays in the live tail — rendered in its exact committed shape by the canonical tail path, so zero cells change at the eventual commit — until the typewriter has swept it, then commits via a pending-boundary ledger (each boundary the scanner discovers is committed individually as the cursor crosses it, keeping the live tail bounded to ~one block + the cursor's lag window, so long-turn per-frame cost stays flat). The reveal overlay's live-copy cache also grew an incremental prefix-growth arm (O(new blocks) per commit instead of O(turn)). Verified end-to-end: scrollback oracle (all shapes, zero corruption), revealscrollbacktest (10 970 checks), full maya suite, CommonMark 652/652.
  • One-frame phantom blank row inside a streaming code fence. maya's markdown engine treated a trailing \n as opening an empty final line, feeding a phantom blank content line into any still-open leaf — an unclosed code fence gained a bogus bottom row that appeared and vanished at every line step of the reveal (a height oscillation the monotonicity gate flags). A trailing newline now terminates the last line (cmark §2.1) instead of opening an empty one; spec conformance stays 652/652.

Changed

  • Esc no longer quits the app. Quit is Ctrl+C only. Esc keeps all its useful jobs — cancel a streaming turn, reject a permission, close any modal — but a stray press on the main screen is now inert. This also makes Alt-emulation on iPhone terminals (iSH, Termius, a-Shell) safe: their Alt+←/→ is an Esc-prefix chord, and the Esc half must not be a live exit key.
  • New CI gate reveal_smoothness_probe: streams a mixed doc (prose/heading/code/table/list/quote) through the production reveal config on the virtual anim clock and fails on any height shrink or any single frame revealing >60 content cells.

[0.2.4]

Added

  • Run code blocks from replies (Ctrl+G). The picker lists every fenced block in the newest assistant reply (first-line preview · language · line count); Enter or a bare digit runs one interactively on the real terminal — the TUI suspends via a new maya suspend primitive, so sudo password prompts work, output streams live, and Ctrl+C kills the command (never agentty; classic system() signal semantics). stdout+stderr are teed: everything hits the screen live AND lands in a capture (capped 2 MB). On exit a Result card shows the command, exit code, and the full scrollable capture — a attaches it to the composer as a collapsed Output chip (the same collapse/expand machinery as a big paste; expands on the wire as "I ran: … output: …"), y copies it clean, Esc/Enter discards. The composer never receives output you didn't explicitly ask for. Extraction strips uniform $ /> transcript prompts (never # comments), tolerates ~~~ fences, CommonMark indent, and unterminated fences; non-shell blocks offer edit/copy instead of run. Also in the command palette as Run code block. Windows degrades to the non-interactive captured runner. See docs/RUN_CODE_BLOCK.md.
  • Runnable-block nudge. When a reply settles and contains shell blocks, a transient toast ("▶ N runnable code blocks — Ctrl+G to run") surfaces the affordance while the commands are still on screen. Counts only runnable (shell-ish) blocks — a python-only reply stays quiet.
  • Thread quick-cycle (Alt+←/→). Flip to the adjacent thread without opening the picker — recency order, wraps at both ends, with a "thread k/N · title" toast on every hop so you always know where you landed. Gated on an idle session; the departing thread is saved first. The ^J thread list now opens at the current thread (not row 0), marks it with a bold , and shows the same k/N position readout — both navigation surfaces speak one coordinate system.

Changed

  • Status bar: the tok/s sparkline now shows from ~90 columns (was 110).

[0.2.3]

Fixed

  • The remaining streaming scrollback-corruption classes — closed, oracle-proven. A new in-repo scrollback oracle (a pixel-exact terminal-emulator harness that replays full streamed turns and diffs every committed row against ground truth) flushed out and proved fixes for every class it found: (1) trailing prose duplicated above tool cards — the paragraph rode maya's inline tail path (different wrap than the committed-block path) until settle rewrote its already-committed rows; the widget now finishes the instant a tool call exists; (2) frozen-front trim committed an estimate of dropped rows instead of the exact count; (3) tool-card grow while overflowed strand-painted rows into scrollback (maya grow-guard + reconcile cooldown); (4) chrome strands — a committed drop larger than viewport+margin preserved a pre-commit canvas and smeared composer/status chrome into history; (5) shrink-while-overflowed at stream settle duplicated the composer one screen up (maya's verify-poison recovery committed rows unconditionally). The oracle passes all shapes with maya's scrollback-invariant gate instrumented and zero gate firings — the gate exists, but nothing trips it.
  • Scrollback no longer wiped by gate recovery. The scrollback-invariant gate's grow recovery arm demoted to a hard reset whose escape sequence (\x1b[3J) deleted the terminal's entire native scrollback — you'd suddenly see only the last few turns. The wipe dated from an era when recovery re-serialized from row 0 with bottom-edge scrolls; today's repaint is viewport-capped, so the destruction was pure loss. Grow recovery now commits off-viewport rows and soft-repaints, same as shrink — no reachable render path clears scrollback anymore (only width-change resize, hard write failure, and explicit thread swap).
  • write/edit streaming cards: seam-stable, no collapse, no committed-row rewrites. Long streaming edits used to balloon the card (every hunk rendered), tick already-committed "/N" headers, and could collapse the card to zero rows mid-stream. The streaming preview now feeds all hunks to maya's diff widget, which pins a status chip and windows the visible body to a cross-hunk row tail; the header/detail are lifecycle-stable (no Done-only suffix rewriting committed rows); the body budget adapts to content instead of pinning worst-case height; and the chip shows the landing-hunk ordinal while streaming.
  • Live tool panel animates without touching the freeze seam. The in-flight agent-timeline panel's spinner moved to a seam-safe footer so animation frames can't perturb rows above the live/frozen boundary.

Changed

  • Model catalog recognises the Claude Fable/Mythos flagship lane.

Performance

  • Test-suite wall clock: 280 s → 24 s. maya's animation system (reveal effect, activity indicator, anim::Clock/Mount) now reads a single skewable time source (anim_now_ms(): steady clock + test-only additive atomic skew). Tests advance the clock instead of sleeping 20 ms per frame; production cost is one relaxed atomic load.

[0.2.2]

Fixed

  • Scrollback duplication / ghosting across streaming, height-grow, compaction, and narrow viewports — the whole class, closed. A cluster of inline-render bugs could leave stale or duplicated rows in the terminal's native scrollback when the live tail transitioned to a frozen (immutable) block. Fixes: the freeze gate (live_tail_reveal_settled) now mirrors build_live_tail's is_live() hash-stamp condition so the two seam gates can't drift; frozen-measure width subtracts the full 4-column chrome (AppLayout + Conversation padding), not 2, so narrow terminals freeze at the same width maya renders; the case-B height-grow cursor anchors to full content height; the compaction divider is now emitted symmetrically in the live tail so the freeze seam stays height-stable across a compaction; and frozen-trim commits a conservative under-estimate of scrolled rows (never the exact count) — an over-commit re-scrolls the visible tail, but an under-commit is reconciled by maya, mirroring the proven agent_session behaviour. At fps=0, markdown now finishes immediately and visual_hash is driven through the settle cooldown so no duplicate paints slip through. Each fix ships with a regression test.
  • Streaming reveal no longer bursts, stalls, or scrambles on long turns. The maya reveal effect glides tables and code blocks left-to-right with a commit-safe seam (no eager scramble of not-yet-typed cells), holds final table column widths during reveal (no horizontal reflow), and reveals the newest table row with the same positional comet gradient as prose. Tall tables no longer ghost-duplicate into scrollback.
  • Data race on provider::active(). A worker thread read the active provider while the UI move-assigned it, tearing the read. The value is now snapshotted under the mutex.
  • Memory tool: garbage rollover count + silent scope downgrade. The rollover counter could print garbage, and a remember could silently downgrade its scope; both are fixed (the scope downgrade is now refused).

Changed

  • Inline render is bounded to the viewport window for tall transcripts. A giant frozen or streaming block used to pay O(content-height) every frame (full-canvas clear + a memcpy-per-row blit). The paint path now clears only the rows below the immutable prefix (clear_below, gated on a Synced coherence state, no canvas realloc, and an 8-row margin above the viewport) and skips the per-row blit when the destination is already byte-identical to the source (blit_packed_row_cached via SIMD bulk_eq). Single-authority scrollback accounting (overflow = prev_rows − term_h) is fully preserved — the worst case is a perf non-improvement, never corruption. Steady per-frame cost on tall blocks drops ~30%.
  • Tool-diff bands: GitHub-dark styling. write/edit diffs render dark-but-saturated green/red backgrounds with bright same-hue text and a sign rail, readable as green/red (not gray) even on low-gamma panels; clean single-filename header, no git plumbing.
  • Responsive status bar. The CTX gauge is lowest-priority (drops first, desktop widths only); the provider badge shows from ~50 columns; compact CTX (bar graph + percent) shows from ~40 columns so phone-width terminals keep the fill graph and %, with raw token counts only on wide terminals. Picker footer hints drop responsively to fit.

Performance

  • Diff is trimmed-LCS, not O(N·M). Common prefix/suffix are trimmed before the LCS, the SSE debug gate is lock-free, and a redundant stream-sink hop was dropped.
  • Off-screen giant message bodies collapse on rehydrate (default off — it was hiding loaded messages, now opt-in), and settled tool panels are cached in long in-flight turns.
  • Build auto-pulls all submodules (maya, acp-cpp, mcp-cpp) to latest.

[0.2.0]

Added

  • agentty airgap <host> --acp [flags…] — one-command Zed-over-airgap setup. Running agentty inside Zed on an internet-less remote used to mean hand-assembling a ssh -N -R 1080 tunnel plus a Zed env block. The new --acp form prints a ready-to-paste Zed agent_servers config (and the path to your settings.json) whose command is ssh itself — its args open the reverse SOCKS5 tunnel and exec the remote agentty acp in a single invocation, with the ACP JSON-RPC riding ssh's stdio. One ssh process is the tunnel, the agent, and the transport; Zed owns its lifecycle, so there's nothing to babysit. Everything after --acp (e.g. -m, --profile, --workspace, --sandbox) is forwarded verbatim to the remote agent. Pair with --setup to copy credentials over first.
  • agentty acp now supports session/load — resume past conversations in Zed. The ACP agent advertises loadSession: true and persists every session to agentty's on-disk thread store (threads_dir()/<id>.json, the same format the TUI writes) after each turn, so sessions survive a subprocess restart. On session/load it restores the Thread (preferring an in-memory copy when the session is still live in this subprocess, else reading from disk), replays the entire conversation — user messages as user_message_chunk, assistant text as agent_message_chunk, and each tool call as a tool_call card with its final input/output/status — as session/update notifications, then resolves the request, exactly per the ACP spec. Session ids are real ThreadIds, so ACP sessions also appear in the standalone TUI's thread picker (and TUI threads are loadable from Zed). Fixes the "Loading or resuming sessions is not supported by this agent" error.
  • agentty acp ACP refinements: model + permission-profile flags, file follow-along, faster cold start. -m / --model is now an ephemeral per-subprocess override in ACP mode (it no longer clobbers the TUI's saved model), so a Zed agent_servers entry can pin a fast model (e.g. claude-haiku-4-5) without touching your interactive default. A new -p / --profile {ask|minimal|write} flag tunes which tools trigger Zed's permission prompt: ask (default — prompt write/exec/net, auto-run reads), minimal (prompt everything including reads), write (never prompt reads). Tool calls now carry ACP locations (file path + optional line) for read/edit/write/listdir/gitdiff/diagnostics, enabling Zed's "follow-along" file highlighting. ACP mode now prewarms the TLS/DNS connection to Anthropic before serving (matching the TUI), eliminating the ~150–300 ms handshake on the first prompt, and the wire tool list is built once instead of per-completion.
  • agentty acp — run agentty as an ACP agent inside Zed (or any Agent Client Protocol client). A new headless subcommand speaks newline-delimited JSON-RPC 2.0 over stdio and implements the full ACP v1 agent surface: initialize (capability negotiation), authenticate, session/new, session/prompt (drives a complete agent turn), and session/cancel. While a turn runs it streams session/update notifications — agent_message_chunk for model text, tool_call / tool_call_update for every tool (with ACP kind, rawInput, status transitions, and diff content blocks for edit/write so Zed renders changes inline) — and calls back with session/request_permission before any side-effecting tool runs (Exec / WriteFs / Net), letting Zed show its native approval UI. The headless loop reuses the exact same provider, tool registry, wire-message shaping, workspace sandbox, and permission policy as the TUI (no maya/UI dependency), so behaviour is identical to interactive agentty. Configure in Zed's settings.json under agent_servers with { "command": "agentty", "args": ["acp"] }; auth comes from your existing agentty login. See README → “Use agentty inside Zed (ACP)”.

Fixed

  • Per-error-class retry caps (Zed-aligned) so a flaky mid-stream wire stops spamming the retry banner. Previously every transient shared one global kMaxRetries (6), so a connection that kept cutting out mid-body stuttered through six loud transient — retrying (attempt N/6)… banners before giving up. Mirroring Zed's agent loop (crates/agent/src/thread.rs::retry_strategy_for), the cap is now per error shape via provider::max_retries_for: rate-limit / overload (429 / 529) keep the full budget (the server is shedding load and usually hands a Retry-After), a clean connect blip with no content keeps the full budget (a fresh connection almost always recovers), but a mid-stream failure — the stall watchdog fired, or the stream had already delivered a delta this turn and then died — gets only *2* attempts, because a wire that keeps dropping after reaching us is a real outage, not a reconnect artifact. The attempt counter in the banner now shows the real per-class cap (N/2 mid-stream, N/6 otherwise). Budget-decay and the first-delta/heartbeat budget reset still apply on top, so a stream that recovers and runs for a while resets to a fresh ladder.
  • x-stainless-retry-count now reflects the real attempt number. It was hard-coded to 0, so every retry looked like a fresh first attempt to Anthropic's edge — which reads this header for routing and to avoid penalising retried traffic, and could land a retry back on the same overloaded pop. The per-turn transient_retries count is now plumbed through provider::Requestanthropic::Request → the header, exactly as the official SDK / Zed increment it.
  • Frequent transient — retrying… banner caused by stale pooled connections. The dominant trigger for the orange retry banner was a reused h2 connection that Anthropic's edge (or an intermediate proxy) had silently half-closed: the pool's acquire-time liveness checks (nghttp2 protocol state + a non-blocking MSG_PEEK) passed because the FIN/GOAWAY was still in flight, so a corpse got handed out. The new stream submitted on it was immediately RST_STREAM'd / GOAWAY'd — and because the old retry gate (any_bytes, set on headers) considered a headers-only :status block "committed," the HTTP layer couldn't re-dial. The error bubbled to the reducer, which restarted the whole turn loudly with backoff. Two transport-layer fixes: (1) the stream-commit point is now real SSE DATA (on_chunk with body bytes), not headers — a stream that got only :status before the reset is replay-safe and re-dials transparently; (2) a reused pooled connection that dies before delivering any data gets up to 2 free fresh re-dials that don't count against the transport attempt budget, so a pool-staleness artifact never surfaces as a user-visible error. Genuine fresh-dial failures and any reset after real content still converge to a terminal error exactly as before. This is what the official Anthropic SDK / Claude Code get for free from undici's managed pool (honor GOAWAY, retry transport resets on a fresh connection); agentty now matches it.
  • Transient backoff that never recovered + frequent "stream stalled" after long sessions. transient_retries only reset to 0 on the first content delta, so a stream that connected, sent heartbeats, then went silent before any byte (common during brown-outs and long opus turns) climbed the retry ladder every attempt until it hit kMaxRetries and latched terminal — the session was dead until restart. Two fixes: (1) a heartbeat (SSE ping / thinking_delta) now resets the retry budget too, since it proves the wire is alive even pre-content; (2) the budget decays over wall-clock time — if the previous failure was longer ago than kRetryDecayWindow (90 s) the connection was healthy in between, so the new failure starts a fresh ladder instead of inheriting an unrelated earlier blip. Net effect: fast-failing connections (refused/reset within 90 s) still converge to terminal at attempt 6, but slow stalls minutes apart recover indefinitely. Esc still breaks the loop at any point.

[0.1.1]

Added

  • --version / -V / version flag — prints agentty <PROJECT_VERSION> and exits. The version is baked at build time from CMakeLists.txt's project(... VERSION ...) line, so bumping the project version updates every site that reads AGENTTY_VERSION.
  • Queued messages render as preview rows in the conversation transcript (above the composer), visually identical to real user turns. Mirrors Claude Code 2.1.119's behaviour at binary offset 80106500.
  • (Up-arrow) on an empty composer recalls every queued message back into the buffer, joined by \n, with the cursor at the recalled-text seam. Destructive on the queue — re-submit to re-queue. Mirrors Claude Code's Lc_ (offset 76303220).
  • Composer placeholder gains a press ↑ to edit queued — type to queue another… hint when the queue is non-empty and the buffer is empty (and matching variants for awaiting/idle phases). Mirrors Claude Code's hint at offset 84591379.
  • Retry status now shows attempt counter: transient — retrying in 5s (attempt 2/6)….

Changed

  • Transport reliability. Anthropic's Retry-After HTTP header is now parsed on 429 / 529 responses and used as the authoritative backoff delay, clamped to [1s, 120s]. Falls back to the existing 500ms→45s ladder when no header is present, with ±20% jitter applied to break thundering-herd retry sync during regional brown-outs. Inspired by Zed's parse_retry_after (crates/anthropic/src/anthropic.rs:574-580).
  • Cancel cleanup. Esc now does the full teardown synchronously: drains streaming_text into text (preserves partial reply), marks every non-terminal tool_call as Failed("cancelled"), pops the assistant placeholder if it produced no content, and resets pending_permission. No more orphan Running spinners or empty placeholder cards after cancel.
  • Status banner row replaced by a notification takeover on the existing shortcut row — when m.s.status is active, the keybindings strip swaps in a single banner-style entry (▎⚠ <text> for errors, ▎ <text> for info) and reverts to bindings when the toast expires. No new rows added.
  • submit_message now queues on any non-Idle phase (m.s.active()) instead of just is_streaming() || is_executing_tool(). Defensive — the keymap already gated AwaitingPermission via the permission modal — but makes the guarantee structural.

Fixed

  • Model / thread / palette pickers felt unresponsive — arrow keys "registered once per 4-5 presses." The Program render gate (visual_hash) didn't include any modal/picker selection state, so moving the cursor (ModelPickerMoveindex++) produced a model the gate considered visually identical and skip_render fired; the new cursor position only painted when an unrelated hashed axis (the ~265 ms composer caret-blink parity) happened to flip. visual_hash now mixes in every modal's open/closed state plus the active picker's cursor index and filter query, so each keystroke repaints immediately.
  • Picker arrow keys double-dispatched. The picker ScrollStates defaulted to auto_dispatch = true, so every ↑/↓/PageUp was fed into ScrollState::handle (bumping scroll.y) in addition to the reducer's selection move — the two then fought the widget's selection-follow clamp. Set auto_dispatch = false on all six picker scroll states; scroll position is now a pure function of the selected index.
  • Up-to-100 ms input stall on bare Escape and split escape sequences (maya). The idle (fps=0) event loop slept the full 100 ms poll while the input parser held a partial escape sequence (a lone ESC, or an arrow key whose bytes arrived in separate reads over SSH/tmux/slow ptys) — only flush_timeout() could resolve it, and only after the 50 ms escape deadline, but the loop never woke to call it. The loop now clamps its poll timeout to the escape deadline while the parser has pending input (Runtime::has_pending_input()). Most visible in the pickers, which idle with no spinner tick to keep the loop spinning.
  • agentty gets stuck — nothing works after Esc. A worker thread's trailing StreamError("cancelled"), dispatched ~200 ms after the cancel-token trip, was running on the runtime's active_ctx. If the user submitted a new turn during that window, the handler's a->cancel.reset() would null out the new turn's cancel token, leaving Esc unable to cancel anything until process restart. launch_stream now wraps dispatch in a guarded lambda that captures the cancel token and short-circuits when tripped — no events from a cancelled worker reach the reducer, so the new turn's state is never touched.
  • Removed the redundant N messages queued line from the shortcut row; the composer's own ❚ N queued chip is now the single source of truth for queue depth.

[0.1.0] — Initial public release

Pre-1.0. Core loop, tools, streaming, permission profiles, in-app auth, persistence, and cross-platform subprocess all working. Linux gets daily smoke testing; macOS and Windows code paths exist (#ifdef branches throughout, posix_spawn for POSIX, CreateProcessW for Windows, fdatasync/fsync switched per OS) but CI for those platforms is next.

Major surfaces

  • Native C++26 TUI rendering through the maya widget engine (sister project, FetchContent-pulled from 1ay1/maya). Single ~9 MB static binary, no Node / Python / Electron runtime.
  • Anthropic provider speaking HTTP/2 + SSE directly via in-house nghttp2 + OpenSSL stack. OAuth (PKCE) + API key both wired through the same auth::cmd_login path.
  • Tools: read, write, edit, bash, grep, glob, list_dir, find_definition, web_fetch, web_search, todo, diagnostics, git_*. Compile-time effect set + permission policy enforced via static_assert on a constexpr matrix.
  • Permission profiles: Write (autonomous), Ask (read-only auto, write/exec/net prompt), Minimal (only pure tools auto). Profile cycle on S-Tab.
  • Sandboxed bash by default — bwrap on Linux, sandbox-exec on macOS. Windows runs unsandboxed (no first-class equivalent yet).
  • Workspace boundary: filesystem tools refuse paths outside --workspace/cwd.
  • SSH air-gap mode (agentty airgap …): wraps agentty on a remote host with SOCKS5 forwarding for TLS / OAuth / chat traffic. Compression off by default (small bursty deltas not worth zlib sync overhead on inline frames); env vars for terminal identification forwarded across the SSH boundary so DEC 2026 sync still applies on the remote side.
  • Persistence: threads and credentials in ~/.agentty/threads/ and ~/.config/agentty/credentials.json (mode 0600). Atomic writes (temp + fsync + rename).
  • Streaming smoothing: SSE deltas drip into streaming_text at ⅛ buffer per Tick (clamped 32–256 chars), so server-side batching doesn't translate into chunky on-screen text.
  • Inline rendering — agentty never takes over the terminal; output flows in scrollback, status bar overlays. compose_inline_frame wraps frames in DEC 2026 begin/end-sync where supported.

Stubbed honestly (not yet implemented)

  • Checkpoint restoreCheckpointId + per-message marker exist; RestoreCheckpoint surfaces "not implemented yet" and does nothing.
  • Diff review pane — modal renders, but pending_changes isn't populated by any tool yet, so review/accept/reject toasts "no pending changes".

Build

  • C++26 (GCC 14+ / Clang 18+); MSVC builds against /std:c++latest.
  • AppleClang tops out at C++23 — AGENTTY_BUILD_TESTS requires g++ or stock LLVM clang++ on macOS, not Xcode's bundled toolchain.
  • cmake -B build && cmake --build build. AGENTTY_STANDALONE=ON produces a static binary (libc and usually OpenSSL stay dynamic).