Releases: cortexkit/aft
v0.26.0
v0.26.0
Post-audit hardening release. 32 fixes from 13 parallel audit lanes plus 3 follow-up dogfood-bug fixes, all verified live. No new public surface — every change is a correctness, honesty, or robustness improvement on top of v0.25.2.
Highlights
- Multi-file undo now works.
aft_safety undois one operation: deleting["a","b"]and undoing restores both.aft_moveundo removes the destination AND restores the source (new backup tombstone API).move_symbolandast_replaceare now operation-scoped too. Symlinks are rejected before mutation in single-file delete (directory delete already had this guardrail). aft_navigate callersresolves workspace package imports.import { foo } from "@your-pkg/bar"now correctly maps to source files in monorepo siblings, including whenpackage.jsonmainpoints atdist/but the source lives insrc/. Top-level call sites (e.g. insidedescribe()/test()blocks) are now indexed.bashfindrewrite no longer drops the path.find /tmp/foo -name "*.ts"now correctly passes the absolute path through to glob instead of embedding it in the pattern.- Tri-state response contract enforced end-to-end.
readreports realtotal_linesand returnscomplete: falseon partial reads. The edit family omitssyntax_validwhen validation didn't run instead of falsely returningtrue.inline_symbolcorrectly matches multiline calls by start-line.lsp_diagnosticsdirectory mode reports partial workspace pulls honestly. - Bash background tasks survive restart by default. Replay now runs with the inferred storage_dir, so
bash background:truecompletions are delivered after an OpenCode restart even without explicitstorage_dirconfig. Detached PID liveness recovery handles externally-killed children. aft doctoris now read-only. Plainaft doctorruns inspection without mutating config or running install commands. Useaft doctor --fixfor the previous auto-remediate behavior. ONNX is only flagged as a problem whensemantic_searchis enabled. Issue title sanitization, JSONC comment preservation, and streaming log tail are in.- Out-of-project navigate paths return an honest error. Calling
aft_navigateon a path outsideproject_rootnow returnspath_outside_project_rootwith a clear message instead of misleadingly reporting 0 results.
Detailed changes
Safety and undo
- Operation-scoped backup IDs for multi-file
aft_delete,aft_move,move_symbol,ast_replace - Backup tombstone API for
aft_move(undo removes destination + restores source atomically) delete_filerejects symlinks before mutation- Session marker handling: markerless session dirs are skipped instead of being collapsed into
__default__ - Backup paths resolve against
project_rootconsistently regardless of process CWD storage_dirreset cleans stale checkpoint directories
Navigate / callgraph
- Workspace package imports (
@org/pkg) resolve to monorepo siblings main: "dist/..."falls through tosrc/...when source exists alongside compiled output- Top-level call sites (e.g. inside
describe/testblocks) indexed as<top-level>callers callers,impact,trace_to,trace_datareject out-of-project paths withpath_outside_project_root
Edit / write / read honesty
readreturns real file length intotal_lines(continues scanning past requested range)- Partial reads return
complete: falseinstead of falsely claiming complete - Batch / edit_match / edit_symbol / extract / inline omit
syntax_validwhen validation didn't run inline_symbolmatches multiline calls by start-lineapply_patchall-failed path throws (UI shows error state) instead of returning misleading success
LSP
- Watched-files dynamic registration via
client/registerCapability(LSP 3.17 protocol-correct) workspace/diagnostichonors caller timeout with$/cancelRequest- Centralized Windows URI helper handles
\\?\,\\?\UNC\server\share, and drive paths consistently across manager / position / client - Directory mode reports
WorkspaceDiagnosticReportResult::Partialascomplete: false
Compression
toml_filter[shortcircuit]regex no longer multi-line by default (previously,when = "^\\s*$"could match any blank line and collapse real output tomake: ok)compress_tscpreserves top-level errors likeTS18003: No inputs found in config fileinstead of dropping them
Bash
- Background tasks replay on default storage_dir (completions delivered across restart automatically)
- Detached PID liveness check distinguishes externally-killed children from running tasks
findrewrite routes absolute paths through glob'spatharg instead of embedding in pattern
Parser / extract / imports
- Symbol cache invalidates by content_hash on mtime collision (fixes false-cache hits on dev cycles)
- TS
export { foo }andexport default foocorrectly detected as exports - Default imports resolve to the real symbol name + metadata
- Namespace imports (
import * as ns) preserved throughaft_import organize(previously degraded to side-effect import) extractis scope-aware: detects enclosing function correctly (not the firstconst x = ...)extractpreserves nested indentation in the extracted bodyextractemitslet/varat call-site when caller already hadlet/varextractsubstitution is scope-aware: nested callback parameters shadowing the same name aren't renamed
OpenCode plugin parity
aft_bash,bash_status,bash_killregistered withaft_prefix when host bash hoisting is disabledclient.session.getshape matches current SDK- Transaction edit, delete, legacy
aft_editthrow on Rust failure (consistent with the rest of the tool surface) onVersionMismatchmigrated to coordinated-retry callback shape
Pi plugin parity
- LSP auto-install uses npm (not Bun; Pi runs under Node)
- Version mismatch reads stderr (Pi v0.74 emits version to stderr in RPC mode)
- Hot-swap path: replaceBinary returns new path; bridge retries in-flight request
- AST grep / replace schema hints surface server-provided guidance
aft_deletethrows on Rust failure (was silently returningsuccess: false)onVersionMismatchmigrated to coordinated-retry callback shape
CLI / doctor
- Plain
aft doctoris read-only (use--fixfor remediation;--forcealiased for back-compat) - ONNX
compatible: falseonly flagged as problem whensemantic_searchis enabled - Issue title sanitization (strips usernames/paths from
--issuebundle title) - JSONC comment preservation through config rewrites
- Binary version probe before extracting cached archives
- Streaming log tail for
--issuebundle
Security
url-fetchSSRF check runs at both cache-check time AND fetch time (prevents a URL fetched once withallowPrivate=truefrom being readable later withallowPrivate=false)- Version-mismatch handling no longer fire-and-forget; the in-flight request is coordinated with the hot-swap and retried transparently
CI / release
tests.ymlnow triggers on changes toscripts/**and release workflows (previously could merge with no CI run if only those paths changed)- All npm publish jobs idempotent — preflight
npm viewskips already-published versions as success rather than failing the rerun - macOS E2E hard-fails on missing artifacts or silent
npm installfailures (previously masked by hardcoded"0.19.5"fallback) scripts/wait-release.shfails fast ongherrors instead of polling forever
Upgrade
npx --bun @cortexkit/aft@latest doctor
If your plugin or binary is older than 0.26.0, restart OpenCode after upgrade so the new bridge spawns.
v0.25.2
v0.25.2
Patch release fixing a latent binary auto-download bug that has affected anyone whose npm optional-dependencies didn't install — most commonly Windows users hitting bun add's known reliability issues with optional deps.
What was broken
When the resolver fell through to the GitHub Releases auto-download fallback (because the bundled @cortexkit/aft-<platform> package was missing or version-mismatched), it constructed a 404 URL: releases/download/0.25.1/aft-darwin-arm64 — missing the v prefix that GitHub release tags actually use. Users in that path saw repeated:
ERROR [aft-plugin] Failed to download AFT binary: HTTP 404: Not Found
This is almost certainly the same root cause as issue #39, where a Windows user had to manually place files in the binary cache to recover.
Why this stayed hidden
The auto-download path is the last resort in the resolver. Most users get the binary directly from the npm platform package they install alongside @cortexkit/aft-opencode. The hot-swap upgrade path (which prepends v explicitly) also worked correctly, so all our local upgrade testing passed. Only the "platform package didn't install or doesn't match" first-install case was broken.
What changed
downloadBinary(version) and ensureBinary(version) now normalize the tag to a v-prefixed form internally. Both "v0.25.1" and "0.25.1" produce the same correct URL + cache directory. Three regression tests pin this behavior.
If you've been seeing HTTP 404 in $TMPDIR/aft-plugin.log, upgrading to 0.25.2 fixes it.
v0.25.1
v0.25.1
v0.25.0 shipped to npm but failed to publish to crates.io, and its binaries reported themselves as aft 0.24.0. v0.25.1 is the corrected release of that work — the actual release notes follow below. (Technical details on what went wrong are at the bottom.)
New languages, atomic operation undo, and recursive directory delete with first-class safety guardrails. Every change applies to both @cortexkit/aft-opencode and @cortexkit/aft-pi.
JSON and Scala outlines
aft_outline now understands two more languages.
JSON — top-level object keys outline as Variable symbols with their key span as line range. Works on package.json, tsconfig.json, biome.json, lockfiles, RTK filter manifests, anything. Directory-mode outlines no longer fill skipped_files with unsupported_language: *.json entries.
Scala — classes, objects, traits, defs, vals, vars, case classes, and type aliases now outline with accurate kinds and line ranges. Scala 3 enum types outline as Class, and enum-contained methods are correctly scoped (e.g. Color.describe). Named given definitions outline as Variable; anonymous givens are skipped. aft_zoom works on Scala symbols. AST search/replace is not supported for Scala.
One tool call = one undo
aft_safety undo now restores the entire last mutation operation atomically when called without a filePath.
Every mutating tool (aft_delete, ast_grep_replace, apply_patch, aft_refactor move, aft_move, multi-file edit transactions, etc.) now tags every file it touches with a single operation id. aft_safety undo with no arguments looks up the most recent operation and reverses every file in it as one transaction. aft_safety undo with an explicit filePath still does the existing per-file pop — backwards compatible.
The restore path is properly transactional: AFT preflights every file write to memory, creates any missing parent directories, and only commits the in-memory undo history changes after every write succeeds. If a write fails midway (permission denied, ENOSPC, etc.), AFT rolls back any files already written to their pre-restore content, removes any directories it created, leaves the undo history untouched, and returns the original error with a partial_rollback indicator. You can retry without losing history.
The backup store schema bumped v2 → v3 with seamless migration: legacy v2 backups load with op_id: None and remain per-file undo-able (the old behavior). New backups carry op_ids.
Recursive directory delete with safety guardrails
aft_delete files: [...] now accepts directories when called with recursive: true. It walks the tree, backs every file up under a single op_id (see above), then removes the directory. A single aft_safety undo afterward restores the entire directory tree — files, parent dirs, and all — in one call.
Before deleting, AFT validates the tree contains nothing it can't reliably restore. If the tree contains any symlink or any empty directory, the delete is refused with a unsupported_directory_contents error that names the offending paths. The filesystem is untouched in the rejection case. This is a deliberate guardrail — symlinks could resolve outside the tree on restore (writing arbitrary files), and empty dirs aren't currently representable in the backup format. Both cases will be supported in a future release with proper node-type metadata.
Without recursive: true, directory paths return invalid_request with a clear message pointing to the flag.
Stop orphaning LSP child processes
Fixes the long-standing killall biome workaround. AFT now puts each LSP server in its own process group at spawn and SIGKILLs the entire group on shutdown. Previously only the npm shim wrapper PID was killed, leaving the real server (e.g. @biomejs/cli-darwin-arm64 biome lsp-proxy) orphaned to PID 1 and accumulating across restarts.
Applies to all LSP servers that use a wrapper-and-child structure — biome, eslint, prettier, and similar npm-distributed servers. On Windows, the equivalent fix uses taskkill /F /T to kill the entire process tree.
Other
-
RPC status timeout warnings gone — between bridge spawn and the first push-frame transition, the plugin's status cache was empty, so the TUI sidebar's 1.5s poll would fall through to a bridge call that raced the in-flight eager configure and aborted at 5s. AFT now seeds the cache directly from the eager configure response so the first poll always hits warm cache.
-
CI — workflows bumped to
actions/checkout@v5andactions/setup-node@v5, removing Node 20 deprecation warnings.
Why v0.25.1 (technical detail)
The v0.25.0 tag was placed on a commit where Cargo.toml and package.json files still said 0.24.0. The release workflow then built platform binaries from that stale Cargo.toml, so aft --version reported 0.24.0 (because CARGO_PKG_VERSION is baked in at compile time). cargo publish tried to publish agent-file-tools@0.24.0 to crates.io, got "already exists", and a graceful fallback masked the mismatch as success. The npm publish step had its own version-sync that ran from the tag, so the npm packages did go out at 0.25.0. Net result: npm got 0.25.0 binaries that reported themselves as 0.24.0, and crates.io got nothing new.
Fixed for future releases: version-sync.mjs --from-tag now runs in publish-crates and in every build-* job (not just the npm publish step). The crates.io "already exists" fallback now only treats success if Cargo.toml's post-sync version matches the tag.
Workflow architecture also refactored: both tests.yml (PR-time) and release.yml (tag-push) now call a single reusable _unit-suite.yml for unit-level coverage (Linux, macOS, Windows cargo, Windows bash e2e). Removes ~400 lines of duplicated job logic and ensures PR-time and release-time unit jobs can't drift. The reusable workflow takes a strict boolean: PR mode keeps Windows jobs non-blocking (continue-on-error: true); release mode makes ALL four unit jobs gate the publish flow. A half-published v0.25.0 is exactly the state the new strict gate refuses to ship.
v0.24.0
v0.24.0
Focused improvements to how AFT runs alongside parallel work, how it talks to its plugins, and how it reports its own state. Every change in this release applies to both @cortexkit/aft-opencode and @cortexkit/aft-pi. Matters most for users running subagents, multiple worktrees, the TUI sidebar, or Pi v0.74+.
Cross-worktree cache reuse
When you spawn a new git worktree (e.g. for a subagent task) and AFT starts there, it now reuses the main project's on-disk search, semantic, and symbol caches via content-hash freshness checks instead of rebuilding. The 30-50 second CPU spike per worktree start is gone for typical projects.
Worktree bridges are now ephemeral readers: they load the base cache, refresh anything that has changed via Blake3 content hash, and never write back. The main project bridge stays the sole owner of cache state, so concurrent worktrees can't clobber each other.
One-time forced rebuild of all three caches happens the first time you launch v0.24 against an existing project. Expect ~30-60s on first launch as the new format is populated; every launch after that is fast.
Push-driven status updates
/aft-status and the TUI sidebar used to round-trip through the AFT bridge on every poll (~every 1.5s). On a busy bridge — running grep, semantic builds, or watcher invalidation — that poll would queue behind real work and sometimes hit a 5-second timeout, producing misleading "retrying after port refresh" warnings.
AFT now pushes status changes directly to the plugin when configure completes, index builds finish, or LSP servers attach. The plugin caches the snapshot in memory; status calls hit that cache in microseconds without touching the bridge. Updates are debounced by 1 second to coalesce bursts.
Net effect: status is essentially free now, and the spurious RPC timeout warnings stop. Status push frames are also drained on idle (every 250ms), so the TUI sidebar transitions loading → ready automatically as soon as a background index build completes — no more sitting on "loading" until you fire a tool call.
Redesigned /aft-status dialog
Both harnesses get a redesigned dialog inspired by @cortexkit/opencode-magic-context's /ctx-status:
- OpenCode (TUI) — a themed two-column JSX dialog with flex layout, color-coded status tones, and a
cache_roleaccent (main / worktree / not_initialized). Fits cleanly in the standard TUI viewport without scrolling. - OpenCode (Desktop) — unchanged plain-text snapshot via
sendIgnoredMessage. - Pi — a custom overlay component (
ctx.ui.custom(...)) with bordered two-column layout, themed colors, and 1.5s auto-refresh so loading → ready transitions surface live. Replaces the prior single-line input-prompt rendering that was effectively unreadable.
ONNX Runtime race on Pi launch
When Pi launched with semantic search enabled, the eager bridge warm-up spawned ~4ms BEFORE the ONNX Runtime download path was patched onto the pool's configure overrides. The bridge that served the agent therefore had no _ort_dylib_dir, so Rust fell through to a system-path dlopen("libonnxruntime.dylib") that fails on managed installs. Symptom: /aft-status showed semantic_index: failed with ONNX Runtime not found even though the runtime had finished downloading seconds earlier.
OpenCode already awaited the ONNX promise (capped at 60s) before its eager spawn; Pi now mirrors that exact path. Semantic indexes now build cleanly on first launch instead of staying failed until manual restart.
Background bash completion reliability
Fixed a regression where background bash completion notifications could be silently dropped, leaving the agent waiting indefinitely. The wake path bailed early if the bridge was busy with any in-flight call — but that included unrelated status RPC polls and configure work, not just agent tool calls. When a completion arrived during one of those windows, no follow-up trigger fired and the completion sat in a pending queue forever.
The early-return was wrong; the downstream debounce, timer cancellation, and retry mechanisms already handle the original concern correctly. Wakes are now always scheduled when a completion arrives, regardless of bridge activity. The 200-1000ms debounce window and in-turn drain cancellation guard still prevent duplicate or empty notifications.
Symmetric fix in OpenCode (promptAsync wake path) and Pi (sendUserMessage with deliverAs: "steer"). If you experienced "main agent stuck waiting for background bash" symptoms in v0.23.x, this fixes the root cause for both harnesses.
Pi v0.74 doctor parity (#37)
Pi v0.74 changed where it stores installed extensions and how pi --version writes output, breaking bunx --bun @cortexkit/aft doctor for Pi v0.74+ users. The visible symptom was Plugin registered: false reported even when AFT was correctly installed, plus +0/-0 edit counts in diagnostics. Three fixes:
- Plugin detection now reads
~/.pi/agent/settings.json(new v0.74 location) and falls back to the legacyextensions.jsonfor older Pi installs. Handles all four package-source forms —npm:<spec>,file:<path>, absolute paths, and relative paths against the agent directory. Path entries verify againstpackage.jsonname instead of substring-matching, so look-alikes likeawesome-aft-pi-thiefdon't trigger a false positive. - Host version detection now reads from both stdout and stderr (Pi v0.74 redirects stdout in non-interactive mode) and tolerates startup banners pre-empting the version line.
- Doctor output labels renamed
CLI/BinarytoAFT CLI/AFT binaryto remove ambiguity with Pi's own versions.Host versionis now on its own line so "unknown" is explicit instead of silently omitted.
v0.23.0
v0.23.0
Highlights
aft_search overhaul — better recall, hybrid lexical lane, source provenance
The biggest semantic-search change since the feature was introduced. Three coordinated improvements landed together:
-
Query-shape classifier + per-shape weighting. The query is now classified as identifier-like (
HashMap,useState), path-like (src/utils/auth.ts), error-message-shaped, mixed, or natural-language. Each shape gets a tailored treatment instead of one-size-fits-all cosine ranking. -
File-summary chunks for small files. Files with two or fewer top-level exports now get a synthetic file-summary chunk that embeds the path, exported names, and signatures together. Generic-file queries like "where is the rate limiter" used to return zero results when the answer was a short single-export module; now they surface the file directly. Per the new built-in eval harness on this codebase: generic-file P@5 went from
0.000→0.333, identifier P@5 went from0.600→0.800, overall from0.607→0.750. -
Hybrid lexical lane. A second retrieval lane runs alongside the existing semantic lane and contributes results that exact-token matches the query. Each result now carries a
sourcetag —"semantic"(embedding match only),"lexical"(trigram exact-token match the embedding lane missed), or"hybrid"(both lanes agreed — strongest signal). The lexical lane especially helps for path-shaped queries and error messages where embeddings underperform.
The aft_search tool description was rewritten around concrete "when to use / when not to use" triggers so agents reach for it for the right shapes of question. Score floor was removed (was suppressing valid 0.30-0.45 hits); Markdown/HTML heading-only chunks no longer outrank code chunks for code-flavored queries.
Bumped semantic-index chunking_version to 2. The old V1 cache deserializes with a serde default and lazily backfills file-summary chunks on the first v0.23 run per project — no manual reindex needed. Total chunk count roughly doubles after backfill (file-summary chunks add one synthetic chunk per qualifying file).
Resolver: refuse stale @cortexkit/aft-<platform> packages on version mismatch
A workspace that upgraded the AFT plugin (e.g. v0.19.5 → v0.22.x) while a stale @cortexkit/aft-<platform> was still hoisted in node_modules — common with bun's .bun/install/cache keeping multiple versions — could see the resolver silently pick the older binary instead of the version-matched cached one. The wrong-version binary still passed basic protocol but emitted pre-rename behavior (in the original repro: bgb- task slugs that don't match the plugin's bash- regex, producing tool-result mismatches).
The resolver now invokes --version on the npm-resolved binary before returning it. If the version doesn't match the plugin's expectedVersion, it logs a warning and falls through to PATH lookup so a locally-built or correctly-installed binary can take over. Both plugin entry points now plumb their PLUGIN_VERSION explicitly into findBinary().
Pi v0.74.0 — migrated to @earendil-works package scope
Pi's coding-agent project moved from the @mariozechner/* npm scope to @earendil-works/* as of Pi v0.74.0; the old packages now carry a "please use @earendil-works/pi-coding-agent instead going forward" deprecation notice. AFT's Pi plugin and Pi RPC test harness now declare the new scope directly. Pi v0.74.0 also switched its embedded type-schema runtime from @sinclair/typebox@0.34 to typebox@1.x — AFT's tool definitions migrated alongside it. No agent-visible API changes.
Pi RPC end-to-end test harness
New tests/pi-rpc/ workspace with a JSONL RPC client, aimock-driven mock OpenAI-compatible provider, and a real Pi process spawn helper modeled on the magic-context Pi runner pattern. Sixteen scenarios cover hoisted reads, permission asks, semantic search, foreground bash, background bash with completion notifications, and post-completion drain across Pi restarts. Wired into the reusable E2E workflow so CI now blocks on Pi behavior the same way it blocks on OpenCode.
The harness already paid for itself during this release cycle: it caught the resolver version-mismatch bug above and a real Pi-side bug where drainCompletions bailed entirely when the RPC envelope omitted sessionID (Pi's RPC mode does not always send one). Pi now forwards an empty params object to Rust so the binary uses its __default__ session namespace and the drain still works.
Permission asks: Pi external-directory parity, OpenCode subagent grep parity
- Pi's hoisted
read,write,edit,apply_patch,grep, andglobnow askexternal_directorypermission for paths outside the project root, mirroring OpenCode's behavior. Previously Pi would silently allow reads/writes anywhere on the filesystem when the agent passed an absolute out-of-project path. - OpenCode hoisted
grepnow asksexternal_directorypermission too. Already present onread/write/edit/etc, butgrephad been left out — agents could still read sensitive files outside the project via repeated grep calls.
Fixes
apply_patch rolls back surviving files when one hunk fails
Previously, apply_patch would commit successful per-file changes even when a later hunk in the same patch failed. The combined effect was a partial application that left the workspace half-edited. Now: any hunk failure rolls back the entire patch atomically using the existing checkpoint/restore path.
Parser: TypeScript export symbol range no longer leaks across replacements
aft_edit { mode: "symbol", operation: "replace" } for export function foo() {...} now includes the leading export keyword in the symbol range, so a replacement string that itself starts with export no longer produces export export function foo() {} and get rolled back.
aft doctor lsp <file> no longer mistakes push frames for the response
The CLI's NDJSON request matcher was indexing responses by arrival order, so a configure_warnings push frame arriving between request and response would be treated as the lsp_inspect response and the actual response would be discarded. Matcher now keys by request id and skips push frames, fixing #34.
Cleanup of dryRun dead branches in 16 Rust command files
The dryRun removal in v0.22.0 took the parameter off the agent-facing schemas but left dead branches in command handler code. Cleaned up across aft_import, aft_refactor, aft_transform, write, edit, apply_patch, and related batch helpers. ast_grep_replace still supports dryRun (workspace-wide AST replacement legitimately benefits from a preview pass).
Session-id threading in Rust log lines
Per-request Rust log lines now carry the originating [ses_xxx] session prefix when the request supplied one. Maintenance and watcher events that are not session-scoped (file invalidation, symbol cache pre-warm, configure-time setup) intentionally remain untagged. Helps correlate semantic refreshes, checkpoints, and format runs back to the triggering session when debugging.
Smaller things
aft_zoomPi rendering: improved single-symbol display.aft_outlineURL fetching: better content-type negotiation (HTML, Markdown, GitHub README API media types).- Plugin auto-update checker: reduced log noise on startup; cross-instance dedup honored on disk for plugin developers running multiple OpenCode windows.
Known issues (planned for v0.24)
- Cache-reuse across worktrees: when the same git repo is checked out at multiple paths (e.g. parallel worker worktrees), each worktree currently builds its own semantic / search / symbol caches even though git root commit is shared. The v0.24 plan (already spec'd) adds content-hash freshness fallback and migrates semantic to relative paths so worktrees can share the same cache.
- Lock contention when multiple bridges spawn simultaneously for the same project root logs
failed to acquire semantic cache lock: timed out. Cosmetic — they all converge to the correct state — but should be cleaned up alongside the cache-reuse work.
Full Changelog: v0.22.1...v0.23.0
v0.22.1
v0.22.1
Patch release. Four fixes since v0.22.0.
Fixes
Semantic search against OpenAI no longer fails with "you must provide a model parameter" (#36)
When semantic.backend: "openai_compatible" pointed at https://api.openai.com/v1, AFT's embedding requests were rejected with HTTP 400 "you must provide a model parameter" even though the configured model was set correctly in aft.json. Root cause: AFT was sending two Content-Type: application/json headers on the wire — once implicitly via reqwest's .json(&body) (which serializes the body and sets the header) and again via an explicit .header("Content-Type", "application/json") call right after. reqwest's .header() calls HeaderMap::append, not replace, so both ended up on the wire. OpenAI's /v1/embeddings parser treats duplicate Content-Type as malformed and rejects the body — including the model field that's actually there.
The fix drops the redundant explicit header from both the OpenAI and Ollama backends. The Ollama branch had the same defect; most Ollama servers tolerate duplicate Content-Type so it never surfaced in user reports, but the fix lands consistently.
A new regression test captures the raw on-wire request and asserts exactly one Content-Type header is sent.
Pi: stop downloading ONNX Runtime when the configured backend doesn't need it
Pi's startup gated the 60-80 MB ONNX Runtime download on config.semantic_search alone, so Pi users with semantic.backend: "openai_compatible" or "ollama" still triggered the download even though the runtime is never loaded for HTTP-based backends. Pi now mirrors OpenCode's gate — ONNX Runtime is only fetched when semantic_search is enabled AND the backend is fastembed.
aft_zoom now accepts the ## / <h2> prefixed form that aft_outline shows
aft_outline emits Markdown and HTML headings with their level prefix:
## Basic usage 32:219
<h2> Features 219:234
Agents naturally copy-paste that prefixed form into the next aft_zoom call. Until now AFT rejected those lookups with symbol '## Basic usage' not found and only accepted the bare text form. aft_zoom now strips the level prefix on the Markdown/HTML resolution path so both Basic usage and ## Basic usage resolve to the same section. Code-symbol resolution is unchanged — Rust attributes like #[derive(Debug)] still match exactly.
Stop polluting Windows builds with unused-warnings
Six items in bash_background/registry.rs, commands/bash.rs, and semantic_index.rs only have call sites on non-Windows targets but weren't gated, so Windows builds emitted unused_imports and dead_code warnings. Added #[cfg(...)] predicates matching the call-site availability so the items only exist where they're reachable. Also corrected a stale doc comment in windows_shell.rs that referenced a spawn_shell_command function which no longer exists (its body was absorbed into bash_background::registry::spawn_detached_child during the v0.20.x foreground-as-polled-background refactor).
v0.22.0
Highlights
LSP correctness, freshness and isolation
- Post-edit diagnostics now wait for version-proven publishes per server/root key. Stale cached diagnostics from open-time publishes can no longer satisfy a freshness check started after the edit. Workspace-pull diagnostics now respect a wall-clock timeout and cancellation when supported.
textDocument/diagnosticpull responses no longer over-claimcomplete: truewhen only some servers responded. File-mode push-only freshness is now keyed per-file, so a fresh publish fora.tscan't whitewash stale state forb.ts.aft doctor lsp <file>now reports successful inspections correctly instead of printinglsp_inspect failedwhen other server output arrives mid-stream (#34).
LSP auto-install — supply-chain trust + redirect + bomb resistance
- npm and GitHub auto-installs now write
.aft-installedmetadata (version + sha256) and validate it on every cache hit. Mismatched binaries are quarantined instead of being trusted on path existence alone. - npm version pins go through safe-version validation. GitHub asset downloads are constrained to a hostname allowlist and follow no redirects. Extracted archives are size-capped (256 MB download, 1 GiB extracted).
- Project config can no longer inject
lsp.servers,lsp.versions,lsp.auto_install,lsp.grace_days, orlsp.disabled. Those keys are now user-config only. - ZIP extraction on Windows uses direct
tar.exeinstead of shelling out to PowerShell.
Bash subsystem — permissions, shell selection, kill race
bash_permissionsnow scans redirect targets including dynamic ones likeecho > $OUTFILE, so commands likeecho hi > /tmp/foocorrectly ask forexternal_directorypermission instead of silently bypassing it.- POSIX shell resolution honors
$BASH, falls back towhich(bash), then/bin/sh— previously hardcoded. bash_killnow reads the exit marker before settingKillingstatus. If the child finished cleanly between the kill request and the registry update, the real exit code wins.- Failed-spawn bundle cleanup deletes wrapper/marker files instead of leaving them in the background-task directory.
- Windows bash uses
.batwrappers (not.ps1) and captures%ERRORLEVEL%correctly. Backgrounded-task previews are reconstructed from disk after replay. - Bash task slug renamed from
bgb-tobash-.
Subagent bash — no more 5-second auto-promotion
OpenCode subagent sessions (spawned worker turns) no longer convert background: true into a background task with no waiting model on the other end. background: true is silently converted to background: false, and foreground bash polls until the command terminates or its timeout fires. Primary sessions keep the existing auto-promotion behavior.
Search, semantic, configure — atomicity + ordering
- Search-index persistence writes through temp files and atomic rename. Trigram cache no longer drops on partial write.
- Semantic-index refresh is non-destructive and re-detects newly added files. Stale data no longer leaks into the warm cache after the index is invalidated.
- File watcher now respects
.gitignorerules instead of a hardcoded skip list, so build outputs likedist/,node_modules/, framework caches don't trigger constant cache invalidation. Live rebuild on.gitignorechanges. - Watcher path matching canonicalizes paths to handle macOS
/varvs/private/varand broken symlink chains on Linux.
aft-bridge — transport + ONNX install + pool
- NDJSON stream uses
StringDecoderfor safe multi-byte UTF-8 handling. Bridge timeouts reject sibling pending requests with an explicit abort error before killing the process. checkVersion()hard-fails onsuccess: falseor missing version instead of being silently swallowed.- ONNX install splits cleanup into a pre-lock staging-dir sweep (cleans abandoned attempts by dead PIDs) and a post-lock target verification. Failed copies hard-fail and remove the partial install. Symlinks are recreated after the real files.
- Bridge pool LRU cleanup skips bridges with pending requests instead of killing in-flight work.
BridgePoolandBinaryBridgeaccept alogger?: Loggeroption for per-instance logger override.
Plugin orchestration
- Background-bash completion wake-ups now preserve
{ providerID, modelID, variant }from the last real assistant message so synthetic prompts don't bust the provider's prefix cache. grepandglobnow askexternal_directorypermission for out-of-project paths, with brace-aware include splitting.- Windows path normalization matches OpenCode's native handling so AFT-submitted patterns work with granular
~/projects/personal/**-style permission rules. bash_statuslookup falls back to disk when the in-memory registry has been cleared by a bridge restart. Persisted task GC deletes delivered-terminal tasks and quarantines corrupt JSON.
Formatter timeout — honor it for shell-launched hanging tools
When a configured formatter hung (deadlocked linter, stuck network probe, etc.), formatter_timeout_secs could silently turn into the natural exit time of the underlying process. The timeout path killed only the immediate child; orphaned grandchildren kept the stdout/stderr pipes open, and the wait blocked until they exited on their own. On Unix, the child now spawns in its own session and the timeout path kills the entire process group, so formatter_timeout_secs is enforced as advertised.
Code-symbol editing
- TypeScript / JavaScript / TSX
aft_edit { mode: "symbol", operation: "replace" }forexportdeclarations now includes the leadingexportkeyword in the symbol range. Replacements that themselves containexportno longer produceexport export function foo() {}and get rolled back. - Pi UI for
aft_zoomnow renders the zoom result instead of showingNo zoom result availablefor single-symbol calls.
Removed dryRun from mutation tools (kept on ast_grep_replace)
aft_import, aft_refactor, aft_transform, write, edit, apply_patch no longer accept dryRun: true. Use aft_safety checkpoint and aft_safety undo for rollback. ast_grep_replace keeps dryRun because workspace-wide AST replacement genuinely benefits from a preview pass.
Full Changelog: v0.21.0...v0.22.0
v0.21.0
Highlights
Tiered bash output compression with TOML filters + new Rust modules
v0.21 ships the long-pending compression mechanism. Hoisted bash output now flows through a three-tier dispatch (with experimental.bash.compress=true):
- Rust modules — hand-written parsers for high-traffic tools. v0.21 adds three new ones (
eslint,vitest/jestsharing a parser,biome) plus six newgitsubcommand compressors (add,commit,push,pull,fetch,stash) on top of the existinggit status/log/diff/blame. JSON output is parsed where the tool offers it. - TOML filters — declarative
strip+truncate+cap+shortcircuitrules. v0.21 ships 15 builtin filters:make,ls,tree,df,du,find,wc,gradle,xcodebuild,terraform,helm,docker,kubectl,gh,ansible-playbook. Filters can also be added by users under<storage_dir>/filters/*.toml(always loaded) or by projects under<project>/.aft/filters/*.toml(trust-gated). - Generic fallback — ANSI strip + consecutive-line dedup + middle-truncate, always applied when no module or filter matches.
Per-call opt-out via compressed: false on the bash tool — preserves raw output for that specific call while keeping the global default on.
Trust model for project filters. Project-supplied filters are an attack vector (a malicious repo could ship a cargo.toml filter that strips real failures and replaces them with tests: ok). They are off by default. Use the new shared CLI to opt in:
npx --bun @cortexkit/aft doctor filters # list builtin + user + project filters
npx --bun @cortexkit/aft doctor filters trust # interactive trust prompt for current project
npx --bun @cortexkit/aft doctor filters --show <name>
Trust state lives in <storage_dir>/trusted-filter-projects.json keyed by canonicalized project root.
Issue #33 — TUI plugin loads on OpenCode 1.14.42-43
api.command.register was removed in OpenCode 1.14.42 and reinstated as a deprecated shim in 1.14.44+. The TUI plugin was crashing on the 1.14.42-43 range with api.command is undefined. Migrated to api.tools.toolDefinition + api.keymap.registerLayer, with a backward-compat fallback when those aren't present. /aft-status and the Ctrl+a, ? keybind now work across <=1.14.41, the broken 1.14.42-43 range, and 1.14.44+.
Issue #32 — grep brace-glob splitting at the Rust boundary
The plugin layer already brace-aware-splits **/*.{ts,tsx},**/*.{js,jsx} correctly, but direct binary callers (bash rewrite, CLI users) hit grep: invalid include/exclude glob: unclosed alternate group because the Rust string_array_param only accepted arrays. Now accepts both strings and arrays, and runs every input through a brace-aware splitter that treats , as a separator only when {/} depth is zero. Same robustness across all caller paths.
Other
- Compression config (
experimental.bash.compress, project filter trust state, storage dir, project root) now re-picks up on the nextconfigurewithout restart — change a setting and the next bash call honors it. - Empty-body
[BACKGROUND BASH STILL RUNNING]reminders are gone. A plugin-side race between the in-turn drain and the wake debouncer could fire a reminder shell with no pending tasks attached; both OpenCode and Pi now cancel the debounce timer when the drain absorbs the pending list, and the timer itself short-circuits if there's nothing to report. - README compression section was rewritten around the three-tier dispatch with a TOML filter authoring guide.
ARCHITECTURE.mdgained a dedicated "Bash Output Compression" section.
Full Changelog: v0.20.1...v0.21.0
v0.20.1
Highlights
Foreground bash now works correctly on Windows. In v0.20.0 the new foreground-as-polled-background architecture inadvertently routed model-issued bash commands through cmd.exe even when the model wrote PowerShell-syntax ($var = ..., Start-Sleep, Add-Content), and a separate process-flag bug made PowerShell wrappers silently exit before writing the exit marker. The fix:
- PowerShell wrappers can now flush stdout/stderr and reach
Move-Itemunder detached spawn. ReplacedDETACHED_PROCESSwithCREATE_NO_WINDOWfor Win32 process flags. UnderDETACHED_PROCESS, pwsh sometimes exited before completing later script statements (theMove-Itemthat writes the exit marker never ran), leaving the bg task forever markedFailed: process exited without exit marker.CREATE_NO_WINDOWkeeps the child without a visible console while still giving it a hidden console handle, so PowerShell file I/O completes correctly. - Restored the natural shell priority (pwsh → powershell → git-bash → cmd). The v0.18-era cmd-first override was a workaround for the now-fixed PS detached-output bug; it silently misrouted PS-syntax commands through cmd, causing immediate
'$marker' is not recognizedfailures.
The Windows native E2E gate is back to blocking releases (the continue-on-error: true from v0.20.0 is removed). Test (Windows — bash perms), Linux Docker E2E, macOS native E2E, and Windows native E2E all gate publishing now.
Full Changelog:
v0.20.0...v0.20.1
v0.20.0
Highlights
`Foreground bash now auto-promotes long-running tasks to the background instead of killing them at an arbitrary timeout. Agents get a fast inline result for short commands and a reliable completion reminder for long ones, with no need to predict task duration up front.
Three other user-visible changes:
- Vue (
.vue) is now a first-class language foraft_outline,aft_zoom, andast_grep_search/ast_grep_replace. - Auto-update reliability fix — the plugin update checker now triggers at plugin load instead of only at
session.created, so resumed sessions and parallel OpenCode windows actually check for new versions. - Brace-aware grep includes — patterns like
*.{vue,ts}and*.{js,jsx,ts,tsx}no longer get split on the comma into invalid separate globs.
Foreground-as-polled-background bash
Every bash call now routes through the same background infrastructure internally, so the Rust dispatch loop never blocks. Foreground execution becomes a thin polling layer on top:
- Plugin polls
bash_statusfor up to ~5 seconds. - If the task finishes inside the wait-window, it returns inline as before.
- If it doesn't, the plugin returns a "promoted to background" message and the agent gets a
taskIdit canbash_status/bash_killagainst later. - A completion reminder is delivered automatically when the task actually finishes, even if the agent has already moved on to other work.
Crucially, the wait-window is decoupled from the task's kill cap:
| Call shape | Wait-window | Task kill cap |
|---|---|---|
bash({ command }) |
5s | 30 minutes |
bash({ command, timeout: 30000 }) |
5s | 30s (hard kill at timeout) |
bash({ command, timeout: 2000 }) |
2s | 2s |
bash({ command, background: true }) |
0 (no poll) | 30 min |
bash({ command, background: true, timeout: 600000 }) |
0 | 10 min |
Practical effect: a long-running e2e test launched as foreground bash with no timeout no longer gets killed after 30 seconds. It runs in the background up to the 30-minute default, and the completion reminder carries the actual exit code and a tail of the output. Explicit timeout: N still means "hard kill at N seconds" — same mental model as timeout(1), Docker, and Kubernetes.
bash_status and bash_kill are now registered alongside bash whenever any experimental.bash.* flag is on, not just when experimental.bash.background is enabled. This way the agent always has tools to inspect or kill auto-promoted tasks, regardless of which experimental originally enabled bash hoisting.
The timeout schema is also tightened — agents can only pass positive integer milliseconds; NaN, negatives, zero, and floats are rejected at the schema level, eliminating a class of invalid-input edge cases that could hang the polling loop.
Vue support
tree-sitter-vue is now wired through the parser, language detection, outline, zoom, and AST stack. Single-file components extract template, <script setup lang="ts">, and <style scoped> as top-level outline nodes. Embedded script content is opaque raw_text to tree-sitter-vue (a known upstream limitation), so deep symbol extraction inside the script block is not yet available — but Vue templates and component structure are now searchable and editable through AFT's structural tools.
AST patterns work too: @click="$NAME" and similar template patterns capture meta-variables correctly.
Auto-update fix
The plugin's update checker previously hooked into session.created, which meant resumed opencode -s sessions and parallel windows that joined an existing project never re-checked for new versions. The checker now triggers at plugin init with a short delay, coordinates across parallel plugin instances via an on-disk dedup file under the plugin storage directory, and clears pending timers on abort.
Brace-aware grep includes
Naive comma-splitting in the hoisted grep tool's include parameter was breaking patterns like *.{vue,ts} into the two invalid fragments *.{vue and ts}. The split is now brace-aware in both OpenCode and Pi adapters, so multi-extension include patterns work correctly.
Quality
- +62 Pi unit tests across 17 new files (audited against actual module gaps rather than indiscriminate coverage).
- Audit-driven safety hardening in the bash subsystem: input validation at the schema boundary, wait-window math simplification, transport-timeout cleanup, parity fix between Pi and OpenCode
isTerminalStatusallowlists. - Combined test surface: 1,155 Rust tests, 672 OpenCode plugin tests, 450 Pi plugin tests, plus typecheck and lint clean across all four workspaces.
Full Changelog:
v0.19.6...v0.20.0