v0.7.106-rc1Pre-release
0.7.106 RC1 — Patron preview
Full release notes: CHANGELOG.md at this tag — https://github.com/milnet01/ants-terminal/blob/v0.7.106-rc1/CHANGELOG.md
Changelog
Every release, newest first — 100 in total.
0.7.106 RC1 — Patron preview
Full release notes: CHANGELOG.md at this tag — https://github.com/milnet01/ants-terminal/blob/v0.7.106-rc1/CHANGELOG.md
0.7.105
Full release notes: CHANGELOG.md at this tag — https://github.com/milnet01/ants-terminal/blob/v0.7.105/CHANGELOG.md
0.7.105 RC1 — Patron preview
Full release notes: CHANGELOG.md at this tag — https://github.com/milnet01/ants-terminal/blob/v0.7.105-rc1/CHANGELOG.md
0.7.104
Full release notes: CHANGELOG.md at this tag — https://github.com/milnet01/ants-terminal/blob/v0.7.104/CHANGELOG.md
0.7.104 RC1 — Patron preview
Full release notes: CHANGELOG.md at this tag — https://github.com/milnet01/ants-terminal/blob/v0.7.104-rc1/CHANGELOG.md
0.7.103 — hotfix
Full release notes: CHANGELOG.md at this tag — https://github.com/milnet01/ants-terminal/blob/v0.7.103/CHANGELOG.md
0.7.102
Full release notes: CHANGELOG.md at this tag — https://github.com/milnet01/ants-terminal/blob/v0.7.102/CHANGELOG.md
0.7.102 RC1 — Patron preview
Full release notes: CHANGELOG.md at this tag — https://github.com/milnet01/ants-terminal/blob/v0.7.102-rc1/CHANGELOG.md
0.7.101
Full release notes: CHANGELOG.md at this tag — https://github.com/milnet01/ants-terminal/blob/v0.7.101/CHANGELOG.md
0.7.101 RC1 — Patron preview
Full release notes: CHANGELOG.md at this tag — https://github.com/milnet01/ants-terminal/blob/v0.7.101-rc1/CHANGELOG.md
0.7.100
Full release notes: CHANGELOG.md at this tag — https://github.com/milnet01/ants-terminal/blob/v0.7.100/CHANGELOG.md
0.7.100 RC1 — Patron preview
Full release notes: CHANGELOG.md at this tag — https://github.com/milnet01/ants-terminal/blob/v0.7.100-rc1/CHANGELOG.md
0.7.99
Full release notes: CHANGELOG.md at this tag — https://github.com/milnet01/ants-terminal/blob/v0.7.99/CHANGELOG.md
0.7.99 RC1 — Patron preview
Full release notes: CHANGELOG.md at this tag — https://github.com/milnet01/ants-terminal/blob/v0.7.99-rc1/CHANGELOG.md
0.7.98
Full release notes: CHANGELOG.md at this tag — https://github.com/milnet01/ants-terminal/blob/v0.7.98/CHANGELOG.md
0.7.98 RC1 — Patron preview
0.7.98 RC1 — Patron preview (public ship target: 2026-07-09)
A large Ants-MCP / Claude-integration release. Highlights:
project_query — Claude runs read-only Lua sandboxed over your project, returning just the result (ANTS-2093)dry_run preview across all 9 mutating MCP verbs (ANTS-2227)audit_run async:true + audit_poll for slow sweeps past the transport timeout (ANTS-3396)📄 Full changelog (all 132 changes): https://github.com/milnet01/ants-terminal/blob/v0.7.98-rc1/CHANGELOG.md
0.7.97 — MCP integration briefs, project auto-detect, work-bundles
A large MCP / Claude-integration milestone. Ants now answers "where do I hook into this pipeline?" (read_region call_sequence) and "show me the canonical idiom" (similar_code include_bodies) in a single call, auto-detects projects whose code isn't under src/ and offers to write their settings file, groups related to-dos into thematic work-bundles, and keeps its most-used tools instantly callable so long sessions don't drift back to raw grep.
Highlights
read_region call_sequence:true returns the ordered steps inside a function with line anchors + the accessors a new step needs; similar_code include_bodies:true returns full matching definitions so you copy a canonical idiom in one call. (ANTS-2157 / ANTS-2156)src/ and offers to write its .ants/project.json; new project_settings verb creates/updates it. (ANTS-2160 / ANTS-2161)roadmap_query mode:"bundles" groups active to-dos by theme, flagging blocked items and shipped siblings. (ANTS-1922)file_outline stops mislabelling locals and case labels as functions and now finds old-C-style definitions; plus a sweep of related fixes. (ANTS-2159 et al.)Full release notes: CHANGELOG.md at this tag — https://github.com/milnet01/ants-terminal/blob/v0.7.97/CHANGELOG.md
0.7.97 RC1 — Patron preview
Theme: Rolling Patron preview of the next release. Fixes and features land in this section as they ship during the freeze window; the section gets its public date when 0.7.97 is promoted to a stable release.
Full release notes: CHANGELOG.md at this tag — https://github.com/milnet01/ants-terminal/blob/v0.7.97-rc1/CHANGELOG.md
0.7.96 — MCP token-frugality, teardown-crash sweep, security hardening
Theme: Ants-MCP token-frugality and roadmap / read-verb expansion, a sweep of teardown / use-after-free crash fixes, and indie-review security hardening.
Added
read_region, apply_edits, project-wide codebase_index (ANTS-2021 / 2022 / 1637)compact:true (ANTS-2085 / 2091)roadmap_log dry-run / custom stable IDs / near-duplicate advisory; changelog_log batch op; server build-identity in session_orient (ANTS-2077 / 2078 / 2043 / 2044 / 2073)Fixed — crash & stability sweep
Security — indie-review hardening
See CHANGELOG.md for the full list (~85 entries). The x86_64 AppImage attaches automatically once the release workflow finishes building.
0.7.95
Settings-dialog & menu usability fixes — readable fields at any window size, no surprise dialog closes, and menus that stay open while you toggle checkboxes (ANTS-1980/1981/1982).
Full release notes: CHANGELOG.md at this tag — https://github.com/milnet01/ants-terminal/blob/v0.7.95/CHANGELOG.md
0.7.94
Full release notes: CHANGELOG.md at this tag — https://github.com/milnet01/ants-terminal/blob/v0.7.94/CHANGELOG.md
0.7.94 RC1 — Patron preview
Full release notes: CHANGELOG.md at this tag — https://github.com/milnet01/ants-terminal/blob/v0.7.94-rc1/CHANGELOG.md
0.7.93
Theme: Auto-switcher visibility — the silent /model injection
finally announces itself + the operator-trust ledger
(ANTS-1893 surfacing + ANTS-1894 near-miss telemetry +
ANTS-1891 honest headline + ANTS-1897 MCP cheat-sheet).
Full release notes: CHANGELOG.md at this tag — https://github.com/milnet01/ants-terminal/blob/v0.7.93/CHANGELOG.md
0.7.93 RC3 — Patron preview
Theme: Auto-switcher visibility — the silent /model injection
finally announces itself + the operator-trust ledger
(ANTS-1893 surfacing + ANTS-1894 near-miss telemetry +
ANTS-1891 honest headline + ANTS-1897 MCP cheat-sheet).
Full release notes: CHANGELOG.md at this tag — https://github.com/milnet01/ants-terminal/blob/v0.7.93-rc3/CHANGELOG.md
0.7.93 RC2 — Patron preview
Respin of v0.7.93-rc1 with cherry-picked fixes: c6cc905
0.7.93 RC1 — Patron preview
Theme: Auto-switcher visibility — the silent /model injection
finally announces itself + the operator-trust ledger
(ANTS-1893 surfacing + ANTS-1894 near-miss telemetry +
ANTS-1891 honest headline + ANTS-1897 MCP cheat-sheet).
Full release notes: CHANGELOG.md at this tag — https://github.com/milnet01/ants-terminal/blob/v0.7.93-rc1/CHANGELOG.md
0.7.92
Theme: MCP token-saver depth + frozen-RC release pipeline. Wires
the 0.7.92 milestone (pulls 34–42 MCP/test-audit/indie-review fold-ins)
and bootstraps the weekly-Wednesday + Patron-RC cadence. Public ship
target 2026-05-27; the inaugural v0.7.92-rc1 is cut from these bits.
Full release notes: CHANGELOG.md at this tag — https://github.com/milnet01/ants-terminal/blob/v0.7.92/CHANGELOG.md
0.7.92 RC1 — Patron preview
Theme: MCP token-saver depth + frozen-RC release pipeline. Wires
the 0.7.92 milestone (pulls 34–42 MCP/test-audit/indie-review fold-ins)
and bootstraps the weekly-Wednesday + Patron-RC cadence. Public ship
target 2026-05-27; the inaugural v0.7.92-rc1 is cut from these bits.
Full release notes: CHANGELOG.md at this tag — https://github.com/milnet01/ants-terminal/blob/v0.7.92-rc1/CHANGELOG.md
0.7.91 — indie-review fold-in (3 CRIT + 18 HIGH inline; 30 follow-ups roadmapped)
Theme: indie-review fold-in. The 2026-05-13 multi-agent /audit
vtparser — 8-bit C1 controls now routed. Raw 0x80-0x9F bytes
in the PTY stream were being silently mis-decoded as malformed
UTF-8 → U+FFFD, which made the ST (0x9C) terminator branches in
OscString / DcsString / ApcString / IgnoreString dead code.
ECMA-48 § 5.3 / Williams VT500 state machine treat C1 as
first-class controls. Fixed at vtparser.cpp:feedByte — bytes in
[0x80, 0x9F] route through processChar(byte) directly, plus a
Ground-state dispatch table for 0x9B (CSI), 0x9D (OSC), 0x90 (DCS),
0x9F (APC), 0x98/0x9E (Ignore). Other C1 emit Execute.
lua-plugins — heap cap silently bypassed. Lua 5.4 manual:
when ptr == NULL, the allocator's osize parameter encodes the
object type (0..8 small int), not byte count. The custom
allocator treated it as a byte count, drifting m_luaMemUsage
downward unboundedly on every fresh allocation — a plugin could
silently exceed the documented 10 MB cap. Fixed at
luaengine.cpp:luaAlloc (zero osize when ptr == nullptr).
roadmapdialog — HTML injection (CWE-79). rec.kind was
emitted unescaped into the rendered card HTML; rxKind admits
<, >, &, ", so a hostile ROADMAP.md bullet like
Kind: <img src=x onerror=…> rendered as raw HTML in
QTextBrowser. Fixed at roadmapdialog.cpp:1337 —
htmlEscape(rec.kind) before emission.
claudeintegration — cold-start hook gate tightened. During the
1–3 s window after a tab switch (while m_transcriptPath was
empty), foreign-tab PreToolUse / PostToolUse / Stop events
passed the focus gate and mutated singleton state — exactly the
ANTS-1161 symptom returning transiently. Now only SessionStart
PermissionRequest land while the path is empty; other
state-mutating hooks drop with a diagnostic log
(DebugLog::Claude).ptyhandler — child signal mask + dispositions reset before
exec. Qt / glib / dbus install handlers for SIGCHLD / SIGPIPE /
SIGUSR2 etc. POSIX § 2.4 keeps SIGPIPE=SIG_IGN across exec, so
child shells inherited the parent's mask and pipelines silently
hung. ~Pty also now sends SIGCONT alongside SIGTERM so a
stopped child doesn't burn the full 500 ms escalation budget.
remotecontrol — cwd validation on cmdLaunch + cmdNewTab
(CWE-22 parity). Other path-bearing verbs (workspace-search,
file-outline, git_state) validated their path parameter
against control-bytes / backslashes; launch and new-tab
did not. Same-UID model holds today, but the verb is the seam
Claude Code (and any future MCP) crosses with user-supplied
input — validate at the boundary, not the kernel.
remotecontrol — getsockopt(SO_PEERCRED) len check. On
getsockopt failure with a zero-initialised cred, the
disconnect log read "peer UID 0" — a phantom "root tried to
connect" alarm. Now checks len == sizeof(cred) before reading
cred.uid.
antshelper — --repo-root traversal hardening. Rejects
.. substrings + NUL bytes; canonicalises before script lookup
so a planted packaging/check-version-drift.sh at an attacker-
controlled path can't pose as a project root.
lua-plugins — init.lua symlink rejection. Plugin directory
was already canonicalised; the init.lua file inside wasn't
symlink-checked. A symlink to /etc/passwd or ~/.ssh/id_rsa
would surface the file's first line in lua_tostring(err) when
the parser failed. Rejected at pluginmanager.cpp:202 with a
qWarning.
terminalgrid — Kitty chunk buffer flush on alt-screen + resize.
An attacker m=1 chunked image transfer + alt-screen flip → stale
bytes prepended to the next legitimate APC m=0 from a different
sender → poisoned image attributed to new sender. Fixed at the
alt-enter site + at the top of TerminalGrid::resize.
terminalgrid — OSC 8 id= length cap (256 bytes). URI was
capped at MAX_OSC8_URI_BYTES = 2048; the id was uncapped. With
the parser's 10 MiB per-OSC cap, a hostile id=<10MB> sequence
scaled to ~800 MiB across 80 rows of scrollback.
terminalgrid — Sixel first-pass payload cap (4 MiB). The vt parser caps DCS bodies at 10 MiB, but Sixel's first-pass iterator walks every byte to compute width/height with no cycle budget. A 10 MiB payload of valid sixel bytes pinned the parse thread for ~10 M iterations.
claudebgtasks::poll() — watch-loss recovery mirror of
claudetasklist::poll(). Re-adds the QFileSystemWatcher when
the transcript path (re-)appears + mtime-shortcircuits to skip
reparse when nothing changed. Closes the documented CLAUDE.md
claim that both trackers had poll() parity (only foreground
did pre-0.7.91). Wired into
ClaudeStatusBarController::refreshBgTasksButton.
claudebgtasks::parseTranscript — isSidechain +
isCompactSummary filters. Foreground tracker already filtered
these (ANTS-1158 sidechain rationale + ANTS-1224 compact-summary
state reset); bg-tasks did not, so subagent-launched background
tasks silently inflated the parent session's running count, and
post-/compact resume left pre-compact bg entries lingering.
m_errorHideTimer owned member on ClaudeStatusBarController
— replaces the prior QTimer::singleShot pattern in setError.
Re-entry now cancels the prior auto-hide instead of inheriting it
(rapid back-to-back errors no longer flash and disappear). Sticky
errors via autoHideMs <= 0.
Accessibility — setAccessibleName on three previously-silent
status-bar chips. m_bgTasksBtn, m_tasksBtn, m_errorLabel
now expose Orca-readable labels per docs/standards/documentation.md § 7. The remaining a11y gaps (keyboard-nav to expand cards,
aria-expanded on collapse anchors) are tracked as ANTS-1277.
vtparser — kSafeMinSigned / kSafeMaxSigned named constants
for the SSE2 safe-ASCII scan boundary (-96 / -2 were magic
numbers).
Q_ASSERT in handleAsciiPrintRun verifying the
[0x20, 0x7E] precondition the SIMD scanner establishes.
CLAUDE.md — three doc-drift fixes. terminalwidget is
QWidget (has been since 0.7.4), not QOpenGLWidget.
featurecoverage lists 2 in-process lanes + 1 shell-based
(test_health runs via QProcess, not inProcessRunner).
claudetasklist / claudebgtasks bullet now reflects ANTS-1246
done/total chip semantics + the new bg-tasks poll().
SARIF partialFingerprints key bumped to
primaryLocationLineHash/v1 (SARIF v2.1.0 § 3.27.13 versioning
convention).
computeDedup chained single-arg .arg() — closes a
%n-placeholder collision vector if a file path contains a
literal "%3" / "%4" substring (legal on filesystems).
terminalwidget — dead #include <QSurfaceFormat> removed
(no QSurfaceFormat reference left in the file).
terminalwidget — dead duplicate loop in invalidateSpanCaches
removed (lines 3472-3478 re-erased the exact key set the
previous loop had just erased; was a no-op).
terminalwidget — unused m_perfLastPaintUs member + the
overlay line that read it dropped. The member was never written;
the overlay always showed Paint: 0 us.
terminalwidget — isCellSearchMatch early-returns on empty
m_searchMatches. Called twice per cell per frame; ~16,000
wasted comparator invocations/frame at 60 fps when no search
active.
luaengine::lua_ants_on — per-event handler cap of 64. A
plugin calling ants.on(event, fn) in a loop would otherwise
grow the handler list until the heap cap fired.
CI — build-asan job timeout 20 → 30 min. Mirrors the
earlier 421e32d build-test 15 → 25 bump shape; ASan is
2-3× slower than the release build, and the 0.7.91 sweep
pushed it past the prior cap.
ROADMAP.md — 30 follow-up cards allocated ANTS-1260…
ANTS-1317 across five themes: indie-review deferred items
(1260…1277), MCP renderer bug (1278), MCP orchestration
consolidation (1279…1288), skill displacement + context
discipline + security hardening + namespacing (1289…1298),
general Claude Code workflow MCPs (1299…1312), and visibility /
Anthropic-discoverability (1313…1317). .roadmap-counter:
1259 → 1317.
antshelper — silent false-clean on hung script.
proc.waitForFinished(60000) return value was ignored;
Qt's exitStatus() returns NormalExit + exitCode() returns
0 by default on a still-running process. A hung drift script
was reported as {ok:true, clean:true} exit 0. Now branches
on the timeout: kills the process and returns
{ok:false, error:"drift script timed out after 60s", code:"script_timeout"} with exit 1.
antshelper — isatty(0) short-circuit on readStdin.
Interactive runs no longer block until Ctrl-D when stdin is
a TTY.
claudeintegration — m_planModeByPid PID-reuse leak. Cache
was only pruned on explicit closeTab. Linux PID-reuse is fast;
an abnormally-exiting shell left a stale entry that poisoned the
next tab assigned the same PID. Now also pruned on the
NotRunning transition in pollClaudeProcess.
claudetasklist + claudebgtasks — m_lastRescanMtimeMs reset on
path change. A re-bind to the same path shortly after a clear
could short-circuit poll() on a matched mtime, skipping the
legitimate rescan.
claudestatuswidgets — m_promptActive now cleared in
resetForTabSwitch (was the only render-state flag left out).
claudestatuswidgets — empty initial label on m_tasksBtn. The
prior "☰ 0/0" literal contradicted the ANTS-1246 hide predicate
(total <= 0 || done >= total). The chip is hidden until first
refresh anyway, but the latent inconsistency is gone.
claudeintegration — qWarning on the 100 MiB transcript cap.
Previously returned an empty QJsonArray silently; "transcript
empty / status frozen" reports now have a logging breadcrumb.
tests — remote_control_launch test window 3000 → 3500 chars
to admit the new cwd validation block in cmdLaunch.
0.7.90 — ANTS-1113 v1: /debt-sweep fold (engine + 4 MCP tools)
Theme: ANTS-1113 v1 — fold /debt-sweep mechanical scan into
Ants. The four canonical post-feature drift categories (code drift,
test coverage, doc drift, packaging drift) lift out of the
file-reading subagent into 4 new MCP tools backed by a
DebtSweepEngine namespace; per-finding triage continues to live in
optional Claude calls. Estimated saving per /debt-sweep run: ~15-40 K
orchestrator tokens (subagent never opens 95% of the project files;
findings come back as pre-classified JSON instead of free-form prose).
Qt "Debt Sweep" tab deferred to ANTS-1259 v2.
ANTS-1113 — DebtSweepEngine helper (src/debtsweepengine.{h,cpp},
Qt::Core only). Per-detector pure functions:
detectStaleTypeComments(projectPath, opt) — code drift (a):
walks git diff <since>..HEAD --name-only for *.cpp/h/py/js/ts/tsx,
extracts comments, flags leading-cap CamelCase tokens (≥4 chars,
\b([A-Z][A-Za-z0-9_]{3,})\b) absent from the project source blob.detectAddedTodos(projectPath, opt) — code drift (c): unified=0
diff parser flags TODO / FIXME / XXX / HACK markers added in scope.detectOrphanQUnused(projectPath, opt) — code drift (d): per-file
pass over git ls-files '*.cpp' '*.h' finds Q_UNUSED(x) /
(void)x; markers wrapping a variable not declared anywhere in
the same file. The only v1 detector that sets autoFixable=true.detectMissingInvariantTests(projectPath, opt) — test coverage:
parses \bINV-([0-9][0-9a-zA-Z]*)\b from each
tests/features/*/spec.md (covers INV-7 and INV-8b); reports
invariants not mentioned in any sibling test_*.{cpp,py,js,go,rs}.detectRoadmapShippedWithoutCommit(projectPath, opt) — doc drift
(a): reads ROADMAP, runs git log --all --format=%s once, flags
✅ items whose stable ID is unmentioned.detectChangelogStaleBullets(projectPath, opt) — doc drift (b):
parses CHANGELOG [Unreleased] block, flags bullets citing files
not in git diff <since>..HEAD.runPackagingDrift(projectPath, opt) — packaging drift: wraps
the existing packaging/check-version-drift.sh; parses stdout
into Finding structs.scanAll(projectPath, opt) — convenience: runs every enabled
detector in canonical category order.applyMechanicalFix(projectPath, finding) — applies one mechanical
edit; returns ApplyVerdict {applied, errorCode, errorMessage} so
MCP handlers can disambiguate file_changed / not_fixable /
io_error no-ops without inspecting QFile errno state.templateDebtSweepFoldInBlock(deferred, ids, dateIso) —
### 🧹 Debt-sweep fold-in (DATE) block per
roadmap-format.md § 3.8 + § 3.5.3. <dateIso> byte-identical
between heading and per-bullet Source: line (locked by INV-10).triagePrompt(llmShaped) — pure string templating of the LLM
triage prompt for the judgment-required subset.
Locked by tests/features/debt_sweep_engine/ (13 tests).ANTS-1113 — 4 new MCP tools registered via the consolidated
registerToolProvider registry from ANTS-1253:
debt_sweep_scan — runs the four-category scan; returns
{findings, total_findings, by_category, since_resolved} (input:
optional since, optional categories subset).debt_sweep_apply_fix — applies one mechanical fix in-place;
ok=true even on recognised no-ops (file_changed /
not_fixable); ok=false only on io_error. Re-grep guard in
applyMechanicalFix § 3.9 makes the operation idempotent against
re-application (locked by INV-13a).debt_sweep_defer — allocates IDs via RoadmapFoldIn::allocateIds,
renders the fold-in block, and atomically inserts it into
ROADMAP.md when findActiveReleaseHeading succeeds. Eager ID
allocation (no rollback on written:false) — same trade-off as
ANTS-1111 § 2.5; envelope returns the allocated IDs so the caller
can splice manually.debt_sweep_triage_prompt — emits the LLM triage prompt for a
caller-filtered subset of findings.
All 4 follow the existing UID-scoped 0700-perms IPC trust model;
schemas use additionalProperties: false. Locked by
tests/features/mcp_debt_sweep_tools/ (5 tests).ANTS-1259 (new ROADMAP item) — ANTS-1113 v2: AuditDialog
"Debt Sweep" tab + per-finding Fix / Defer / Allow buttons +
Triage with AI button (aidialog dispatch). Adds README CLI flag
drift detector (needs binary execution). Spec § 1.1 of
docs/specs/ANTS-1113.md documents the v1/v2 split rationale.
featurecoverage.{h,cpp} — three internal helpers lifted to
FeatureCoverage:: public surface so DebtSweepEngine can reuse
them without copying the extension list, skip-dir set, or
containment-check fallback chain:buildProjectSourceBlob(projectPath) — concatenates every source/
config/doc file in the project tree (per the canonical extension
list) into one UTF-8 blob; skips build dirs + spec.md files.existsInSource(blob, token) — substring containment +
:: and . tail-fallbacks (with the same identifier-shape
guards runSpecDriftCheck had inline).specStopwords() — public accessor for the existing internal
kSpecStopwords set.
runSpecDriftCheck is rewired to call them; behaviour unchanged.tests/features/debt_sweep_engine/ (13 tests) — engine pure-fn
invariants (INV-3, INV-4, INV-9, INV-10, INV-11, INV-13a, INV-13btests/features/mcp_debt_sweep_tools/ (5 tests) — source-grep
verification of the wiring layers (tools/list, registerToolProvider,
remotecontrol.h declarations, remotecontrol.cpp definitions, schema
additionalProperties:false).tests/features/mcp_indie_review_tools/test_mcp_indie_review_tools.cpp —
AllSchemasUseAdditionalPropertiesFalse region boundary tightened
so it doesn't drift when new tool blocks land after it.0.7.89 — ANTS-1112 v1: /indie-review fold (engine + 5 MCP tools)
Theme: ANTS-1112 v1 — fold /indie-review orchestration into
Ants. The mechanical halves of the multi-agent independent review
(partition, brief assembly, cross-lane corroboration, synthesis-prompt
templating, ROADMAP fold-in) lift out of orchestrator context as 5
new MCP tools backed by a IndieReviewEngine namespace; per-lane
review judgment + dispatch continue to live in Claude subagent
calls. Estimated saving per /indie-review run: ~20-50 K orchestrator
tokens. Qt dialog deferred to ANTS-1258 v2.
ANTS-1112 — IndieReviewEngine helper (src/indiereviewengine.{h,cpp},
Qt::Core only). Six pure functions:
derivePartition(projectPath) — reads CLAUDE.md ## Module map (src/) via the existing SubsystemMap::cachedLanes helper, walks
src/ to compute per-lane source-file lists; honours
<projectPath>/.indie-review/partition.json override when present.assembleBrief(projectPath, lane) — verbatim brief text for one
lane: header + source bodies + ROADMAP slice + standards links.
Pure file IO bounded to projectPath.extractFileLineCitations(projectPath, report) — regex pass over
a single review report; rejects paths that escape projectPath
(defense against fabricated cites).corroboratedFindings(projectPath, reports, minLanes=2) —
cross-lane corroboration; (file, -1) (file-level) and
(file, 42) (line-level) are distinct keys (intentional).synthesisPrompt(reports, threatModelExtras) — pure string
templating of the optional cross-cutting synthesis prompt.templateIndieReviewFoldInBlock(actionable, ids, dateIso) —
### 🔍 Indie-review fold-in (DATE) block per
roadmap-format.md § 3.8 + § 3.5.3.assembleThreatModelExtras(projectPath) — MCP-handler helper
that concatenates CLAUDE.md / SECURITY.md / .semgrep.yml under
=== <header> === markers.
Locked by tests/features/indie_review_engine/ (13 tests).ANTS-1112 — 5 new MCP tools registered via the consolidated
registerToolProvider registry from ANTS-1253:
indie_review_partition — returns lane list (no input).indie_review_brief — returns the assembled brief for one lane
(input: lane).indie_review_corroborate — returns cross-lane corroborated
findings (input: reports map, optional min_lanes).indie_review_synthesis_prompt — returns the rendered synthesis
prompt (input: reports, optional include_threat_model_extras).indie_review_fold_in — allocates IDs via
RoadmapFoldIn::allocateIds, renders the
### 🔍 Indie-review fold-in (DATE) block, and atomically
inserts it into ROADMAP.md when findActiveReleaseHeading
succeeds (input: actionable array, optional date_iso,
optional release_block_heading).
All 5 follow the existing UID-scoped 0700-perms IPC trust model;
schemas use additionalProperties: false. Locked by
tests/features/mcp_indie_review_tools/ (5 tests).ANTS-1258 (new ROADMAP item) — ANTS-1112 v2: Qt
IndieReviewDialog that wraps the v1 engine for users who want
in-app indie review without Claude orchestration. Spec § 1.1 of
docs/specs/ANTS-1112.md documents the v1/v2 split rationale.
0.7.88 — ANTS-1111 v1: /audit triage fold (engine layer)
Theme: ANTS-1111 v1 — fold /audit triage into the Project
Audit tool's engine layer. Five mechanical pieces lift out of the
LLM round-trip: cross-tool corroboration severity-tier shift,
framework auto-detect, ROADMAP fold-in helper, the
// audit: drop[=rule] inline-suppress alias, and the
RuleQualityTracker::noisyRuleIds accessor that feeds the shift.
UI affordances (Fold-into-ROADMAP button, per-finding Allow
button, Since-baseline pill) deferred to ANTS-1257 v2.
Cumulative payload: ~750 LoC engine + ~33 new test cases, all
green. Foundation ships ahead of ANTS-1112 / 1113 (which reuse
RoadmapFoldIn).
ANTS-1111 — RoadmapFoldIn helper (src/roadmapfoldin.{h,cpp},
Qt::Core only). Three operations: allocateIds(projectPath, n)
reserves N consecutive IDs from .roadmap-counter under
::flock(LOCK_EX|LOCK_NB) (5 s budget; adopts the
configbackup.h pattern); insertBlock(projectPath, heading, block) performs an atomic insert immediately after a named
## heading via QSaveFile, preserving original file
permissions; findActiveReleaseHeading(projectPath) returns the
first (target: …)-marked heading, falling back to the first
shipped release block. Returns false on heading-not-found —
caller is responsible for creating the heading first.
Locked by tests/features/roadmap_fold_in/ (12 tests).
ANTS-1111 — AuditEngine::applyCorroborationShift
(src/auditengine.cpp). Severity-tier promotion when ≥ 2
distinct CheckIds cite the same (file, line) (clamped to
Blocker); demotion when a single-tool finding's checkId is in
noisyRules (clamped to Info). Wired into
AuditDialog::renderResults after the enrichment pass; the
noisyRules set comes from the new
RuleQualityTracker::noisyRuleIds(fpThreshold=50, minSamples=5)
accessor (src/auditrulequality.{h,cpp}). Locked by
tests/features/audit_corroboration_shift/ (8 tests).
ANTS-1111 — AuditEngine::templateRoadmapFoldInBlock
(src/auditengine.cpp). Pure-string templating of a
### 🔍 Audit fold-in (DATE) subsection per
roadmap-format.md § 3.8 (subsection shape) + § 3.5
(per-bullet fields). Caller pre-allocates IDs via
RoadmapFoldIn::allocateIds. Empty input → empty string.
Covered by tests/features/roadmap_fold_in/Inv5TemplateShape.
ANTS-1111 — AuditHygiene::detectProjectFrameworks
(src/audithygiene.{h,cpp}). Probes the project root for 7
framework markers (flask, django, react, vue, qt6, rust, go) by
reading requirements.txt / pyproject.toml / package.json /
CMakeLists.txt / Cargo.toml / go.mod / manage.py.
Companion semgrepRulePacks(QStringList) maps recognised
frameworks to {"--config", "p/<fw>"} argv pairs. Pure-IO,
bounded to projectPath. Wiring into the live runNextCheck
semgrep invocation deferred to ANTS-1257. Locked by
tests/features/audit_framework_detect/ (10 tests).
ANTS-1111 — // audit: drop[=rule] inline-suppress alias.
Shorter ergonomic form of the existing // ants-audit: disable
token. Same parser code (auditdialog.cpp:2055); same
semantics (bare form suppresses every rule on the line; =rule
suffix targets one). Both forms coexist indefinitely. The
generic audit: prefix is confined by the verb constraint
(\s*drop) plus the existing rule-list parser
(auditdialog.cpp:2083-2107); collision-prevention covered in
spec § 2.6 + INV-12. Locked by
tests/features/audit_drop_alias/ (2 tests).
ANTS-1111 — Widened-allowlist documentation. The pre-existing
.audit_allowlist.json filter (AuditDialog::allowlisted(),
auditdialog.cpp:3991) was already cross-detector — the call
site lives in the main per-finding loop and matches by exact
checkId equality, so any detector's rule (clazy-X,
cppcheck-Y, etc.) can be allowlisted. The doc-comment at
auditdialog.h:101-110 now documents this widened scope (was
previously labelled grep-rule only). No behaviour change.
ADR-0003
(docs/decisions/0003-cc-fold-relax-gate-and-draw-boundary.md)
— relaxes the ADR-0002 dec 8 gate (ANTS-1120 measurement no
longer pre-requisite for the CC-fold bullets), draws the
ANTS-1108 ↔ ANTS-1111 / 1113 per-surface boundary, and
enumerates pre-existing scaffolding so the subsequent ANTS-1112
/ 1113 specs don't re-invent it.
ANTS-1257 (new ROADMAP item) — ANTS-1111 v2: UI affordances
(Fold-into-ROADMAP button, per-finding Allow button,
Since-baseline pill, semgrep wiring). Spec § 12 of
docs/specs/ANTS-1111.md documents the v1/v2 split rationale.
0.7.87 — MCP token-reduction pack
Theme: MCP token-reduction pack — five new MCP tools
(workspace_search, file_outline, git_state, subsystem,
last_audit_summary) collapse common Bash/Read patterns into
structured envelopes, plus a token-saving hook pack and a
provider-registry consolidation that makes the next tool a one-line
add. Cumulative session saving on a typical /indie-review +
/audit workflow: ~50-100 K tokens.
setXProvider/m_xProvider setter+member pairs
in ClaudeIntegration (each added by ANTS-1244 / 1247-1251) with
a single registerToolProvider(QString name, ToolHandler handler)
surface backed by std::map<QString, ToolHandler> m_toolProviders,
where ToolHandler = std::function<QString(const QJsonObject&)>.
The 92-line tools/call else if (toolName == "X" && m_XProvider)
chain in claudeintegration.cpp collapses to one inline branch
for get_session_info (the documented carve-out — it reads
ClaudeIntegration's own state, not an external delegate) plus a
single m_toolProviders.find(toolName) lookup. MainWindow::setupClaudeMcpProviders
now makes 12 registerToolProvider("name", lambda) calls; each
lambda absorbs the dispatcher-side argument extraction + result
formatting that previously lived in the per-tool dispatch case.
No behaviour change. Source delta: −157 LoC across
claudeintegration.{h,cpp} + mainwindow.cpp. Spec:
docs/specs/ANTS-1253.md (5-loop cold-eyes pass, ship-ready).
New regression test: tests/features/mcp_provider_registry/
(10 invariants source-greped, pre-fix red verified — 9/10 fail
against the unrefactored tree; INV-10 carve-out preservation
passes both sides).ANTS-1254 — last_audit_summary MCP tool. New read-only tool
that opens the latest .audit_cache/audit-*.sarif and returns a
compact summary: counts (error/warning/note/suppressed) plus
top_findings[] sorted by SARIF level desc → confidence desc →
file asc → line asc. Default top_n=5, severity_floor="warning";
server-clamps top_n to [0, 50]. Saves ~5-15 K tokens per
audit consultation vs reading the HTML report (which today's flow
uses). Backed by the new AuditEngine::summariseSarif(path, topN, levelFloor) pure parser + RemoteControl::cmdLastAuditSummary
with a single-entry mtime-keyed cache ((path, mtime, topN, floor)
4-tuple). Latest-SARIF discovery uses lex-max filename
(audit-YYYYMMDD-HHmmss.sarif is sortable at second granularity);
html_path derives via extension swap, falling back to lex-max
audit-*.html within ±60 s of the SARIF timestamp (the SARIF and
HTML export buttons each call QDateTime::currentDateTime()
independently). Severity resolution: rule-index lookup
(runs[0].tool.driver.rules[].properties.severity) with fallback
for foreign SARIF (error→CRITICAL, warning→MAJOR, note→INFO).
Per-tab gate inherits from resolveRootCanonical(MainWindow*).
Lands on the post-1253 registry (one registerToolProvider call
in setupClaudeMcpProviders, no per-tool setter). Spec:
docs/specs/ANTS-1254.md (5-loop cold-eyes pass, ship-ready).
New regression test: tests/features/mcp_last_audit_summary/
(10 invariants — 6 parser-side against committed
fixture_min.sarif + fixture_empty.sarif; 4 wiring-side via
source-grep). Pre-fix red verified — summariseSarif symbol
absent from stashed engine fails build.
ANTS-1252 — Token-saving hook pack. Five bash hooks plus
tools/install-hooks.sh that nudge Claude Code toward cheaper
MCP tool calls. SessionStart preamble emits a ≤ 500 B
branch/ahead/last-commit summary (cap enforced via head -c).
PreToolUse(Bash) veto blocks grep -r src/, git status,
cat ROADMAP.md | grep etc. with reasons capped at 200 bytes,
redirecting to mcp__ants__workspace_search /
mcp__ants__get_git_status / mcp__ants__roadmap_query. Per-
command escape hatch: trailing # ants-bypass comment, stripped
before pattern match (INV-12 — never appears in the emitted
reason text). PreToolUse(Read) veto blocks full reads of
ROADMAP.md > 50 KiB; bypassed cleanly via offset/limit
args. Stop hook backgrounds an ants-helper drift-check under
flock (INV-11) with a sane-toplevel marker write (INV-9).
PreCompact hook walks the transcript JSONL and writes the most
recent TodoWrite snapshot to
~/.cache/ants-terminal/precompact_<sessionId>.json only if
sessionId matches ^[a-zA-Z0-9_-]{1,64}$ (INV-2). Per-project
gate via committed .ants-project marker — non-ants sessions
exit silently in sub-millisecond stat ascent (no git rev-parse
fork). Install-hooks hardening: lstat symlink abort (INV-5),
cp --no-dereference backup, sentinel-key fence
ants_hooks_pack_v1 rather than text-fence comments (INV-6 —
jq strips JSON comments), tmpfile + jq empty validate-
before-rename (INV-8), idempotent re-install, --dry-run and
--uninstall flags. Token saving: ~30-100 K/week depending on
which siblings shipped first (calibrated in spec § 5).
Conformance harness at tests/features/hook_pack/test_hooks.sh
(shell-driven per audit_self_test.sh pattern, no C++ link)
covers INV-1/2/3/4/7/9/10/12 + bash-veto behaviour + read-
roadmap-veto behaviour + install round-trip + symlink abort —
36 assertions, all green; INV-6/8/11 deferred to manual smoke
with documented rationale. Spec: docs/specs/ANTS-1252.md.
Cold-eyes review on tests/features/hook_pack/spec.md returned
7 findings (1 HIGH on source-vs-runtime semantics for INV-12,
2 MEDIUM on doc-code drift, 4 LOW); all fixed inline.
ANTS-1251 — subsystem MCP tool (consolidated; map / files /
recent_changes via op discriminator). Pre-parses the project's
CLAUDE.md ## Module map (src/) H2 into a lanes[] array and
serves per-lane chunks so /indie-review reviewers don't each
re-read the file. Three ops: map returns [{name, summary}, …],
files returns the lexicographically-sorted src/<lane>* glob,
recent_changes returns merged-by-sha git log entries across
every file in the lane. Cache is mtime-only on CLAUDE.md (no
wall-clock TTL — concurrent reviewers share warm cache). Defensive
parser drops bullets that don't match the `name` — summary
shape and splits multi-name bullets like `a` / `b` into one
Lane each. Hardening: lane membership check precedes any
filesystem call (closes cold-eyes S1251-1 path-traversal vector);
per-result canonical-startsWith re-check on resolved files
(defence in depth against malicious symlinks inside src/).
Composes cmdGitState({op:"log", path:<file>}) per lane file for
the recent_changes op — no duplicated git plumbing. Distinct
error codes bad_op (input enum) and unknown_lane (with
lanes:[…] echoed in the response so the caller can recover).
Single setter / member / provider-lambda triple on
ClaudeIntegration + MainWindow. Locked by feature test
mcp_subsystem/ (12 invariants — decl, INV anchors, IPC dispatch,
MCP tools/list schema with op enum + op in required[],
MCP tools/call dispatch, header surface, mainwindow lambda,
op-switch literals, error-code surfacing, cmdGitState composition,
parser surface, CMake wiring, and the ≥ 15-lane CLAUDE.md parser
floor). Token math: ~24 K saved per /indie-review run (6
reviewers × ~3.5 K → ~250 each); permanent schema cost ~115
tokens. Side-effect: mcp_workspace_search test INV-3c now uses
a word-boundary regex (\bsystem\() instead of substring match
so cmdSubsystem( is not false-flagged as a shell escape.
ANTS-1250 — git_state MCP tool (consolidated; status / log /
diff via op discriminator). Replaces multiple Bash calls to
git status, git log, git diff with a single structured tool.
Cold-eyes pass 2 collapsed three originally-proposed verbs into
one to save ~240 permanent schema tokens per session start; per-
call savings are ~14–300 tokens depending on op. Status returns
{branch, upstream, ahead, behind, files:[{path,index,worktree}], untracked[]} parsed from --porcelain=v1 -b. Log returns
{commits:[{sha,subject,date,body?}], truncated} parsed from
unit-separator-framed --pretty=format:, with n+1 probe to
detect truncation and per-body 1 KiB cap when body:true. Diff
returns {files:[{path,added,removed}], totals} parsed from
--numstat, with binary files surfaced as null added/removed.
Hardening: shell-less QProcess::start("git", QStringList...)
via the new gitwrap.{h,cpp} synchronous helper (5 s terminate
kill, 4 KiB stderr cap); strict regex on
range excludes leading - from any rev-component (closes
cold-eyes S1250-1 flag-injection); -- argv separator before
every user-derived positional arg, with ./ prefix on
--leading paths; canonical-startsWith path-escape guard for
path mirroring ANTS-1248's lane check; distinct error codes
bad_op, bad_range, bad_path, git_failed, git_missing,
not_git_repo. Locked by feature test mcp_git_state/ (13
invariants spanning decl, INV anchors, shell-lessness in
gitwrap.cpp, IPC route, tools/list schema with op enum +
required:["op"], tools/call dispatch, header decl + member,
provider lambda, op-dispatch chain, the stricter regex literal,
the 2-tier kill constants, and CMake wiring). Spec:
docs/specs/ANTS-1250.md.ANTS-1249 — file_outline MCP tool. Returns a compact
{header_doc, symbols:[{line, kind, name, signature}], total_lines, total_bytes} envelope for a single file instead
of a full Read. ~13-39× compression on typical C++ source
(e.g. auditdialog.cpp 67 K tokens → ~2 K). New
src/fileoutline.{h,cpp} translation unit hosts a 6-regex
scanner (C++ member / type / free-func / Qt-marker + Python +
Markdown headings). Each regex is a static const QRegularExpression with .optimize() invoked at first use,
so the JIT compiles once per process. Possessive quantifiers
path argument is canonicalised + NFC-normalised + checked
against the project root via startsWith (same security
posture as ANTS-1248's lane). Locked by feature test
mcp_file_outline/ (10 invariants: wiring + runtime floor of
≥ 8 symbols against the in-tree auditdialog.cpp to catch
regex-set regressions, plus not-found path). Spec:
docs/specs/ANTS-1249.md.ANTS-1248 — workspace_search MCP tool (ripgrep wrapper).
Replaces typical Bash grep -r ... src/ patterns with a
structured {ok, matches:[{file,line,text}], truncated, elapsed_ms} envelope. Shell-less argv (QProcess::start("rg", QStringList...) — never bash -c, never a single-string
overload). Server clamps max_results at 500 (default 50);
lane and glob NFC-normalised and rejected if they contain
control chars / backslash / .. segments / parent-traversal
past the project root (canonical-startsWith check via
QFileInfo::canonicalFilePath()); 4 KiB stderr cap surfaced
only on ok:false; 2-tier hard kill (2 s terminate() then
200 ms grace → kill()) so a catastrophic regex can't outlive
the wall budget. Estimated saving ~6–15 K tokens per typical
bug-investigation session at ~150 permanent schema tokens.
Locked by feature test mcp_workspace_search/ whose 10
invariants cover decl, INV anchors, shell-lessness, IPC route,
tools/list schema (with required: ["pattern"]), tools/call
dispatch, header decl + member, provider lambda, the literal
ripgrep flags (--json, --no-heading, --line-number,
--max-columns, --threads), and the 2-tier kill wiring.
Spec: docs/specs/ANTS-1248.md.
0.7.86 — MCP integration end-to-end + inputSchema compliance
Theme: MCP integration end-to-end — Claude Code sessions inside
Ants tabs can now call the in-process MCP server. Three internal
remote-control verbs (roadmap_query, tab_list, get_text) are
promoted to MCP tools; a Python stdio bridge (tools/mcp-bridge.py)
connects Claude Code's stdio MCP client to the Ants QLocalServer
Unix socket; and a Zod-compliance fix repairs the silent
zero-tools failure that the bridge surfaced on its first probe.
Plus a Tasks chip semantics tidy-up and an optional status filter
on roadmap_query that cuts typical "what's next" queries by ~7×.
ANTS-1244 — roadmap_query / tab_list / get_text as MCP
tools. Wires three existing RemoteControl IPC verbs as MCP
tools so Claude Code sessions inside Ants tabs can query terminal
state via tool-call rather than Bash / Read. The handlers
(cmdRoadmapQuery, cmdTabList, cmdGetText) are promoted to
public on RemoteControl; MainWindow lambdas in
setupClaudeMcpProviders delegate directly, sharing the existing
roadmap-query mtime cache. Token saving per
session-with-roadmap-need: ~110 K tokens (a 120 K Read of the
482 KiB ROADMAP.md → ~13 K of structured JSON for 397
status-emoji bullets at ~133 B/bullet measured). Per-call saving
on tab_list / get_text: 30–100 tokens of bash-glue Claude no
longer composes. Locked by 7-invariant source-grep test
mcp_extra_tools/. Spec converged across 7 cold-eyes loops.
ANTS-1247 — status filter on roadmap_query MCP tool /
IPC verb. Optional status argument ("all" default,
"active" = 📋+🚧, "shipped" = ✅; case-insensitive) on the
roadmap_query MCP tool. On this repo at ship time: "active"
returns 57 of 399 bullets (~1.75 K tokens vs ~12 K), a ~7× saving
per "what's next" query. Provider lambda widened to thread the
filter through; cache continues to hold the full unfiltered array
(filter runs post-cache). Pack-mate with ANTS-1248..1252
(spec-only) and ANTS-1253/1254 (planned follow-ups). Cold-eyes
pass 2 converged across 4 lanes (performance / token reduction /
security / optimisation) over 4 loops — final pass clean.
ANTS-1255 — MCP stdio bridge (tools/mcp-bridge.py). Unblocks
the entire MCP pack on the consumer side. The Ants in-process MCP
server exposes JSON-RPC over a QLocalServer Unix socket; Claude
Code's MCP client speaks stdio. Until now, every server-side tool
registered since ANTS-1244 was unreachable from a Claude Code
session. ~100-line Python 3 stdlib script reads line-delimited
JSON-RPC from stdin, opens one AF_UNIX connection per request
(server is one-shot by design), forwards the request, writes the
reply to stdout. Notifications (no id) are forwarded without
awaiting a response. Socket selection: $ANTS_MCP_SOCKET override
or newest-mtime /tmp/ants-terminal-mcp-*. Register with
claude mcp add ants -- /path/to/tools/mcp-bridge.py once per
machine. End-to-end verified: initialize handshake → 9 tools
listed → roadmap_query status="active" → bad_status error
path → clean exit on stdin close.
ANTS-1246 — Tasks chip progress semantics + Mode B batch reset.
Two coordinated fixes for the same user-visible symptom (the
bottom-right Tasks chip showing wrong or no info during an active
Claude task list). (1) Chip now reads ☰ <completed>/<total> and
stays visible iff 0 < done < total, closing the visibility hole
left by ANTS-1221's pending-only predicate — visible end-to-end
through every active run, hides cleanly at 100 %. (2) The
TaskCreate path in ClaudeTaskListTracker::parseTranscript
(Mode B) piled up completed tasks forever; now, when a new
TaskCreate arrives and every task in out is completed, the
prior batch is cleared before appending. Mode A (TodoWrite
snapshot) was already correct. Partial batches (any
pending/in_progress) are preserved. Locked by 4-INV
tasks_chip_done_over_total/ and 3 new behavioural cases in
claude_task_list/. Spec: docs/specs/ANTS-1246.md (2 cold-eyes
loops, clean pass).
ANTS-1256 — MCP tools/list inputSchema compliance. The six
zero-arg MCP tools (get_cwd, get_session_info,
get_last_command, get_git_status, get_environment,
tab_list) were emitted without an inputSchema field. Claude
Code's MCP client validates tools/list with Zod and rejects the
entire response when any entry omits the field — the
connection reports "Connected" but registers zero tools. Surfaced
2026-05-12 on the first probe through the ANTS-1255 bridge: the
log read tools[1].inputSchema expected object, received undefined for indices 2/3/4/5/7 as well. Fix: a shared
QJsonObject emptySchema; emptySchema["type"] = "object";
sentinel, assigned to each zero-arg tool's inputSchema. Locked
by feature test mcp_tools_list_schema/ whose third invariant
pins tools.append() == ["inputSchema"] = count parity inside
the tools/list block — so any future tool addition that omits
the field breaks CI before it reaches a user.
0.7.85 — Roadmap density toggle + Qt::Popup blocker fix
Theme: Roadmap dialog density toggle (compact / cozy /
comfortable) — Material 3 / Linear / GitHub-style density
selector that persists across sessions. Plus a regression fix
that surfaces while building it: Qt::Popup widgets (combo
dropdowns, menus, color pickers) opened from inside a dialog
were being eaten by the ANTS-1051 pseudo-modal blocker. The
fix is one short-circuit in dialogfocus::shouldSuppressEvent ForDialog and benefits every dialog that hosts a popup, not
just the new density combo.
QComboBox lives at the trailing edge of the filterRow (after
the existing addStretch(1)) with three options: compact
(9/10/11/14 px text tier, proportional padding), cozy (the
pre-1238 baseline — 10/11/12/13/16 px), and comfortable
(12/13/14/15/18 px). Selection drives a CSS class on
renderCardsHtml's root container; per-tier sizes/padding are
defined in STYLE_COMPACT / STYLE_COZY / STYLE_COMFORTABLE.
Persists via a new roadmap_density Config key, with graceful
fallback to "cozy" on missing/invalid value and silent
best-effort on persistence-write failure (matches the existing
Config write convention). The default is cozy, byte-equal to
the pre-1238 baseline render — INV-1 in the spec's test bundle
locks that. Regression-locked by ANTS-1238-INV-1..9 in
tests/features/roadmap_density/spec.md (tier-unique sentinels
9/16/18 px scoped to renderCardsHtml; combo present and wired;
Config getter/setter round-trip; persistence-failure contract).
No keyboard shortcut in v1 — the ?-cheatsheet (ANTS-1236)
doesn't gain a row. Spec: docs/specs/ANTS-1238.md
(cold-eyes-clean after 4 loops + ~50 verified findings fixed;
full audit trail at ROADMAP.md
### 📝 Cold-eyes 2026-05-12 (ANTS-1238 spec)).Qt::Popup events were swallowed by the
pseudo-modal blocker. Surfaced by the ANTS-1238 density combo:
clicking an option in the dropdown popup did nothing — the
popup didn't even close. Root cause: a Qt::Popup window
(QComboBox dropdown, QMenu context menu, color-picker popup, …)
is its own top-level widget, and QWidget::isAncestorOf stops
walking at window boundaries — so popup descendants opened FROM
inside a dialog read as "outside the dialog's tree" and the
pseudo-modal blocker (ANTS-1051) ate every mouse event on them.
Fix is a one-block short-circuit at the top of
dialogfocus::shouldSuppressEventForDialog: if
QApplication::activePopupWidget() is non-null and target is
either that popup or a descendant of it, return false
immediately. Regression-locked by a new INV-2i in
tests/features/dialog_pseudo_modal/spec.md —
source-grep-asserted rather than behaviourally driven because
the offscreen QPA platform doesn't promote Qt::Popup widgets
to activePopupWidget(). Affects every dialog that hosts a
combo / menu / color picker, not just the Roadmap density combo
— Settings, AuditDialog, etc. all benefit transparently.0.7.84 — RoadmapDialog v2 finishing pass
Theme: RoadmapDialog v2 finishing pass — keyboard ergonomics
(/-focus, Esc-clear, ?-cheatsheet), accessibility (status and
theme glyph labels for screen readers), and a freshness signal
("Updated N days ago" on in-progress cards). Plus theme-aware
frameless title bars + dark-theme link fixes across every
dialog.
ANTS-1237 — "Updated N days ago" line on 🚧 cards in the
Roadmap dialog. Surfaces stall signal at-a-glance: every 🚧
in-progress card now shows when its bullet block was last
touched, mirroring GitHub Projects / Linear / Jira's default
"last activity" affordance. Derived from one
git blame --line-porcelain call against ROADMAP.md (cached
by mtime via the same pattern as parseShippedDates), then
MAX(author-time) over each - 🚧 [ANTS-NNNN] bullet's line +
its 2-space-indented continuation lines. Renders as
<span class="rm-date">· Updated <X></span> inline with the
ID chip, where <X> is one of: today / yesterday /
Nd ago (2-13d) / Nw ago (2-8w) / Nmo ago (2-12mo) /
Ny ago (≥1y). Reuses the existing .rm-date CSS class —
no new style rule. Status-gated (✅ already shows shipped
date; 📋/💭 don't need it). Graceful degradation on non-git
checkouts: git blame failure → empty hash → no "Updated"
line, mirroring the ✅-card path when shipped date is absent.
ID mentions outside the bullet block (e.g. audit-trail prose
in another card's body) do NOT contribute to MAX — the walker
only enters block-mode at lines matching
^- 🚧 \[ANTS-NNNN\]. Cost: ~175 ms blame call on first
dialog open per session against the current 8723-line
ROADMAP, cached thereafter; ~4 MB transient peak for the
porcelain output (verified by wc -c); ~1 KiB persistent
per dialog at realistic 5-15 🚧 bullet counts. Regression-
locked by ANTS-1237-INV-1..8 in
tests/features/roadmap_inprogress_age/spec.md (renderer-
layer + parser-layer test split; parser tests early-skip with
GTEST_SKIP() when git is not on PATH). Spec:
docs/specs/ANTS-1237.md (cold-eyes-clean after 4 loops + ~40
verified findings fixed; full audit trail at ROADMAP.md
### 📝 Cold-eyes 2026-05-11 (ANTS-1237 spec)).
ANTS-1234 — Roadmap dialog /-focus + Esc-clear + body-only
auto-expand. Three keyboard-first ergonomics additions to the
Roadmap dialog's existing substring search box. (1) Pressing /
anywhere in the dialog focuses the search box and select-alls
any existing predicate so the next keystroke replaces it
(Linear / GitHub / Notion / VS Code / Slack convention).
Layout-robust via event->text() == "/"; gated on
!m_searchBox->hasFocus() so the user can still type / into
the predicate (URLs, path tokens). (2) Pressing Esc while the
search box has focus clears the predicate and removes focus —
one press undoes a search, a second press closes the dialog as
before. Implemented via installEventFilter(this) on the
search box + a dialog-side eventFilter override that consumes
the Escape KeyPress before it can reach QDialog::reject. For
every non-Escape key, the filter falls through so the QLineEdit
receives PgUp / F5 / typing characters normally. (3) Cards
whose match lives only in the body continuation prose (not in
ID / headline / Layman) auto-expand for that render so the
matched substring is visible in context. Render-time only;
m_expandedItems is never mutated, so clearing the search
reverts every auto-expanded card to its prior user-driven
state. The id:NNNN jump shortcut also auto-expands the
matched card. The cheatsheet (ANTS-1236) gains a / row in
lockstep — kRoadmapShortcuts[] bumps 9 → 10 rows and the
static_assert + test assertions all move 9 → 10 in the same
commit. Cost: +4 case-insensitive QString::contains calls per
card per render (~0.3 ms on 200 cards, well below the 120 ms
search debounce). Regression-locked by ANTS-1234-INV-1..9 in
tests/features/roadmap_search_keybinds/spec.md. Spec:
docs/specs/ANTS-1234.md (cold-eyes-clean after 7 loops + ~25
findings fixed).
ANTS-1236 — Keyboard-shortcut cheatsheet in the Roadmap dialog.
Press ? inside the Roadmap dialog to open a sub-dialog listing
every keyboard shortcut it ships today. Mechanism: a file-scope
kRoadmapShortcuts[] data table in src/roadmapdialog.cpp is the
single source of truth; roadmapShortcutRows() exports it to
RoadmapShortcutsDialog (src/roadmapshortcutsdialog.{h,cpp}),
which renders a two-column QTableWidget (Shortcut / Action).
10 shortcuts at ship after ANTS-1234 added /: ?, /, Esc,
F5, Ctrl+C, Ctrl+A, ↑ ↓, PgUp PgDn, Home End,
Tab Shift+Tab. The trigger uses event->text() == "?"
(layout-robust — AltGr / dead-key paths on non-US layouts still
hit it) and gates on !m_searchBox->hasFocus() so the user can
still type ? into the substring filter. The overlay is lazy +
reused (one instance via QPointer), inherits the active
terminal theme through DialogChrome, and announces via
setWindowTitle(tr("Roadmap Keyboard Shortcuts")). The
static_assert(std::size(kRoadmapShortcuts) == 10, …) guard
locks the row count to the test's exact-10 assertion so adding a
future shortcut must bump the test in lockstep. Regression-locked
by ANTS-1236-INV-1..8 in
tests/features/roadmap_shortcuts_cheatsheet/spec.md. Spec:
docs/specs/ANTS-1236.md (cold-eyes-clean after 7 loops + ~27
findings fixed).
ANTS-1235 — Accessible status / theme glyph labels in the
Roadmap dialog. Screen readers (Orca / NVDA / VoiceOver)
announce "✅" as "white heavy check mark" by default — useless
as a scan cue on a several-hundred-bullet roadmap. The fix
emits a short, lowercase text label inline alongside each
status emoji on every card: <span class="rm-state">✅</span> <span class="rm-state-label">shipped</span> and friends.
Section-header count chips gain trailing words too —
✅ 47 shipped · 🚧 2 in progress · 📋 3 planned · 💭 8 considered
in place of the prior bare ✅ 47 🚧 2 …. Filter checkboxes
get setAccessibleName() setters ("Show shipped items"
etc.) so Orca speaks the verb-led form instead of the visible
label. The visible ✅ Done checkbox label is renamed
✅ Shipped for vocabulary consistency with the rest of the
roadmap-format standards (§3.3 accepts both terms). Theme
glyphs (🎨/⚡/🔌/🖥/🔒/🧰/📚/📦/🐛/🔍/🧹) stay unlabelled
because the heading text they prefix already labels them.
Verified mechanism: HTML aria-label is dead on arrival in
Qt 6 QTextBrowser (parser strips unknown attributes;
QAccessibleTextInterface only reads toPlainText()), so
any a11y solution must put the label in the rendered text.
docs/standards/documentation.md gains a new Accessibility
section documenting the Qt 6 a11y path so future contributors
don't repeat the aria-label investigation. Regression-locked
by INV-18..24 in tests/features/roadmap_dialog_cards/spec.md
(302/302 tests pass). Spec: docs/specs/ANTS-1235.md. Cold-
eyes audit trail: ROADMAP.md ### 📝 Cold-eyes 2026-05-11.
QPalette. The main terminal window already worked around this
by going frameless (Qt::FramelessWindowHint) and drawing its
own TitleBar widget; dialogs did not. Fixed by introducing a
shared DialogChrome helper (src/dialogchrome.{h,cpp}) that
any dialog ctor can invoke with one line:
auto chrome = DialogChrome::install(this, themeName); — it
sets the frameless flag, prepends a themed TitleBar widget,
wires close / minimize / maximize signals, and returns a
content QWidget to use as the layout parent for the rest of
the ctor. Applied to all ten QDialog subclasses:
RoadmapDialog, AuditDialog, SettingsDialog, SshDialog,
AiDialog, ClaudeAllowlistDialog, ClaudeTranscriptDialog,
ClaudeProjectsDialog, ClaudeBgTasksDialog, and
ClaudeTaskListDialog. MainWindow::applyTheme now also calls
DialogChrome::setActiveTheme(name) so dialogs that don't
receive a theme name still pick up the current one. Live-theme
refresh is preserved on the dialogs that previously supported
it (Roadmap, bg tasks, task list) — the helper's
applyTheme(dlg, bar, name) re-styles both the dialog palette
and the title bar.#NNNN on summary row +
larger ID font. Third-pass user feedback after ANTS-1240
(2026-05-11): the card's #NNNN (and shipped date) were
emitted on a separate <div class="rm-meta"> row below the
summary line, which (a) hit the same Qt nested-block
QPalette::Base frame issue that ANTS-1240 fixed on the
expanded body — painting a darker band under the ID on dark
themes — and (b) was visually noisy. Fixed by emitting the
hashed ID and shipped date as inline <span> children of the
card div, on the summary row immediately before the
rm-toggle anchor. CSS .rm-id font bumped from the old
10 px meta size to 12 px so the number is scannable at a
glance. The .rm-meta CSS rule is gone. Regression-locked by
spec INV-17 in tests/features/roadmap_dialog_cards/spec.md.QTextBrowser's QPalette::Link / LinkVisited were
themed in ANTS-1239; the QDialog window, the QListWidget
TOC sidebar, and the viewer's Base / Text roles all
fell through to Qt's default dark palette. So the dialog
looked greyer than the rest of the app (e.g. against the
Tokyo Night / Dracula / One Dark navy backgrounds). Fixed
by applying bgPrimary to QPalette::Window / Base
on all three widgets, textPrimary to
WindowText/Text, and accent to the TOC's
Highlight so the selection indicator stays visible.<div>
block elements with their own QPalette::Base background
frame — it does not visually inherit from the parent
<div>'s background CSS. So the renderer's
<div class="rm-body"> wrapper inside each
<div class="rm-card"> painted bgPrimary over the
card's bgSecondary background, creating a visible
colour break at the divider. Fixed by emitting body
<p> paragraphs directly as children of the card (no
wrapping div) — <p> doesn't create a separate
background frame, so each paragraph paints over the
card's bgSecondary and the visual continuity is
restored. First body paragraph carries
class="rm-body-first" (dotted divider + extra
padding-top); subsequent lines carry
class="rm-body-line" (indent only). Regression-locked
by spec INV-15 + INV-16 in
tests/features/roadmap_dialog_cards/spec.md.<a> text on dark themes. Two bugs surfaced by user
test-drive of 0.7.83's card renderer:### Performance h3s (under 0.7.0,
0.8.0, and Beyond 1.0) all slugged to performance, so
expanding one expanded all three, and bySection["performance"]
pooled bullets from every Performance section into a single
bucket. Fixed by tracking a seen set across each walk and
appending -2, -3, … to repeat slugs (uniqueSlug helper).
Both parseBullets and renderCardsHtml walk the same
sourceText in the same order, so their slug sequences agree
and bySection[slug] keys match the URLs emitted into the
click anchors. Same fix covers other duplicate h3s in the
file (Platform, Cross-cutting themes, Security,
Tier 2 — hardening sweep, …).<a> foreground using the widget's QPalette::Link
role, ignoring the inline <style> block's a{color:…} rule
and not propagating through color:inherit. On Qt 6's default
palette that role renders as near-black on most dark themes,
so the chevron + heading text in each section header
disappeared. Fixed by setting QPalette::Link /
QPalette::LinkVisited on the QTextBrowser to the active
theme's textPrimary, plus emitting explicit
color:<textPrimary> on .rm-section-toggle /
.rm-section-title (replacing the prior color:inherit) as
belt-and-braces. Regression-locked by spec INV-13/14 in
tests/features/roadmap_dialog_cards/spec.md.See CHANGELOG.md for release notes.
See CHANGELOG.md for release notes.
0.7.81 — tasks-chip semantics polish + About-dialog build context
Theme: small follow-ups to the 0.7.80 user-feedback batch — a two-fix bundle that finishes the Tasks-chip semantics arc started in ANTS-1216, plus a long-requested About-dialog enhancement that surfaces enough build context to triage bug reports without a "what build are you on?" round-trip.
Build: line with the build date, build type (Release/Debug/RelWithDebInfo), short git commit (or unknown for tarball builds), and the compiler used (GCC or Clang with version). Implementation: a CMake configure_file() block at the top of CMakeLists.txt writes build/generated/build_info.h at configure time; the compiler ID is detected at compile time inside aboutdialogs.cpp so it reflects the actual toolchain (env-var overrides, distcc, distro alternative chains) rather than what CMake found. Reconfigure (cmake build/) to refresh the SHA between commits.☰ X/Y chip used to mean "X tasks left out of Y" (counted down) and stayed lit while a single in-flight Claude task remained. It now means "X done out of Y" (counts up, like every other progress display in the app) and disappears as soon as nothing is waiting on you. Two bundled changes: (1) ClaudeTaskListTracker::unfinishedCount() now counts only pending tasks — a task Claude is actively working on is Claude's problem, not yours, so it no longer keeps the chip visible. (2) The chip's numerator is now total - unfinished instead of unfinished, so X/Y reads like a GitHub-PR-checks progress bar. Pre-existing unfinished <= 0 hide branch (from ANTS-1216) still fires — combined effect: chip cleanly hides at 100%, monotonically increases as tasks complete, and never surfaces a single in-flight task as actionable.CI is now building the AppImage and other release artifacts via release.yml; they'll attach to this release page once the workflow completes (typically a few minutes on the public-repo Linux runners).
See CHANGELOG.md for release notes.
See CHANGELOG.md for release notes.
See CHANGELOG.md for release notes.
See CHANGELOG.md for release notes.
0.7.75 — DEC mode 2026 sync output atomicity fix (ANTS-1148)
Theme: ANTS-1148 — DEC private mode 2026 (Synchronized Output /
BSU/ESU) atomicity fix. Pre-fix, onVtBatch suppressed only its own
update() during BSU but left the live grid mutated by
processAction; any other paint trigger (blinkCursor,
focusIn/Out, hover, selection, visual-bell flash) would call
paintEvent which read the live half-applied state and leak mid-
sync content to screen. The fix unifies sync output onto the
existing 0.6.33 frozen-screen snapshot machinery: pre-scan a
VtBatch for CSI ?2026h, capture the snapshot before the
processAction loop, route paintEvent's cursor reads through new
effectiveCursorRow/Col() accessors, and clear on ESU or via the
500 ms safety timer. Single TU touched (terminalwidget.{cpp,h}),
behavioural-correctness only — no LoC delta on mainwindow.cpp.
132/132 ctests pass.
ANTS-1148 — Synchronized Output (DEC mode 2026) is now
atomic against non-batch paint triggers. Pre-fix, BSU
(CSI ?2026h) suppressed onVtBatch's trailing update() but
did nothing about the live grid mutation underneath: any
paintEvent driven by blinkCursor (550 ms cadence),
focusInEvent / focusOutEvent, mouse-hover OSC 8 hyperlink
rollover, selection drag, or visual-bell flash would re-render
from the half-applied grid mid-BSU and leak partial state —
exactly the tearing the protocol exists to prevent. Fix folds
sync output onto the existing 0.6.33 frozen-screen snapshot
path: a new file-scope helper batchEntersSyncOutput pre-scans
the VtBatch for CSI ?2026h (CsiDispatch + intermediate ? +
finalChar h + 2026 in params), and onVtBatch captures the
snapshot under
(m_syncOutputActive || batchEntersSyncOutput(*batch)) && m_frozenScreenRows.empty() before processAction runs. The
left disjunct handles eviction-recovery — resize / RIS / alt-
screen toggle clear the snapshot mid-sync, and we need to
re-capture for the rest of the block. Cleanup runs at end-of-
batch when !m_syncOutputActive && m_scrollOffset == 0 && !m_frozenScreenRows.empty() (drops the prior wasSync && gate
that stranded same-batch BSU+ESU snapshots — cold-eyes C2); the
500 ms safety timer also clears on force-end. paintEvent's
three cursor-render sites (cursor draw, under-cursor glyph,
autocomplete ghost) route through new
effectiveCursorRow/Col() inline accessors that read
m_frozenCursorRow/Col when m_frozenScreenRows is non-empty
and live otherwise; the eight typing / IME / semantic-output
cursor reads (keyPressEvent, inputMethodQuery,
clickToMoveCursor, findMatchingBracket, toggleFoldAtCursor,
toggleBookmark, updateSuggestion, lastCommandOutput)
intentionally keep direct m_grid->cursorRow/Col() reads —
those paths want the real cursor, not the rendered one. Spec at
docs/specs/ANTS-1148.md cold-eyes-reviewed before code (10
findings folded — 2 CRITICAL, 3 HIGH, 3 MEDIUM, 2 LOW); new
feature test at tests/features/sync_output_snapshot/ locks
9 INVs by source-grep. Out-of-snapshot scope (intentional,
matches xterm/foot): cursor visibility / shape / blink
(DECSCUSR, DECTCEM) and inline images (Sixel / Kitty / iTerm2)
read live mid-BSU.
ANTS-1148 — updateScrollBar's frozen-snapshot predicate
extended to cover sync output. Indie-review HIGH on commit
9674e5a caught a missed call site: updateScrollBar at
terminalwidget.cpp:2793 still used the pre-1148 predicate
wantFrozen = (m_scrollOffset > 0). Closes spec transition
row 7 — scrolled-back-during-sync, user scrolls back to bottom
while BSU is still active: without the sync clause, the
snapshot is evicted and the next paint reads the half-applied
grid. Fix extends to (m_scrollOffset > 0) || m_syncOutputActive,
matching the unified-predicate pattern already in use at the
other three call sites (onVtBatch pre-scan, end-of-loop
cleanup, safety-timer slot). Test gains INV-4b locking the
disjunction at this third site (74395a6).
blinkCursor partial-update rect routes through
effectiveCursorRow/Col(). Pre-fix the blink invalidation
rect was computed from m_grid->cursorRow/Col() directly; under
sync the live cursor position can have advanced past the
frozen-rendered cell, so the partial-update rect wouldn't cover
the cell paintEvent actually draws and the cursor would
visually stall mid-BSU. Routing through the centralised
accessor locks the invalidation rect to the rendered cell — a
small consistency win that drops out of the snapshot
unification rather than a separate fix.0.7.74 — Bundle G Tier 3 closeout (mainwindow.cpp -785 LoC)
Theme: Bundle G Tier 3 carve-out — second and third of three
L6 LoC decompositions of src/mainwindow.cpp. Ships the
ClaudeStatusBarController extraction (ANTS-1146) and the
themedstylesheet helper extraction (ANTS-1147), together cutting
mainwindow.cpp from 6249 → 5464 LoC (-785, -12.6 %) — the
largest single-bundle reduction in the file's recent history.
Adds three roadmap items (ANTS-1155 / 1156 / 1157) covering the
in-app self-update gap, a roadmap-system audit, and the Project
Audit tool flesh-out. Closes with a post-bundle debt sweep
(5 trivials + 3 behaviorals). 131/131 ctests pass.
ANTS-1146 — ClaudeStatusBarController extracted from
mainwindow.cpp. L6 LoC2 carve-out. Three contiguous
Claude-status sections — applyClaudeStatusLabel,
updateClaudeThemeColors, setupClaudeIntegration (~680 LoC
combined, including the ~120-LoC permission-button factory) —
moved to a top-level ClaudeStatusBarController : QObject in
new translation unit src/claudestatuswidgets.{cpp,h}. State
booleans (m_claudePromptActive, m_claudePlanMode,
m_claudeAuditing, m_claudeLastState, m_claudeLastDetail)
and the six widgets they drive (m_claudeStatusLabel,
m_claudeContextBar, m_claudeReviewBtn, m_claudeErrorLabel,
m_claudeBgTasks, m_claudeBgTasksBtn) all migrate; service
objects (m_claudeIntegration, m_claudeTabTracker) stay on
MainWindow and are observed via attach(). Coupling shape:
six signals out (reviewClicked, bgTasksClicked,
allowlistRequested, reviewButtonShouldRefresh,
statusMessageRequested, statusMessageCleared) plus four
std::function providers in (current/focused/at-tab terminal
setupClaudeIntegration for historical convenience but aren't
Claude chrome (Roadmap button, update-available QAction, 5 s
startup update-check singleShot) stay on MainWindow under
the renamed setupStatusBarChrome host. mainwindow.cpp:
6249 → 5656 LoC (-593). Spec at docs/specs/ANTS-1146.md
cold-eyes-reviewed before implementation; new feature test at
tests/features/claude_statusbar_extraction/ locks 9 INVs;
three pre-existing tests (claude_bg_tasks_button,
claude_state_dot_palette, allowlist_add) re-pointed at
claudestatuswidgets.cpp per the spec's INV-9 table.ANTS-1147 — themedstylesheet helpers extracted from
mainwindow.cpp + cache-and-compare branch chip. L6 LoC3
carve-out. applyTheme's ~178 LoC inline QSS body and four
per-widget restyle templates moved to pure-function builders
in new translation unit src/themedstylesheet.{cpp,h}: six
public helpers (buildAppStylesheet,
buildMenuBarStylesheet, buildStatusMessageStylesheet,
buildStatusProcessStylesheet, buildGitSeparatorStylesheet,
buildChipStylesheet). The chip QSS template that was
inlined three times (applyTheme, updateStatusBar,
refreshRepoVisibility) collapses onto one parameterised
helper; margin asymmetry preserved byte-for-byte (unit-free
0 vs <N>px). mainwindow.cpp: 5656 → 5464 LoC (-192);
themedstylesheet.cpp: 272 LoC. New feature test at
tests/features/themedstylesheet_extraction/ locks 8 INVs;
three pre-existing tests (menubar_hover_stylesheet,
tab_close_button_visible, review_changes_clickable)
re-pointed at themedstylesheet.cpp.
updateStatusBar runs every 2 s on the status timer; pre-fix
code rebuilt and re-applied the branch-chip QSS unconditionally
on every tick even when nothing changed. Two new private
members on MainWindow (m_lastBranchChipQss +
m_lastBranchChipValid) cache the last-applied stylesheet;
the tick now computes the new QSS via
themedstylesheet::buildChipStylesheet and only calls
setStyleSheet when it differs. applyTheme invalidates the
cache on theme change so the next tick re-applies. User-visible
only as a small CPU reduction in the steady-state status-tick
path; the optimisation was the roadmap entry's stated
motivation alongside the extraction.ANTS-1155 added to 0.8.0 — true in-app self-update (no
AppImageUpdate dep). Today's "Update" click in
handleUpdateClicked is in-place auto-update only when the
user already has AppImageUpdate (GUI) or
appimageupdatetool (CLI) on $PATH AND is running the
AppImage build; every other path falls through to
QDesktopServices::openUrl — a glorified browser link. User
feedback 2026-05-02: "the terminal must download and apply the
update directly, no link." Plan: download full AppImage via
QNetworkAccessManager, SHA-256 verify against the *.sha256
artefact already published by release.yml, atomic
rename(2) over $APPIMAGE, restart-with-session-preservation.
Three-bucket distribution-channel contract (ANTS_BUILD_CHANNEL
= appimage / distro / source): distro-packaged builds
suppress the notifier structurally — Flatpak / RPM / .deb /
Arch users update via their package manager, not via the app.
Placed in 📦 Distribution readiness between H7 (website) and
H13 (outreach launch) — auto-update lands before outreach so
the wave of new users actually stays on latest.
ANTS-1156 added to 0.8.0 — Roadmap-system audit (split /
tag / integrate / display / number / write). User ask
2026-05-02: "We need to iron out how the roadmap is going to
work" — six concrete questions. Three already covered (defer
to ANTS-1154 tagging, ANTS-1139 summary-table renderer, and
docs/standards/roadmap-format.md § 3.5.1 numbering); three
genuinely open (file-splitting strategy as ROADMAP.md
approaches the § 3.9 archive-rotation threshold; Ants Terminal
↔ roadmap integration via Help-menu access + cross-roadmap
navigation + two-pane reading mode; Claude Code ↔ roadmap
integration via auto-load, fold-in of /audit and /debt-sweep
findings, and a roadmap MCP capability). Six child
deliverables (1156-A through 1156-F) enumerated to spin out
once decisions land. Sequence dependency: ANTS-1154 (format v2
tagging) lands first since 1156's child items consume v2
tags. Placed at the lead of 🧰 Dev experience.
ANTS-1157 added to 0.8.0 — Project Audit tool flesh-out
(cross-skill / cross-project history). User ask 2026-05-02:
flesh out the Project Audit concept by analysing every
/audit, /indie-review, and /debt-sweep run across all
projects. Six capabilities sequenced smallest-first: (1) run-
history persistence as dated SARIF v2.1.0 files in
~/.local/share/ants-terminal/audit-history/<slug>/; (2)
per-finding fingerprint + recurrence detection via SARIF
partialFingerprints (§ 3.36); (3) MTTR / open-finding-age
tracking + a new History tab in AuditDialog; (4) cross-skill
correlation lane for findings flagged by both static analysis
and cold-eyes review; (5) Projects tab with per-project
summary metrics modeled on Codacy/SonarQube; (6) roadmap-
fold-in audit trail (raw → ANTS-N → status flip → CHANGELOG)
integrating with ANTS-1117 IPC verbs and ANTS-1154 tagged-text.
Online research synthesised from SARIF 2.1.0 (OASIS),
DefectDojo v2.55.1, Microsoft sarif-tools, SARIF Visualizer,
Codacy, and SonarQube. Placed at the lead of 🧰 Dev experience
above ANTS-1156.
Post-bundle debt sweep — 5 trivials + 3 behaviorals.
Trivials: stale comment in mainwindow.h:4 referencing the
pre-1146 applyClaudeStatusLabel rewritten to point at the
controller; refreshStatusBarForActiveTab doc-comment at
mainwindow.h:404 updated similarly; unused <QApplication>
and <QDir> includes dropped from claudestatuswidgets.cpp;
tests/features/claude_bg_tasks_button/spec.md and
tests/features/claude_state_dot_palette/spec.md INV prose
updated to match the post-1146 controller-routed call shapes.
Behaviorals (user sign-off 2026-05-02): deleted write-only
m_lastBranchChipPrimary + m_lastBranchChipTheme cache
members from ANTS-1147 (the QSS string itself encodes the
theme × primary × margin triple, so per-flag fields were YAGNI
redundancy); added "verified externally via git diff --stat;
this in-process test asserts only the floor on the new TU"
disclaimer to the two-sided LoC anchors in both
docs/specs/ANTS-1146.md (INV-8) and docs/specs/ANTS-1147.md
(INV-7) so spec text matches what the source-grep harnesses
can actually enforce.
See CHANGELOG.md for release notes.
See CHANGELOG.md for release notes.
See CHANGELOG.md for release notes.
See CHANGELOG.md for release notes.
See CHANGELOG.md for release notes.
See CHANGELOG.md for release notes.
See CHANGELOG.md for release notes.
See CHANGELOG.md for release notes.
See CHANGELOG.md for release notes.
See CHANGELOG.md for release notes.
See CHANGELOG.md for release notes.
See CHANGELOG.md for release notes.
See CHANGELOG.md for release notes.
See CHANGELOG.md for release notes.
See CHANGELOG.md for release notes.
See CHANGELOG.md for release notes.
See CHANGELOG.md for release notes.
0.7.56 — App-Build suite alignment
Theme: App-Build suite alignment — confirm-on-close for tabs
running non-shell processes, four-doc shareable standards bundle
synced to the user-level /start-app template, CI-blocking
metainfo escape fix, and the strategic roadmap for incorporating
the App-Build workflow natively into Ants Terminal so users can
run as much of it as possible without spending Claude tokens.
Config::confirmCloseWithProcesses to false for subsequent
closes. Default on. Settings UI in the Terminal tab.
Default safe-shell allowlist: bash, zsh, fish, sh, ksh,
dash, ash, tcsh, csh, mksh, yash. Probe walks
/proc/<pid>/task/<pid>/children transitively (cap 256
visited PIDs) — Linux-only, mirrors the existing
ClaudeIntegration descendant probe. Locked by
tests/features/confirm_close_with_processes/ (11
invariants).docs/standards/
(coding · documentation · testing · commits) plus an index
README, with the detailed ROADMAP and CHANGELOG format spec
carried as a sub-spec at docs/standards/roadmap-format.md.
Commits standard mandates <ID>: <description> subjects so
every commit links back to a ROADMAP item; testing standard
mandates TDD by default (ANTS-1055, ANTS-1104).docs/decisions/ with
0001-record-architecture-decisions.md (Michael Nygard's
format) plus per-folder README.docs/specs/ and docs/journal/ placeholder folders for
per-feature spec drafts and per-phase outcomes.Project standards section in CLAUDE.md pointing at
the five standards files and the /start-app,
/app-workflow, /close-phase skills (ANTS-1104)./audit, ctest,
drift checks, /debt-sweep, /bump, /release, fold-in
templating, atomic CHANGELOG/ROADMAP edits) runs in C++
with zero LLM round-trips. Three supporting roadmap items:
ANTS-1106 (mandatory Kind: + viewer faceted
categorisation), ANTS-1107 (adopt App-Build documentation
folder structure: glossary.md, known-issues.md,
audit-allowlist.md, ideas.md, design.md,
.claude/workflow.md), ANTS-1109 (status-bar git-branch
chip restyle to match the Public/Private repo pill).docs/standards/roadmap-format.md for the format spec (was
docs/ROADMAP_FORMAT.md originally; briefly lived inline in
documentation.md § 3 before being extracted as a sub-spec
for token efficiency under ANTS-1104).docs/standards/ now byte-identical to the user-level
/start-app template at
~/.claude/skills/app-workflow/templates/docs/standards/,
with per-language idiom examples and push-policy details
delegated to the global ~/.claude/CLAUDE.md rather than
duplicated in the project standards (ANTS-1104).docs/standards/,
docs/decisions/, docs/specs/, docs/journal/; flags
STANDARDS.md / RULES.md as deprecated (retire under
ANTS-1105 with explicit user confirmation).[Unreleased] block
that the Roadmap dialog reads for current-work signaling
(roadmap-format.md § 4.1 mandates one always, even empty).& in the 0.7.55 metainfo <release> body broke
appstreamcli validate and turned CI red on every commit
since the release. Now &. (ANTS-1099)0.7.55 — VT parser correctness + audit hardening
Theme: VT parser correctness + audit-dialog hardening from the 2026-04-27 indie-review. Six fixes — multi-row OSC 8 hyperlink span emission, ITU/ECMA-48 colon-RGB form parsing, audit comment-suppress hyphen handling, trend-snapshot dedup, bg-tasks liveness sweep split from full reparse, and a hot-path returnByReference fix.
38:2::r:g:b dropped a channel. Parser now detects the 4-slot colon group (colorspace + R + G + B) and reads R/G/B from the right offsets. Legacy 38;2;r;g;b and 3-slot 38:2:r:g:b continue unchanged.// nosemgrep: bash-c-non-literal matched only bash because the terminator class included -. Now [)\]]|$.renderResults which fires on every severity-pill toggle. Now gated by m_snapshotPersisted, reset in runAudit().sweepLiveness() walks m_tasks via stat()+mtime check; the 2 s status-timer drives it instead of the 16 MiB transcript reparse. File watcher continues to drive full rescan() on transcript-changed.ClaudeBgTaskTracker::tasks() returned by value on hot path. cppcheck returnByReference. Now const &.0.7.54 — A11y + UX bundle (indie-review fold-in)
Theme: A11y + UX bundle from the 2026-04-27 indie-review. Six fixes — accessibility plumbing on the status bar + ToggleSwitch, plan-mode persistence across tab switches, an SSE iteration cap to prevent UI freeze on misbehaving AI endpoints, and IPv6 support in the SSH Quick Connect parser.
setAccessibleName. Branch chip, repo visibility chip, foreground-process label, status-message slot, Claude session label, Claude context bar, Review Changes button. Screen readers (Orca / NVDA / VoiceOver) now announce each chrome widget with semantic text instead of raw tab content or Powerline glyph codepoints. The Claude session label additionally updates accessibleDescription on every state transition so the screen reader announces the current state.ToggleSwitch accessibility plumbing. Default accessibleName + QAccessibleStateChangeEvent on every check-state flip (checkStateSet / nextCheckState). Keyboard users now get immediate AT-SPI / UIAutomation confirmation when their toggle lands.m_planModeByPid and restores it on tab switch back, so the indicator no longer flickers off→on when the transcript-tail window doesn't include the latched permission-mode event.singleShot(0). Caps at 256 lines per tick (covers ~8 seconds of legitimate streaming output before yielding); pathological bursts yield to the event loop. Prevents UI freeze on misbehaving endpoints.[2001:db8::1]:2222 no longer mis-parses as host=[2001 port=0. Matches RFC 3986 §3.2.2 / OpenSSH command-line convention.0.7.53 — Tier-1 remainders + VT/paste/plugin hardening
Theme: Tier-1 remainders + VT/paste/plugin hardening from the 2026-04-27 indie-review. Six security-class fixes — three completing the Tier-1 ship-this-week list, three from Tier-2 paste/image hardening.
ESC c triggered RIS (full terminal reset); ESC D triggered IND; ESC 7 / ESC 8 triggered DECSC/DECRC. RCE-adjacent because OSC payloads can arrive from a hostile remote shell, trigger-rule expansion, or pasted content. Fixed at the C0 pre-handler — string-state ESC no longer transitions to Escape; new OscStringEsc / DcsStringEsc / ApcStringEsc / IgnoreStringEsc peek-states consume the trailing byte and return to Ground. Locked by new tests/features/vt_osc_esc_discard/ (5 invariants × 11 sub-cases).col + 32 produced bytes ≥ 0xE0 when col exceeded 223; apps reading the emit stream mis-framed subsequent click bytes as UTF-8 continuations. Both wheel-event + mousePressEvent SGR fallback now clamp col/row to 223 in X10 mode.knownPermissions() (clipboard.write, settings); prompt-result is intersected with requested set before reaching the engine.m_imagePasteDir canonicalised + required under $HOME; UUID4 suffix on filename to prevent millisecond-collision clobbers.\r terminator spoofed dialog. Preview now normalises \r\n and bare \r to \n for display; actual paste still writes original bytes verbatim.\x9B[200~ not stripped. Now strips 7-bit (\x1B[), raw 8-bit (\x9B), and UTF-8-encoded 8-bit (\xC2\x9B) forms.tests/features/vt_osc_esc_discard/ — 5 invariants × 11 sub-cases.0.7.52 — indie-review tier-1 sweep (data-loss + Wayland + secret-leak)
Theme: First Tier-1 sweep of the 2026-04-27 indie-review findings. Eight fixes covering CRITICAL data-loss / security regressions plus HIGH security-class hardening — SessionManager silent scrollback loss, the same Wayland modal-grab pattern from 0.7.50 lurking in the update-confirmation dialog, SARIF/HTML reports leaking secrets at 0644, and five smaller HIGH items. The user-reported GitHub repo-type chip regression is also resolved (verified visible).
SessionManager silent data loss. saveSession + saveTabOrder used QFile::rename, which on POSIX refuses to overwrite an existing destination. Every session save AFTER the first silently failed: the .dat file held the original snapshot, .dat.tmp accumulated each new write, user scrollback never updated past the first save. Switched both to std::rename (POSIX rename(2) — atomic replace), mirroring the 0.7.12 Config fix. Errno logged on failure; orphaned .tmp removed.QMessageBox box(this); box.exec() — exactly the QTBUG-79126 / QTBUG-90005 click-drop pattern. Converted to the proven non-modal QDialog + plain QPushButton + clicked()→close() shape.gitleaks / secrets_scan rules. Switched to QSaveFile + commit() + setOwnerOnlyPerms (0600).new-tab / launch IPC commands bypassed the send-text C0 filter. Both now route through filterControlChars by default; raw: true opt-out preserved.file:// and ftp:// schemes removed from the hyperlink allowlist. xdg-open file:///foo.desktop autoexecutes via the desktop's .desktop handler — RCE-adjacent. Allowlist now covers only http, https, mailto.extraArgs quote-bypass on the dangerous--o allowlist. Switched to QProcess::splitCommand for proper POSIX shell quoting.extractCwdFromTranscript unbounded readLine. Caps at 64 KiB.file:// / gopher:// / bare-host schemes up-front.openFileAtPath argv-injection via attacker-controlled paths starting with -. Now prepends ./ for combined-path branches and inserts -- for separate-path branches.tests/features/persistence_post_rename_chmod/ INV-2 revised to accept either QFile::rename or std::rename + rc==0 gating.tests/features/github_status_bar/ INV-17 revised to lock the Wayland-correct dialog shape (forbid QMessageBox box(this), require new QDialog(this) + &QPushButton::clicked + &QDialog::close).tests/features/remote_control_new_tab/ INV-3c/3d added.tests/features/remote_control_launch/ INV-4b/4c added.0.7.51 — config-reload inotify-loop fix
Theme: Hot-reload doesn't loop any more. Same-day follow-up to 0.7.50 — three reported symptoms (status bar permanently sticking at "Config reloaded from disk", Help → Check for Updates appearing to do nothing, Settings → Preferences not opening) all traced to a single root cause: MainWindow::onConfigFileChanged re-entering itself in an infinite inotify loop. The 0.7.31 attempt at fixing the same loop (m_configWatcher->blockSignals(true/false) bracketing) never worked because Qt reads inotify events from the event loop after the slot returns — outliving the blockSignals window. Fix is at the source: make the setters idempotent so the slot has nothing to re-trigger.
Config reload re-entered itself in an inotify loop. Config::setTheme unconditionally called save() even when the value matched, so any applyTheme(m_config.theme()) from inside onConfigFileChanged rewrote the watched file → kernel inotify event → next event-loop tick re-emits fileChanged → slot re-enters → loop. setTheme now early-returns when the value matches, breaking the loop at its source. onConfigFileChanged additionally skips the no-op applyTheme call when the theme didn't change, and carries an m_inConfigReload re-entrancy flag (cleared via QTimer::singleShot(0, ...) so a save inside the slot is dropped but a subsequent genuine external edit is honored). The failed 0.7.31 blockSignals calls are removed.
User-visible cascade now resolves:
Help → Check for Updates produces a visible "Checking for updates…" then "Up to date — running v0.7.51 (latest)" toast (or the actual update notice when behind).Settings → Preferences opens the dialog instead of having it deleted-on-reload faster than the user can click.Locked down by tests/features/config_reload_loop_safety/ — INV-1 idempotent setter (call shape + functional mtime check), INV-2 no-op skip at the call site, INV-3 re-entrancy guard, INV-4 failed blockSignals attempt removed.
0.7.50 — Wayland-correct dialog dismissal
Theme: Round four on the same dialog-button bug — and the matching Roadmap-dialog Close button. Three prior fix attempts (0.7.22, 0.7.35, 0.7.49) each diagnosed a downstream symptom rather than the actual root cause; this release identifies it from upstream Qt bug reports and applies the fix that the bg-tasks dialog has been quietly using all along.
Both Help → About OK buttons (still) didn't dismiss the dialog in 0.7.49, and the Roadmap dialog's Close button hadn't worked since 0.7.43 either. User report 2026-04-28: "OK buttons still do nothing on the 2 About dialogs… Close button does nothing on the Roadmap either."
Real root cause: both upstream Qt bugs QTBUG-79126 ("Dialogs behavior on Wayland is wrong") and QTBUG-90005 ("global modality on Wayland") document that Wayland's xdg-shell protocol has no equivalent of Qt::ApplicationModal. Calling setModal(true) (which 0.7.49 added) is a no-op on Wayland except for an aggravating side effect: KWin/Wayland routes click events into the modal-grab handler which then drops them, instead of delivering them to the dialog's button. The role-based dispatch path inside QDialogButtonBox is a known second aggravator on the same bug.
The Background Tasks dialog has been working all along on the same parent-window flags — by being non-modal, with a plain QPushButton whose clicked() is wired directly to QDialog::close. Both About dialogs and the Roadmap dialog now follow that proven shape: non-modal, plain QPushButton, direct clicked() → close(). No more setModal(true), no more QDialogButtonBox in these handlers. The OK / Close click reaches its slot and the dialog dismisses on the first try.
Spec coverage extended: tests/features/help_about_menu/ INV-4 now asserts a plain QPushButton (negative grep on new QDialogButtonBox); INV-7f flips from "must call setModal(true)" to "must NOT call dlg->setModal(". tests/features/roadmap_viewer/ INV-14 mirrors the same. The regression history in help_about_menu/spec.md records the Wayland-modal root cause so the next contributor doesn't repeat the cycle.
🤖 AppImage builds via the existing release workflow. Wait for the workflow to attach the AppImage to this release.
See CHANGELOG.md for release notes.
See CHANGELOG.md for release notes.
See CHANGELOG.md for release notes.
See CHANGELOG.md for release notes.
See CHANGELOG.md for release notes.
See CHANGELOG.md for release notes.
See CHANGELOG.md for release notes.
0.7.42 — Command Palette ghost-completion (Tab to commit /slash-style suggestions)
Theme: Inline ghost-text completion in the Command Palette —
Claude Code's /slash-command UX, ported to Ctrl+Shift+P. As you
type, the unmatched suffix of the top fuzzy-match is rendered in a
dimmed colour right after the cursor; Tab commits the suggestion
into the input. Retires ROADMAP § "Command Palette ghost-completion
(near-term, small scope)".
GhostLineEdit subclass of QLineEdit. Lives in
src/commandpalette.h. Carries a m_ghost string set via
setGhostSuffix(); paintEvent calls
QLineEdit::paintEvent(event) first, then opens a fresh QPainter
and draws the ghost at cursorRect().right() + 1 using
palette().color(QPalette::Text) with setAlphaF(0.45). No
layout changes — the suffix overlays the existing line-edit content
area, so palette geometry is unchanged.
CommandPalette::updateGhostCompletion(filter). Invoked from
populateList after the result list is rebuilt. Looks at
m_list->item(0), recovers the underlying QAction, strips &
accelerators, and:
setGhostSuffix("");setGhostSuffix(name.mid(filter.length())), which
preserves the action name's original casing in the ghost.contains()-only matches (where the filter appears mid-string in
the top match) get an empty ghost — the visual contract is that
the suffix appears flush after the user's typed input, which only
makes sense for prefix matches.
Tab-key commit handler. eventFilter adds
Qt::Key_Tab → commitGhost(), which appends the ghost suffix to
the input via setText. The follow-up
textChanged → filterActions → populateList → updateGhostCompletion
cycle clears the ghost in the same dispatch since the new filter
equals the action name's tail exactly. The post-commit text equals
the visible composition (user-typed prefix + ghost suffix), so
user-typed casing is preserved — same as shell-completion
semantics. Tab is always consumed by the palette (even when
the ghost is empty) so focus cannot leak out of the input while
the palette is open. Tab does not also execute the action — the
user presses Enter to run, matching Claude Code's
/slash-completion contract.
tests/features/command_palette_ghost_completion/ — ten
invariants spanning the contract: I1 the input is a GhostLineEdit
findable by objectName; I2 empty filter → empty ghost; I3
prefix "ind" → ghost "ex Review" (first item in setActions
order is "Index Review"); I4 uppercase "INDEX" →
ghost " Review" (case-insensitive prefix, casing preserved
from the action name); I5 contains-only match → empty ghost;
I6 no-match → empty ghost; I7 Tab commit → text =
filter + ghost ("index Review") and ghost cleared; I8 Tab
with empty ghost is consumed (text unchanged, focus retained on
input); I9 source-grep on commandpalette.cpp (exactly one
setAlphaF(0.45 literal, ≥1 cursorRect( reference); I10 Esc
still hides the palette and emits closed() exactly once
(regression guard on existing dismiss behaviour). Drives
CommandPalette directly under QT_QPA_PLATFORM=offscreen —
no MainWindow link, no PTY. Pre-fix verification: with
commandpalette.{h,cpp} stashed, the test fails to even compile
(missing GhostLineEdit symbol); restored, test passes.💭 with deferral past 1.0. It
would touch terminalgrid.cpp paint path and vtparser.cpp
rather than commandpalette.cpp.0.7.41 — A11y for chrome (accessible names + AT-SPI introspection lane)
Theme: Accessibility — explicit setAccessibleName /
setAccessibleDescription on every glyph-only chrome control so
Orca, Speakup, and other AT-SPI screen readers announce them by
purpose ("Close window") instead of by codepoint name ("eight-spoked
asterisk", "multiplication sign x"). Two ROADMAP items retired in
one bundle: § "Accessibility pass on chrome" (mainwindow / palette /
title-bar control labels) and § "AT-SPI introspection lane" (an
automated check that every user-visible chrome control carries a
name).
Accessible names on TitleBar window controls.
centerBtn ("Center window" / "Center this window on the active
screen"), minimizeBtn ("Minimize window"), maximizeBtn
("Maximize window" / "Maximize or restore this window"), and
closeBtn ("Close window") each carry an explicit
setAccessibleName and setAccessibleDescription set immediately
after setText in titlebar.cpp:54-97. Each button also gains a
stable objectName (the previously-set closeBtn is joined by
the matching three siblings) so the introspection test can find
them deterministically. Visual rendering is unchanged — the glyphs
(✥ – □ ✕) still drive the on-screen pixels; only the AT-SPI tree
changed.
Accessible names on the Command Palette. m_input
(objectName commandPaletteInput) gains Command palette search
/ Type to filter actions; Tab to commit; Esc to dismiss.
m_list (objectName commandPaletteList) gains
Command palette results / Available actions matching the current filter. Placeholder text and visible behaviour are
unchanged. The placeholder is a typing hint for sighted users; the
accessible name is what screen readers announce on focus —
separating the two unblocks the keyboard-only workflow.
tests/features/a11y_chrome_names/ — eight invariants spanning
the contract: T1–T4 each TitleBar button has the right
accessibleName + non-empty accessibleDescription; T5/T6 the
CommandPalette input + list have theirs; T7 the AT-SPI
introspection lane (every reachable QAbstractButton carries
either a non-empty accessibleName() or a non-empty text(),
every reachable QLineEdit has an explicit accessibleName),
T8/T9 source-grep counts on titlebar.cpp and commandpalette.cpp
so a future refactor that drops the setAccessibleName calls
fails the build, not just AT-SPI usage at runtime. The test
constructs the TitleBar and CommandPalette directly under
QT_QPA_PLATFORM=offscreen so it runs in CI without a display.tr() translation hooks for the new strings are deferred to the
0.9.0 H10 i18n bundle so the .qm files cover both UI text and
accessibility strings in one pass — pulling them in now would mean
re-touching every setAccessibleName(...) call later.QAccessibleInterface adapter for TerminalWidget
itself (the text-changed event lane behind the H9 a11y bundle in
0.9.0) is a separate, larger design cycle and is out of scope
for this bundle.0.7.40 — Perf bundle — scroll-region rotate + VtBatch zero-copy
Theme: Performance — scroll-region rotate + VtBatch zero-copy
across the worker→GUI thread hop. Two Tier 3 perf items from
ROADMAP.md § 0.7.x ⚡ / 🏗 Tier 3 — structural retired in one
bundle. Both are observably equivalent at the API level — the
existing tests/features/scroll_region_rotate/ and
tests/features/threaded_parse_equivalence/ regressions pass
unchanged — so the win is throughput / cost, not visible behaviour.
TerminalGrid::scrollUp / scrollDown use std::rotate.
Replaced the per-iteration m_screenLines.erase + insert loop
with a single rotate over [scrollTop, scrollBottom], gated by a
pool-salvage / scrollback-push pre-pass and a fresh-blank
post-pass. The CSI 2J doubling-guard window check now runs once
per batch (elapsed time across a batch is microseconds, so the
per-iteration window-extend was redundant). m_scrollback cap
enforcement collapses from N pop_front passes to one. On CSI 100 S against an 80-row screen, the row-shift cost goes from
O(count × rows) memmoves (8000) to O(rows) (80).
VtStream::batchReady carries VtBatchPtr instead of
const VtBatch &. using VtBatchPtr = std::shared_ptr<const VtBatch>; is the new alias in vtstream.h. Qt's queued-
connection plumbing must own the parameters it dispatches, so
the prior const T & signal forced a deep copy of the entire
batch (the actions vector + rawBytes QByteArray) on every
worker→GUI hop, regardless of any move-from-pending shaping the
emitter performed. The shared_ptr wrap reduces the cross-thread
payload to a 16-byte atomic refcount bump; the underlying
VtBatch lives on the heap and is not duplicated. Both emit
sites (flushBatch, onPtyFinished) build via
std::make_shared<VtBatch>(). TerminalWidget::onVtBatch
signature changed to void onVtBatch(VtBatchPtr batch); field
access is now via batch->… instead of batch.….
tests/features/vtbatch_zero_copy/ — 5 source-grep invariants
locking the cross-thread signal shape so a future refactor
can't silently revert to the deep-copy form. Behavioural
equivalence on the action stream is already covered by
tests/features/threaded_parse_equivalence/.
tests/features/scroll_region_rotate/ — pre-existing 8
invariants on rotation correctness (I1–I8 in spec.md) still
pass. The spec was written algorithm-agnostic, anticipating this
swap.
0.7.39 — Claude state-dot palette + status-bar Roadmap viewer
Theme: Claude Code UX bundle + status-bar Roadmap viewer. Two user requests landed in quick succession.
User request 1 (Claude state-dot palette):
"Let's have a round dot on each tab that has a Claude Code session running (no icons or anything else other than the tab label). The dot will change colour with the various states that Claude Code is in. Each state has its own colour (grey for idle). Then extend those colours to the status bar Claude Code status too."
User request 2 (Roadmap viewer):
"Add a button on the status bar to view the roadmap. So, it brings up a dialog showing the roadmap. It should have filters as well to show what is outstanding and what is completed. If at all possible, it should also highlight what item is being done currently. The roadmap button should only show if there is roadmap documentation — let's simplify that to requiring a roadmap.md file only." Plus a clarification adding a fourth emoji toggle and elevating "Currently being tackled" to a peer filter.
Unified Claude state-dot palette across tabs and status bar.
New static helper ClaudeTabIndicator::color(Glyph) in
coloredtabbar.h is the single source of truth for an
eight-state palette: Idle grey #888888, Thinking blue
#5BA0E5, ToolUse yellow #E5C24A, Bash green #6FCF50,
Planning cyan #5DCFCF, Auditing magenta #C76DC7, Compacting
violet #A87FE0, AwaitingInput orange #F08A4B. Red is
intentionally absent — AwaitingInput is a normal interaction
state, not an error. ColoredTabBar::paintEvent calls the
helper for fill colour and uses a single kDotRadius = 4 for
every state; the prior AwaitingInput "outline + radius 5"
treatment is removed (per "no icons or anything else"). The
bottom status-bar Claude Code label was rewired to map current
state → Glyph → helper colour, replacing the prior
Theme::ansi[N] mappings that drifted from the tab dot's
colour and varied across themes.
Auditing now lights the per-tab dot. Previously surfaced
only on the active-tab status bar (m_claudeAuditing). Plumbed
into ClaudeTabTracker::ShellState::auditing (mirrored from
the existing ClaudeTranscriptSnapshot.auditing field). Tab
provider routes it to Glyph::Auditing magenta; hover tooltip
reads "Claude: auditing". Precedence chain unchanged across
both surfaces:
AwaitingInput → Planning → Auditing → state-derived.
Status-bar Roadmap viewer button (roadmapdialog.{h,cpp},
new files). Visible iff the active tab's shellCwd()
contains a ROADMAP.md (case-insensitive — accepts
Roadmap.md, roadmap.md too). MainWindow:: refreshRoadmapButton is called from the central
refreshStatusBarForActiveTab tick. Click opens
RoadmapDialog: a non-modal QDialog with a top row of five
peer category checkboxes (✅ Done · 📋 Planned · 🚧 In progress ·
💭 Considered · Currently being tackled), all default-checked.
A bullet renders iff any of its enabled category memberships
matches; plain narration bullets (no status emoji) always
render. The static RoadmapDialog::renderHtml helper is pure
so tests can drive it without spinning a Qt widget.
"Currently being tackled" highlight. A bullet matches the
current-work signal if its first-80-character payload (status-
emoji-stripped, normalised — lowercase, hyphens-and-underscores
as spaces, punctuation removed) contains a phrase from a signal
set built from (a) the local CHANGELOG.md [Unreleased]
block bullets, (b) the last 5 non-release/non-merge/non-revert
git commit subjects. Matched bullets get a yellow left-border
CSS highlight (border-left: 4px solid #E5C24A — the same
ToolUse yellow from the dot palette; the consistency is
intentional). When the signal set is empty, no highlight
renders — the feature is silent when there's nothing to point
at.
Live-tail dialog mechanics. QFileSystemWatcher on
ROADMAP.md and CHANGELOG.md, 200 ms debounce timer, and
the same scroll-preservation triple shipped with 0.7.37 /
0.7.38: m_lastHtml shared_ptr cache + skip-identical-HTML
guard, capture vbar before setHtml, restore with
qMin(saved, vbar->maximum()) clamp, was-at-bottom pin so
live appends stay visible.
ClaudeTabIndicator::Glyph enum gains Auditing. Inserted
between Planning and Compacting so the palette ordering
reads Done-side → Action-side → InFlight-side → BlockingUser.
ClaudeTabTracker::ShellState gains a bool auditing field
and a new comparison branch in maybeEmit so changes to the
audit latch trigger shellStateChanged like every other
field.
MainWindow::applyClaudeStatusLabel no longer reads
Theme::ansi[] for Claude-label colours. All state →
colour mappings now route through the unified helper. The
Theme &th = Themes::byName(...) local was removed (unused).
radius = 5 and
white outline). Colour alone is the differentiator now, per
the "no icons or anything else" user spec. The
outline.alpha() branch was removed from paintEvent.tests/features/claude_state_dot_palette/ — 8 source-grep
invariants asserting helper signature, full palette,
paintEvent helper-call, uniform geometry, mainwindow
status-bar wiring, Auditing plumbing through ShellState,
and double-use of the Auditing glyph (provider closure +
status applier).
tests/features/roadmap_viewer/ — 10 hybrid invariants
(source-grep + behavioral). Links the dialog source so the
static renderHtml helper can be driven against synthetic
markdown to verify five-bit filter semantics, the highlight
CSS marker on signal match, the marker's absence on empty
signal sets, the case-insensitive cwd probe, the wire-up
shape (m_roadmapBtn construction + click connection), and
the refreshRoadmapButton call from
refreshStatusBarForActiveTab.
/tmp/*.js TOCTOU and the
no-auth local-IPC chain — both shipped in the 0.7.12 Tier 1
batch but the umbrella narrative entries weren't updated at
the time).0.7.35 — About dialog OK button fix — QDialog + connected QDialogButtonBox
Theme: UX bug fix. Single user-reported regression in the Help → About Ants Terminal dialog (the GUI version indicator added in 0.7.22).
About Ants Terminal dialog OK button silently no-op'd
(mainwindow.cpp). Was: Help → About Ants Terminal… opened
the version dialog correctly, but clicking its OK button did
nothing — the dialog could only be dismissed via the
window-manager close (X) button. The 0.7.22 implementation
used QMessageBox::Ok with Qt::TextBrowserInteraction (which
pulls in Qt::TextSelectableByMouse); under the combination of
our frameless MainWindow, Qt::WA_TranslucentBackground, KDE
Plasma + KWin, and Qt 6.11 this caused the OK click to be
silently dropped. Now: the About handler builds a custom
QDialog with a QDialogButtonBox(QDialogButtonBox::Ok) whose
accepted signal is explicitly connected to QDialog::accept,
giving us a click path that's standard, testable, and
independent of QMessageBox's internal standard-button dispatch.
About dialog GitHub link was a visual-only no-op
(mainwindow.cpp). Was: the link in the About body was
rendered as clickable (cursor changed on hover) because
Qt::TextBrowserInteraction enables Qt::LinksAccessibleByMouse,
but setOpenExternalLinks(true) was never called on the label
— so clicking the link emitted linkActivated() into the void.
Now: the body QLabel has setOpenExternalLinks(true), so
clicking the GitHub URL opens it in the user's browser.
tests/features/help_about_menu/spec.md + test (8
invariants). Spec rewritten to lock the QDialog +
QDialogButtonBox shape, document the 2026-04-25 regression in
the History section, and assert via source-grep that the
QMessageBox::Ok + Qt::TextBrowserInteraction pattern
cannot reappear without the test failing. Pre-fix source
fails 6 of the 8 new I4 invariants; post-fix all 8 pass.0.7.38 — Background-tasks status-bar button — Claude Code live tail
Theme: Feature. Background-tasks status-bar surface for Claude Code. User request 2026-04-25:
"a button on the status bar when there are background tasks being run. We then click the button to view what Claude Code shows for the background tasks. The button opens a dialog showing the live update info on the background tasks."
Claude Code can spawn long-running tasks via Bash or Task with
run_in_background: true. The TUI shows a sidebar listing them with
their tail output; users dropping into a different tab to keep
working had no way to see those tasks from Ants Terminal — they had
to flip back to the TUI and parse the sidebar by hand. The new
status-bar button surfaces the live count and the dialog mirrors the
0.7.37 Review Changes update model (debounced live tail with
scroll-preservation).
Background-tasks tracker (new claudebgtasks.{h,cpp}). A pure
parser walks the active session's transcript JSONL, recognizing
tool_use blocks whose input.run_in_background == true (start)
and tool_result blocks whose toolUseResult.backgroundTaskId is
set (confirmation, with the on-disk output path extracted from
the result body). Completion / kill state is derived from
subsequent BashOutput results carrying status: "completed" | "killed" | "failed" and from KillShell tool calls. The
ClaudeBgTaskTracker class wraps the parser with a
QFileSystemWatcher on the transcript and emits tasksChanged()
when the running-count or shape changes. Static
parseTranscript(path) is testable without a watcher.
Status-bar button (mainwindow.{h,cpp}). Sibling to the
Review Changes button; same fixed sizePolicy contract. Hidden by
default; visible only while runningCount() > 0 for the active
tab's session. Label re-renders as "Background Tasks (N)";
tooltip discloses running + total. Re-targeted on tab switch via
refreshBgTasksButton() (called from
refreshStatusBarForActiveTab), so each tab's session drives its
own count independently.
Live-tail dialog (new claudebgtasksdialog.{h,cpp}). Mirrors
the Review Changes live-update model that 0.7.37 stabilized.
QFileSystemWatcher covers each task's .output file plus the
transcript path, so new starts and completions appear without
manual refresh. A 200 ms debounce timer collapses bursts of
fileChanged signals (common when the backgrounded process is
noisy) into one render. Skip-identical-HTML guard via a per-
dialog m_lastHtml (std::shared_ptr<QString>) preserves
selection and scroll position when the render is unchanged.
Capture-vbar/hbar before setHtml, restore after with qMin(..., maximum()) clamp keeps absolute scroll position when content
changes — same shape as the 0.7.37 Review Changes fix. Bonus
"was at bottom" detection: when the user is tailing the bottom,
pin them to the bottom across appends so live output stays
visible without manual scrolling.
tests/features/claude_bg_tasks_button/spec.md + test
(10 invariants). New regression test pinning the parser shape
(run_in_background and backgroundTaskId keys), the tracker's
QFileSystemWatcher + tasksChanged() signal, the button's
hide-when-empty contract, the connect to showBgTasksDialog,
the dialog's reuse of the 0.7.37 scroll-preservation pattern,
the outputPath watch enumeration in rewatch(), the debounce
timer interval (≤ 500 ms, single-shot), and the CMake source
list. Source-grep harness, no Qt link.0.7.37 — Review Changes dialog — scroll preserved across live refreshes
Theme: UX bug fix. Live-update regression in the Review Changes dialog (the QFileSystemWatcher + 300ms debounce wiring added in 0.7.32). User report 2026-04-25:
"When using the Review Changes dialog, the constant resetting of the text means that if I scroll, it resets to the beginning every refresh. That means I can't scroll basically."
The 0.7.32 finalize lambda called viewerGuard->setHtml(html)
unconditionally on every probe completion — every git change, every
debounce tic, every Refresh click. QTextEdit::setHtml re-parses
the document and snaps the vertical scroll bar back to the top, also
discarding selection and cursor position. On a long diff with active
live updates the dialog became unscrollable.
mainwindow.cpp). Two-layer guard around the
setHtml call inside MainWindow::showDiffViewer's finalize
lambda. (1) An auto lastHtml = std::make_shared<QString>() cache
threaded through runProbes and finalize lets finalize early-
return when the new render is byte-identical to the previous one
— the common case during idle live-update tics, where branch
metadata refreshes don't change anything visible. Selection,
cursor, and scroll are byte-perfectly preserved. (2) When content
does change, vertical and horizontal scroll-bar values are
captured before setHtml and restored after, clamped to
bar->maximum() so a shorter render after a commit doesn't over-
scroll. First-render carve-out (isFirstRender = lastHtml->isEmpty()) keeps the initial paint at the top.tests/features/review_changes_scroll_preserve/spec.md + test
(6 invariants). New regression test pinning the lastHtml
cache, the skip-identical guard, the
capture-scroll-before-setHtml + restore-after pattern, and the
first-render carve-out. Source-grep harness scoped to the
MainWindow::showDiffViewer body so other QScrollBar / setHtml
call-sites in mainwindow.cpp don't cause false positives.0.7.36 — Tab bar + status bar opaque under translucent parent
Theme: UX bug fix. Same translucent-parent failure mode that bit the menubar in 0.7.25/0.7.26 caught in two more places (user report, post-0.7.32):
"The tabs themselves are fine but the rest of the tab bar across the window is transparent as well as the background for the status bar."
The 0.7.32 close-button SVG drew the user's eye to the empty-area fill that had been mis-painted all along; the bug was not introduced by 0.7.32 but became visible because the new tab look re-balanced what the user noticed.
Tab bar empty area paints opaque under
WA_TranslucentBackground (coloredtabbar.{h,cpp}). Was: the
strip to the right of the last tab rendered the desktop wallpaper
through, with the QSS QTabBar { background-color: ... } rule
silently dropped by Qt's stylesheet engine on KWin + Breeze + Qt 6
once WA_OpaquePaintEvent was set on the widget. Same root cause
documented in opaquemenubar.h. Now: ColoredTabBar exposes
setBackgroundFill(QColor) and prepends a
CompositionMode_Source fillRect to its existing paintEvent,
before the base class draws tabs and before the colour-group
gradient overlay. applyTheme() pushes theme.bgSecondary into
the override.
Status bar background paints opaque under
WA_TranslucentBackground (new opaquestatusbar.h,
mainwindow.{h,cpp}). Was: the entire status bar strip rendered
the desktop wallpaper through — same root cause as the tab bar
above. Now: a header-only OpaqueStatusBar mirrors
OpaqueMenuBar (paintEvent fillRect → delegate). MainWindow
constructs an OpaqueStatusBar, installs it via setStatusBar(...)
before the first statusBar() call (Qt's lazy-creation would
otherwise install a plain QStatusBar that paints transparent),
and pushes theme.bgSecondary via setBackgroundFill from
applyTheme().
tests/features/tabbar_statusbar_opaque/spec.md + test (5
invariants). New regression test pinning the setBackgroundFillfillRect mechanism on both bars, the setStatusBar install
order, the WA_OpaquePaintEvent attribute on the tab bar, and the
paint order inside ColoredTabBar::paintEvent (fill before the
base class draws tabs). Source-grep harness, no display required.0.7.34 — Terminal correctness — DECOM CUP/HVP/VPA + DECSC saves origin
Theme: Terminal correctness. One ROADMAP item retiring the last
known DECOM (origin mode) bug — tmux/screen save-restore
round-trips were silently dropping the origin-mode flag and
ignoring scroll-region-relative coordinates on absolute cursor
moves.
CUP / HVP / VPA translate to scroll-region origin under DECOM
(terminalgrid.cpp). Was: with origin mode (DECOM, CSI ?6h)
active and a scroll region set via DECSTBM (CSI top;bottom r),
CSI 1;1H jumped to absolute (0, 0) and CSI 99;1H clamped to
the bottom of the entire screen instead of the bottom of the
scroll region. Programs that depend on origin-mode-relative
positioning (vim split-window cursor restoration, screen's
caption line, tmux status redraws) ended up writing to the
wrong row. Now: the 'H', 'f', and 'd' cases inside
processCSI add m_scrollTop to the requested row when
m_originMode is set, then clamp to [m_scrollTop, m_scrollBottom].
Behaviour matches xterm.
DECSC saves DECOM + DECAWM, DECRC restores them
(terminalgrid.cpp, terminalgrid.h). Was: saveCursor()
stored only (row, col, attrs), so a TUI that flipped origin
mode or auto-wrap, did CSI s / ESC 7, ran code that
re-toggled the flag, then CSI u / ESC 8'd ended up with the
flag in whatever state the inner code left it in — silent
coordinate-space corruption. Now: m_savedOriginMode and
m_savedAutoWrap members capture the flags on save and restore
them on restore, matching the VT420 spec for DECSC / DECRC.
DECSTBM home position respects DECOM (terminalgrid.cpp).
Was: setScrollRegion() always homed the cursor to absolute
(0, 0) after setting the region. Now: when m_originMode is
on at the time DECSTBM lands, the cursor moves to
(m_scrollTop, 0) — the top-left of the origin-mode
coordinate space — as xterm does.
Locked by tests/features/origin_mode_correctness/ — 12
behavioural invariants (CUP/HVP/VPA translation, scroll-region
clamping, DECSTBM home, DECSC/DECRC save-restore in both CSI s/u
and ESC 7/8 forms, DECAWM round-trip via the wrap-or-not at
last-column probe) plus 4 source-grep checks anchoring the fix's
shape (std::clamp(row, m_scrollTop, m_scrollBottom) near case 'H', m_savedOriginMode = m_originMode in saveCursor, the
mirror line in restoreCursor, and the m_originMode ? m_scrollTop : 0 ternary in setScrollRegion).
0.7.33 — Lifecycle/cleanup — PTY dtor off-thread + portal session close + plugin manifest safety
Theme: Lifecycle / cleanup. Three ROADMAP items addressing GUI-thread blocking on shutdown, an xdg-desktop-portal session leak, and two latent issues (OOM surface + symlink escape) in the Lua plugin loader.
PTY destructor escalation runs off the main thread
(Pty::~Pty). Was: SIGTERM-then-busy-wait-then-SIGKILL ran on
the GUI thread; N split panes closing together blocked the
window close N × 500 ms. KWin would throw a "window not
responding" hint at four splits. Now: the destructor still
does the cheap pre-escalation reap (SIGHUP + close master fd +
waitpid(WNOHANG)) inline — most shells exit on SIGHUP in
microseconds. If the cheap reap doesn't take, the
SIGTERM/SIGKILL escalation moves to a detached std::thread
capturing the pid by value (no this reference can outlive
the destructor). The thread creation is wrapped in a
try { ... }.detach() } catch (const std::system_error &)
that falls back to the synchronous escalation when thread
creation fails (rare — ulimit -u pressure at exit). Locked
by tests/features/pty_dtor_off_main_thread/ — 11 invariants
on <thread> include, lambda capture list (rejects [this]
and [&]), .detach() call, fallback retention, and the
pre-escalation cheap reap remaining inline.
GlobalShortcutsPortal closes its session on destruction
(GlobalShortcutsPortal::~GlobalShortcutsPortal). Was: no
destructor at all — the session handle returned by
CreateSession leaked for the lifetime of the D-Bus client.
xdg-desktop-portal accumulated one orphan session per Ants
invocation that crashed or was SIGKILLed before the QObject
parent-tree cleanup could implicitly close the bus connection;
visible via busctl --user introspect org.freedesktop.portal.Desktop .... Now: the destructor
issues an asynchronous
org.freedesktop.portal.Session.Close call against
m_sessionHandle when non-empty (early-returns on empty
handle so X11 / GNOME / no-portal paths don't crash Qt's
D-Bus marshaller). New kSessionIface constant in the
anonymous namespace alongside the existing
service/path/interface constants. Locked by
tests/features/portal_session_close/ — 8 invariants on
header dtor declaration with override, kSessionIface
constant, empty-handle early return, and the
asyncCall(createMethodCall(..., kSessionIface, "Close"))
dispatch.
Plugin manifest cap + canonical plugin path
(PluginManager::scanAndLoad). Two latent issues addressed
together. Manifest cap: f.readAll() was unbounded — a
multi-GB manifest.json (corrupt disk, malicious tarball)
would allocate that much RAM before QJsonDocument::fromJson
got a chance to reject it. Now: f.read(kMaxManifestBytes)
with kMaxManifestBytes = 1024 * 1024 (1 MiB ≈ 250 plugins
worth of permission/description text — real manifests are
<10 KiB, so the cap never bites legitimate content). Files
larger than the cap log a warning and skip without parsing.
**Canonical plugin path:** the scan now anchors on
QFileInfo(m_pluginDir).canonicalFilePath(), passes
QDir::NoSymLinks to entryList (cheap first-pass filter),
and per-entry verifies the resolved path is anchored inside
the canonical root via
startsWith(canonicalRoot + "/"). Closes the symlink-escape
shape where a hostile plugin tarball containing
evil -> /etc/cron.daily could trick the loader into
attempting init.lua from outside the user's plugin tree.
Locked by tests/features/plugin_manifest_safety/ — 12
invariants on the cap (named constant + value + bounded read
0.7.32 — Dialog UX — Settings gating/rollback/defaults + Review Changes branches+live + tab × always visible
Theme: Dialog UX. Three bundle items from the Settings dialog ROADMAP list (dependency-UI gating, Cancel rollback for Profiles, Restore Defaults per-tab) plus one user feedback item from 2026-04-25 — the Review Changes dialog only surfaced current- branch state and missed work living on other branches.
Restore Defaults button per primary Settings tab
(SettingsDialog::setupGeneralTab, setupAppearanceTab,
setupTerminalTab, setupAiTab). Was: only Keybindings had
a defaults-reset button. A user who tweaked the dialog and
wanted to start over had to either remember every default or
delete ~/.config/ants-terminal/config.json (which lost
unrelated settings: highlight rules, profiles, plugin grants).
Now: each substantive tab has its own button with stable
objectName (restoreDefaultsGeneral, restoreDefaultsAppearance,
restoreDefaultsTerminal, restoreDefaultsAi). Reset slots
mutate widget state only — applySettings still commits, Cancel
still rolls back. Schema defaults match the second argument of
each Config::xxx() getter so the dialog and config layer can't
drift. Locked by tests/features/settings_restore_defaults/
— 22 invariants on objectNames, reset-value coverage per tab,
and the "no m_config-> writes from within reset slots" rule.
Review Changes dialog: live updates via QFileSystemWatcher +
manual Refresh button (MainWindow::showDiffViewer). User
feedback 2026-04-25: dialog should show live or near-live
updates. Was: probes ran once on dialog open and never again —
the user had to close and re-open to see changes from a commit
/ fetch / branch operation done in the terminal underneath. Now:
a QFileSystemWatcher watches cwd, .git, .git/HEAD,
.git/index, .git/refs/heads, .git/refs/remotes,
.git/logs/HEAD. fileChanged and directoryChanged signals
feed a 300 ms single-shot QTimer (debounce — git pull /
git fetch fire fileChanged O(refs) times in milliseconds; the
debounce coalesces them into one re-probe). The probe-spawning
logic was refactored into a runProbes lambda that constructs a
fresh ProbeState per call, so an in-flight probe whose finalize
outlives the next refresh can't decrement the new pending counter
and render a half-populated mix. Atomic-rename safe: the
fs-event handler re-adds paths that exist but are no longer
watched (Qt loses the watch on rename(2), which git uses for
HEAD/index/logs/HEAD updates). A live-status label
(reviewLiveStatus objectName) shows "● refreshing…" /
"● live — auto-refresh on git changes" so the user can confirm
the watcher is wired. A manual Refresh button
(reviewRefreshBtn objectName) bypasses the debounce for cases
where state changed outside the watched paths (a build script
in another shell, a different terminal). Locked by
tests/features/review_changes_branches/ (extended) — now 33
invariants total covering ProbeState fields, runAsync targets,
finalizer rendering, copy-handler payload, empty-state guard,
runProbes lambda, watcher armament, debounce timing, atomic-
rename re-watch, refresh-button bypass, and live-status label
states.
Review Changes dialog: per-branch summary + cross-branch
unpushed commits (MainWindow::showDiffViewer,
MainWindow::ProbeState). Was: three async git probes
(status, diff HEAD, log @{u}..HEAD) all scoped to the
current branch's working tree and HEAD lineage. A user with
five feature branches each holding unpushed work saw "no
unpushed" if they happened to be on a clean branch. Branches
without upstreams or with diverged ahead/behind state were
invisible. User feedback 2026-04-25: "the Review Changes
dialog doesn't consider changes in other branches of the
project." Now: two additional probes drop in alongside the
existing three —
git for-each-ref refs/heads --format='%(refname:short) \t%(upstream:short)\t%(upstream:track)\t%(subject)' for the
per-branch summary (with ahead/behind/gone/no-upstream colour
cues), and git log --branches --not --remotes --oneline --decorate for the cross-branch unpushed log (every commit
reachable from any local branch but not from any remote-
tracking branch). Both are O(refs) and finish in milliseconds.
Copy Diff includes both new sections. Locked by
tests/features/review_changes_branches/ — 15 invariants on
ProbeState fields, runAsync targets, finalizer rendering,
copy-handler payload, and empty-state guards.
Tab close button (×) is always visible, not hover-only
(MainWindow::applyTheme stylesheet —
QTabBar::close-button). User feedback 2026-04-25: "the
tabs still don't have a visible marker per tab that shows
where to click to close the tab. The mouseover works but we
need to also see it when onmouseout." The 0.6.27 fix removed
image: none to let Qt fall back to the platform's standard
close icon — that worked on Breeze/Adwaita but failed on
Fusion / qt6ct / certain Plasma colour schemes where the
platform style still rendered the × hover-only. Now: explicit
data-URI SVG image: url("data:image/svg+xml;...") rules in
both the default and :hover QTabBar::close-button variants.
Glyph re-tints with the active theme via textSecondary
(default) / textPrimary (hover); hover keeps the ansi-red
background-color will-click cue. URL-encoded %23 is
spliced into the arg list via QStringLiteral("%23") + theme.<color>.name().mid(1) rather than the format string —
prevents Qt's CSS parser from truncating the data URI at the
fragment delimiter and also avoids the QString::arg()
placeholder-numbering collision. Locked by
tests/features/tab_close_button_visible/ — 11 invariants on
data-URI presence, two-line × shape, arg-side splice, and
image-rule presence in BOTH state variants.
Dependency-UI enable gating (SettingsDialog::setupAppearanceTab,
setupTerminalTab, setupAiTab). Was: master checkboxes
(m_aiEnabled, m_autoColorScheme, m_quakeMode) gated logic
but not UI — typing an API key into a feature-disabled AI tab,
or selecting "Solarized" as the light-mode theme while
auto-switch was off, both produced silent no-ops. Now:
QCheckBox::toggled is wired to setEnabled on every
dependent sibling, with a one-shot sync call at construction
so the initial state matches the loaded config without
relying on setChecked() always emitting toggled (it only
emits when state actually changes). Disabled controls keep
their current values, so toggling the master back on restores
the user's prior selection rather than zeroing it out. Locked
by tests/features/settings_dependency_gating/ — 16
invariants on the three sync lambdas, dependent setEnabled
calls, toggled-connect wiring, and initial-sync call sites.
Profiles tab honors Cancel/OK semantics
(SettingsDialog::setupProfilesTab, loadSettings,
applySettings, m_pendingProfiles, m_pendingActiveProfile).
Was: profile Save/Delete/Load buttons mutated m_config
immediately via setProfiles() / setActiveProfile(). Cancel
could not roll those edits back — they had already been
persisted to config.json before the user's intent was known.
Every other Settings tab follows the standard "stage in
widgets, commit on applySettings, discard on reject" pattern;
Profiles broke that contract. Now: the three buttons mutate a
pending-state pair (m_pendingProfiles + m_pendingActiveProfile),
loadSettings re-initializes the pair from m_config, and
applySettings is the single commit point that calls
setProfiles / setActiveProfile. Cancel skips applySettings,
so dialog close leaves m_config unchanged. Locked by
tests/features/settings_profile_cancel_rollback/ — 11
invariants including a global "exactly one m_config->setProfiles
call site" check that catches a regression where a button
callback starts writing to m_config directly again.
0.7.31 — Persistence integrity — concurrent-writer guard + post-rename chmod + secureio.h split
Theme: Persistence integrity (cross-file). Four items from the
post-0.7.30 grouping plan, addressing the silent-data-loss /
permission-drift / concurrent-writer surfaces that span Config,
ClaudeAllowlist, SessionManager, and SettingsDialog. Splits the
growing secureio.h into secureio.h (perms) + configbackup.h
(rotation + cooperative write lock) before a third helper landed.
ConfigWriteLock cooperative write lock for shared config files
(src/configbackup.h). Was: two simultaneously-running Ants
instances saving the same ~/.config/ants-terminal/config.json
raced over <path>.tmp + rename(2) — both writers truncated the
shared tmp, partial bytes interleaved on the same inode, and
last-rename-wins silently dropped one process's keystrokes /
settings / profile / AI key. Same shape for ~/.claude/settings.json
where Allowlist + Install-hooks + git-context-installer all
read-modify-write with no serialization. Now: an RAII guard wraps
POSIX flock(2) on a sibling <path>.lock file with a 5-second
poll deadline (100 × 50 ms), advisory so cooperating callers
serialize while non-cooperating editors (vim, jq) bypass by
design. Config::save, ClaudeAllowlistDialog::saveSettings,
SettingsDialog::installClaudeHooks, and
SettingsDialog::installClaudeGitContextHook now construct
ConfigWriteLock writeLock(path) with an acquired() guard
before the write block; failure to acquire logs and returns
rather than risking the data race. Locked by
tests/features/concurrent_writer_lock/ (5 runtime invariants
via fork(2) so flock semantics are honestly tested across
processes, plus 7 source-grep invariants on each save site).
Pre-fix source fails 7; post-fix all 12 pass.
Belt-and-suspenders post-rename setOwnerOnlyPerms at every
persistence site (Config::save, SessionManager::saveSession,
SessionManager::saveTabOrder, SettingsDialog::installClaudeHooks,
SettingsDialog::installClaudeGitContextHook). Was: each site
set 0600 on the temp fd before write but relied on rename(2)
preserving perms across the swap. ext4/xfs/btrfs honor that, but
FAT/exFAT on removable media (no POSIX bits at all), some
SMB/NFS servers (server-side rename applies server umask), and
Qt's copy+unlink fallback (cross-device rename, exotic mounts —
copy creates the destination with the process umask) do not.
Files containing ai_api_key (config.json), Claude Code bearer
tokens (settings.json), or paste-buffer scrollback content
(session_*.dat) could land 0644 on those filesystems and leak to
every UID on the host. Now: each site re-chmods the final
inode after rename/commit() returns success — idempotent on
POSIX filesystems, essential elsewhere. Locked by
tests/features/persistence_post_rename_chmod/ (10 source-grep
invariants spanning config.cpp / sessionmanager.cpp /
settingsdialog.cpp). Pre-fix source fails 6; post-fix all 10
pass.
secureio.h / configbackup.h split. secureio.h is now
perms-only — setOwnerOnlyPerms(QFileDevice&) and
setOwnerOnlyPerms(const QString&), the original 0600-bitmask
helpers that ~12 callers reach for. configbackup.h is the new
home for rotateCorruptFileAside (silent-data-loss recovery,
added 0.7.12) and the new ConfigWriteLock (concurrent-writer
guard, added this release). The split preempts a third-helper
cliff: the file was straddling perms + recovery + lock concerns
and a fourth would have made the include cost-of-business across
the codebase. Each downstream caller now picks the include it
actually needs (config.cpp, claudeallowlist.cpp,
settingsdialog.cpp pull both; sites that only set perms keep
their secureio.h include). Locked by
tests/features/secureio_configbackup_split/ (13 invariants:
file-content boundaries, non-copyable lock, every caller's
include set). Pre-fix source fails 4; post-fix all 13 pass.
Config::load, ClaudeAllowlistDialog::saveSettings,
and SettingsDialog::install{ClaudeHooks,ClaudeGitContextHook} —
but only Config had a regression test
(tests/features/config_parse_failure_guard/). The other two
were locked only by adjacent grep-style asserts inside other
feature tests. Now they have their own dedicated test:
tests/features/settings_parse_failure_mirror/ (8 invariants:
rotation call site, return-false-after-rotation gating,
open-failure branch distinct from parse-failure branch, comment
anchors explaining the clobber-risk reasoning). Closes the
ROADMAP "Allowlist + settings-dialog feature-test analogs" item.
Pre-fix source already passes — these tests lock previously
shipped behaviour against future regressions, not catch a new
bug.0.7.30 — Session-file integrity — SHA-256 envelope + qUncompress pre-flight + cell-loop status checks
Theme: Session-file integrity. Three ROADMAP § 0.7.12 Tier 2 items
shipped together from the post-0.7.27 grouping plan, all in
sessionmanager.cpp: a SHA-256 envelope around the qCompress payload,
a pre-flight on qCompress's 4-byte uncompressed-length prefix, and
per-cell QDataStream::status() checks inside the decode loops.
V4 SHA-256 envelope around session-file payload
(SessionManager::serialize, SessionManager::restore,
SessionManager::ENVELOPE_MAGIC,
SessionManager::ENVELOPE_VERSION). Was: session files at
$XDG_DATA_HOME/ants-terminal/sessions/session_<tabId>.dat were
raw qCompress output with no payload integrity. Anyone with
write access to that directory (the user's own UID, a compromised
local process, a runaway pip install post-exec, an npm
dependency) could plant a crafted session that fed arbitrary
codepoints, fg/bg colors, and attribute flags into the grid on
next restore — a render surface, not a sandbox. Now: serialize
wraps the qCompress output in a V4 envelope
[SHEC magic (0x53484543)][envelope version=1][SHA-256 of payload (32 bytes)][payload length (uint32)][compressed payload]; restore
peeks the magic, verifies the hash, and refuses to restore on
version mismatch, length disagreement, or hash mismatch. Inner
QDataStream format unchanged (still V3) — the integrity layer is
framing-only. Legacy V1-V3 files (no envelope) continue to load
via the magic-peek fall-through; their next save writes them out
as V4 organically. Regression test
tests/features/session_sha256_checksum locks four invariants —
serialize emits the envelope (INV-1), restore peeks the magic and
verifies the hash with a return-false on mismatch (INV-2),
ENVELOPE_MAGIC/ENVELOPE_VERSION declared on SessionManager with
the agreed-upon magic value (INV-3), envelope version remains 1
at this milestone (INV-4). Pre-fix source fails eight invariant
assertions; post-fix all four pass. ROADMAP § 0.7.12 Tier 2 entry
retired.
qUncompress length-prefix pre-flight
(SessionManager::restore, SessionManager::MAX_UNCOMPRESSED).
Was: a crafted file claiming 500 MB uncompressed in qCompress's
4-byte big-endian prefix triggered a 500 MB allocation that the
post-hoc raw.size() > 500MB cap could only catch after the
damage was done — and the on-disk payload could be tiny, so the
allocation pressure showed up with no concomitant disk-space
anomaly. Now: restore reads the first 4 bytes of the compressed
payload, reconstructs the big-endian uint32, and rejects any
claim above MAX_UNCOMPRESSED (500 MB) BEFORE qUncompress runs —
constant-time, no allocator pressure. Short-payload guard
(compressed.size() < 4) keeps the same path safe against
truncated inputs that can't carry a length prefix at all. The
post-decompression cap remains as a defense-in-depth backstop
against payloads that under-claim and over-deliver. Regression
test tests/features/session_qcompress_length_guard locks four
invariants — pre-flight reconstruction precedes qUncompress
(INV-1, INV-3), MAX_UNCOMPRESSED is the named constant (INV-2),
short-payload guard exists (INV-4). Pre-fix source fails four
invariant assertions; post-fix all four pass. ROADMAP § 0.7.12
Tier 2 entry retired.
QDataStream::status() checks inside cell-decode loops
(SessionManager::restore readCell / readCombining). Was:
the readCell lambda was void; a stream truncated mid-cell still
flowed default-constructed QRgb and uint8_t values through
QColor::fromRgba etc. into the cell, silently writing
uninitialized fg/bg/flags into the grid. The surrounding loops
didn't check per-iteration status either, so the grid kept
accepting cells from a stream already at ReadPastEnd. Now:
readCell returns bool and short-circuits on in.status() != QDataStream::Ok; every call site (scrollback cells, screen cells
in range, screen cells skipped on width shrink, screen cells
skipped on height shrink) is guarded by if (!readCell(...)) return false. The combining-character helper checks status after
each codepoint read, so a stream truncated mid-codepoint can't
push default-constructed 0 into the combining map either. Pre-
fix, a partial save (kernel crash mid-fsync, disk-full mid-write,
hostile sender truncating the envelope payload) could materialize
as garbage cells in the next restore — not a crash, but a
corrupted scrollback. Regression test
tests/features/session_cell_loop_stream_status locks three
invariants — readCell returns bool with status check (INV-1),
every call site uses if (!readCell(...)) and at least four
sites exist (INV-2), readCombining inner codepoint loop checks
status (INV-3). Pre-fix source fails three invariant assertions;
post-fix all three pass. ROADMAP § 0.7.12 Tier 2 entry retired.0.7.29 — Audit pipeline II (SARIF suppressions[] + regex-DoS watchdog + 96-bit dedup)
Theme: Audit pipeline II — output quality. Three ROADMAP § 0.7.12
items shipped together from the post-0.7.27 grouping plan, all in
auditdialog.cpp: SARIF v2.1.0 result.suppressions[] array, a
regex-DoS watchdog on user-supplied patterns, and a widened 96-bit
dedup key.
SARIF v2.1.0 result.suppressions[] array
(AuditDialog::exportSarif, AuditDialog::loadSuppressions,
AuditDialog::saveSuppression, m_suppressionReasons map,
Finding::suppressed). Was: SARIF export silently dropped
suppressed findings (those whose dedup key matched
~/.audit_suppress), producing a falsely-clean report that
defeated the suppression-trend telemetry already computed for
the in-app dashboard. Now: a parallel
QHash<QString, QString> m_suppressionReasons map populated
alongside m_suppressedKeys carries the user's free-text
reason from the JSONL file into memory; the parse pipeline
marks suppressed findings with Finding::suppressed = true
instead of dropping them; render paths (UI, HTML, plain-text)
continue to filter via isSuppressed; the SARIF export iterates
ALL findings and attaches a suppressions[] block (kind:
external, state: accepted, justification: the user's reason)
per SARIF v2.1.0 §3.34. GitHub Code Scanning / SonarQube /
VSCode SARIF Viewer now see the full audit picture and can
compute fires-vs-suppressions ratios across export boundaries.
Regression test tests/features/audit_sarif_suppressions locks
five invariants — map declaration (INV-1), loadSuppressions
populates + clears (INV-2), saveSuppression mirrors (INV-3),
exportSarif emits the suppressions JSON property with
external/accepted fields (INV-4), exportSarif no longer
drops on isSuppressed (INV-5). Pre-fix source fails seven
invariant assertions; post-fix all five pass. ROADMAP § 0.7.12
Tier 2 entry retired.
Regex-DoS watchdog on user-supplied audit patterns
(AuditDialog::isCatastrophicRegex,
AuditDialog::hardenUserRegex, applyFilter,
loadAllowlist). Was: user patterns from
audit_rules.json (OutputFilter::dropIfMatches) and
.audit_allowlist.json (AllowlistEntry::lineRegex) flowed
straight into QRegularExpression with no shape check and no
match-time bound. A pathological pattern committed in either
file could pin the GUI thread for seconds with classic
catastrophic backtracking on adversarial scanner output —
(.+)+, (\w*)*, (.*)+ against long lines. Now: a static
isCatastrophicRegex heuristic rejects nested-quantifier
shapes (a quantified group whose body itself contains a
quantifier) at compile time with a qWarning naming the
offending pattern; the rule continues to run without the
filter rather than refusing to load. Patterns that pass the
shape check are wrapped in PCRE2's (*LIMIT_MATCH=100000)
inline option via hardenUserRegex so even catastrophic
shapes that slip past the heuristic have a bounded match-step
budget — PCRE2 returns "no match" on overrun (fail-safe). 100
k steps handles every sane pattern (typical match completes
in < 1 k) and aborts adversarial patterns within
milliseconds. Regression test
tests/features/audit_regex_dos_watchdog locks four invariants
— the helper exists and is invoked at both user-pattern entry
points (INV-1), it recognizes nested-quantifier shapes
(INV-2), the LIMIT_MATCH=N cap is in the [1k, 1M] sane
range (INV-3), loadAllowlist emits a qWarning on rejection
(INV-4). Pre-fix source fails four invariants; post-fix all
four pass. ROADMAP § 0.7.12 Tier 2 entry retired.
computeDedup from 64 to 96 bits
(computeDedup, AuditDialog::isSuppressed,
Finding::dedupKey). Was: SHA-256 truncated to 16 hex chars
(64 bits) — same key serves as the SARIF partialFingerprint,
the suppression-JSONL key, the rule-quality bucket, and the
in-app "Suppress" anchor URL. The 64-bit collision threshold
(~2³² entries at 50 % collision probability) was comfortable
but tight given the multi-role usage; a single false-collision
cost a wrong-finding suppression. Now: .left(24) (96 bits)
raises the birthday threshold to ~2⁴⁸ for 8 extra bytes per
stored key — well past any plausible project's lifetime
collection. A new bool AuditDialog::isSuppressed(const QString &dedupKey) const helper encapsulates a backward-compat lookup:
match either the full 24-char key OR the leading 16-char prefix,
so existing pre-0.7.29 user ~/.audit_suppress entries
continue to suppress new 24-char findings without forcing a
migration. Six render-pipeline call sites that previously read
m_suppressedKeys.contains(f.dedupKey) now route through the
helper. Regression test tests/features/audit_dedup_96bit
locks four invariants — width ≥ 24 hex chars (INV-1),
isSuppressed exists with .left(16) legacy path (INV-2),
zero raw m_suppressedKeys.contains(f.dedupKey) sites
remain (INV-3), saveSuppression mirrors into
m_suppressionReasons (INV-4). Pre-fix source fails five
invariant assertions; post-fix all four pass.85/85 ctest green; drift check clean.
0.7.28 — Audit pipeline I (process-side robustness)
Theme: Audit pipeline I — process-side robustness. Three ROADMAP § 0.7.12 Tier 2 items shipped together as bundle 0.7.28 from the post-0.7.27 grouping plan: per-tool timeout overrides, incremental QProcess output drain with a 64 MiB cap, and a distinct tool-crash warning that no longer hides as "0 findings."
AuditCheck::timeoutMs field — per-check QProcess timeout
(AuditCheck, AuditDialog::runNextCheck, ctor lambda). Was:
m_timeout->start(30000) hard-coded inside runNextCheck, with
the timeout-handler lambda printing a literal "Timed out (30s)"
warning. Slow tools (cppcheck on a 500k-line tree, semgrep with
rule-pack compile, osv-scanner rate-limited by OSV.dev,
trufflehog over the full git history, clang-tidy / clazy on
Qt-heavy code) routinely exceeded the 30 s cap and got demoted
to tool-health warnings instead of producing findings. Now: a new
int timeoutMs = 30000; trailing field on the AuditCheck
aggregate (default preserves the pre-fix global so positional
call sites stay correct), m_timeout->start(check.timeoutMs) at
the use site, and the timeout-warning string formatted from the
actual cap. populateChecks ends with a calibration loop that
bumps known-slow tool IDs to 60 s (cppcheck, cppcheck_unused,
clang_tidy, clazy), 90 s (semgrep), or 120 s (osv_scanner,
trufflehog). Regression test tests/features/audit_per_tool_timeout
locks four invariants — the field declaration (INV-1), the
per-check use site with no hard-coded 30000 (INV-2), the
parameterised warning message (INV-3), at least one calibration
override above 30 s (INV-4). Pre-fix source fails all four;
post-fix all four pass. ROADMAP § 0.7.12 Tier 2 entry retired.
Incremental QProcess output drain with 64 MiB cap
(AuditDialog::onCheckOutputReady, AuditDialog::onCheckErrorReady,
AuditDialog::onCheckFinished). Was: m_process->readAllStandardOutput()
called exactly once inside onCheckFinished. Until that moment,
QProcess accumulated internal buffers without bound — a runaway
semgrep against generated code or a buggy plugin emitting a
tight printf loop could buffer hundreds of megabytes before the
timeout fired, with the audit dialog showing a frozen progress
bar the whole time. Now: the constructor wires
readyReadStandardOutput and readyReadStandardError to new
onCheckOutputReady / onCheckErrorReady slots that append
incrementally to m_currentOutput / m_currentError; if the
combined size exceeds MAX_TOOL_OUTPUT_BYTES = 64 * 1024 * 1024,
the process is killed and m_outputOverflowed is flagged. On
finished() the runner drains any tail data (Qt may buffer
between the last readyRead and finished()), reads from the
member buffers instead of the live process, and surfaces a
distinct "Output exceeded N MiB cap" warning when overflow
occurred. Buffers reset before each check so output never
concatenates across checks. A small connectProcessSignals()
helper centralises the three connections so the timeout-kill /
cancel-kill / reconnect cycles never lose a drain slot.
Regression test tests/features/audit_incremental_output_drain
locks six invariants — both readyRead* connections (INV-1),
buffer members declared (INV-2), MAX_TOOL_OUTPUT_BYTES cap in
the 4 MiB ≤ cap < 1 GiB defensible range (INV-3), per-check
reset (INV-4), onCheckFinished reads buffers not the live
process (INV-5), overflow path kills the process (INV-6).
Pre-fix source fails on the connect-grep alone; post-fix all six
pass. ROADMAP § 0.7.12 Tier 2 entry retired.
AuditDialog::onCheckFinished). Was: signature
void AuditDialog::onCheckFinished(int /*exitCode*/, QProcess::ExitStatus /*status*/) — both parameters discarded
via comments. A tool that segfaulted with empty stdout was
indistinguishable from a clean run with zero findings, silently
hiding both a real bug in the tool AND any findings the tool
would have reported. Same went for non-zero-exit-with-stderr-only
patterns (clang-tidy missing compile_commands.json, semgrep
failing to parse a rule). Now: parameters named (exitCode,
exitStatus); function branches on QProcess::CrashExit to
emit a "Tool crashed (signal exit)" warning; on exitCode != 0 && stdout empty && stderr non-empty to emit a "Tool exited N
with no findings on stdout" warning. Both warnings demoted to
Severity::Info so they don't sort to the top of the report
next to real findings. The four exit modes (timeout, overflow,
crash, non-zero-with-stderr-only) all funnel through a single
small file-scope makeToolHealthWarning() helper that
centralises the row shape (Info severity, warning flag, distinct
message prefix). Regression test
tests/features/audit_tool_crash_distinct locks four invariants
— named parameters (INV-1), CrashExit branch (INV-2), warning
emission with crash/exit-message (INV-3), Severity::Info
demotion (INV-4). Pre-fix source fails all four; post-fix all
four pass. ROADMAP § 0.7.12 Tier 2 entry retired.81/81 ctest green; drift check clean.
0.7.27 — PTY-handler hardening (close_range + EAGAIN write queue)
Theme: PTY-handler hardening sweep. Two ROADMAP § 0.7.12 Tier 2
items shipped together — child-side FD closure no longer relies on
a hard-coded fd<1024 cap, and master-side writes no longer drop
data on EAGAIN.
close_range(2) with RLIMIT_NOFILE-bounded
fallback (Pty::start). Was: for (int fd = 3; fd < 1024; ++fd) ::close(fd) — silently leaked any inherited FD with index
≥ 1024 into the user's shell on systemd-service / container /
hardened-server profiles where rlim_cur sits above the
hard-coded ceiling. Qt's display socket, D-Bus session
connection, plugin HTTP sockets, Lua VM eventfds, and the
remote-control IPC socket are all valid leak candidates; a
leaked AI HTTP socket is a credentials-exfiltration vector,
a leaked D-Bus socket lets the shell impersonate the user's
desktop session, a leaked remote-control socket is a UID-scope
RCE-by-proxy vector. Now: post-fork child branch issues
::syscall(SYS_close_range, 3, ~0U, 0) first — single
signal-safe syscall on Linux 5.9+, atomic over the whole
range, ignores the soft cap. If the syscall returns non-zero
(kernel < 5.9, missing build-time SYS_close_range), the
fallback path consults getrlimit(RLIMIT_NOFILE) and iterates
up to the runtime soft cap, capped at 65536 to bound the
worst-case syscall storm on profiles where rlim_cur is in
the hundreds of thousands. Two new headers
(<sys/resource.h>, <sys/syscall.h>) added to
ptyhandler.cpp. Regression test
tests/features/pty_closefrom locks five invariants —
SYS_close_range referenced inside the post-fork child
branch (INV-1), the hard-coded fd<1024 loop is gone (INV-2),
fallback consults RLIMIT_NOFILE (INV-3), required headers
included (INV-4), fallback bound is capped to 65536 to avoid
the unbounded-loop pathology (INV-5). Pre-fix source fails 4
of 5; post-fix all 5 pass. CWE-403 reference; ROADMAP § 0.7.12
Tier 2 entry retired.Pty::write queues on EAGAIN instead of dropping bytes
(Pty::write, new Pty::onWriteReady). Was: the master FD
is non-blocking, so a slow consumer on the slave side could fill
the kernel PTY buffer; on EAGAIN the write loop's else-clause
broke out with the comment // EAGAIN or fatal error, silently
dropping the unwritten remainder. Behaviourally invisible during
normal interactive use (the kernel buffer drains within
microseconds) but provoked by realistic bursts — large pastes,
AI-dialog command insertions, plugin-driven keystroke floods,
or any write into a slave whose reader is suspended. Now: a new
m_pendingWrite byte buffer and a QSocketNotifier( QSocketNotifier::Write) on m_masterFd (initially disabled
because PTY masters are writable nearly continuously and a
hot notifier would burn CPU). On EAGAIN the unwritten remainder
is moved to the queue and the notifier is enabled; when the
kernel signals writability, onWriteReady drains the queue and
disables the notifier on completion. Fresh write() calls
arriving while the queue is non-empty append rather than
bypass, preserving FIFO ordering so newer keystrokes never race
ahead of older ones. Queue capped at 4 MiB
(MAX_PENDING_WRITE_BYTES) — large enough for realistic bursts,
small enough that a permanently-stuck slave cannot OOM the GUI
process. Regression test tests/features/pty_write_eagain_queue
locks seven invariants — write-side notifier creation (INV-1),
queue member declared (INV-2), notifier pointer member declared
(INV-3), onWriteReady slot declared and connected (INV-4),
EAGAIN handled distinctly inside Pty::write (INV-5), 4 MiB
cap present (INV-6), direct-write path checks queue first for
FIFO ordering (INV-7). Pre-fix source fails 5 of 7; post-fix
all 7 pass. ROADMAP § 0.7.12 Tier 2 entry retired.78/78 ctest green; drift check clean.
0.7.26 — OpaqueMenuBar (root-cause fix for menubar transparency)
Theme: menubar opacity — the actual root-cause fix. 0.7.25's palette + widget-local-QSS belt-and-suspenders did not in fact suspend any belt: the user reported the desktop wallpaper still showing through the menubar strip on KWin + Breeze + Qt 6.
WA_TranslucentBackground (OpaqueMenuBar, MainWindow::applyTheme).
User report 2026-04-25: "I can clearly see my desktop background
behind it." Root cause: under WA_TranslucentBackground parent +
WA_OpaquePaintEvent on the menubar, none of the conventional
opaque-paint paths runs reliably on every WM/style stack. Specifically:
autoFillBackground is suppressed by WA_OpaquePaintEvent (the
contract is "the widget paints all pixels"), QPalette::Window is
only consulted by autoFillBackground and inherits the suppression,
and QSS QMenuBar { background-color: … } — which is supposed to
draw via QStyleSheetStyle::drawControl(CE_MenuBarEmptyArea) —
is silently skipped on KWin + Breeze + Qt 6 when
WA_OpaquePaintEvent is set, because the QSS engine assumes the
widget owns those pixels. Net effect: every safeguard 0.7.25 added
was correctly installed and not painting anything; the menubar
surface stayed cleared-to-transparent and the compositor showed the
wallpaper through. New file src/opaquemenubar.h defines
OpaqueMenuBar, a QMenuBar subclass whose paintEvent runs
QPainter::fillRect(rect(), m_bg) with CompositionMode_Source
before delegating to QMenuBar::paintEvent. That is the only
path that actually keeps the WA_OpaquePaintEvent contract honest
under translucent parents. m_menuBar is now an OpaqueMenuBar and
applyTheme sets the fill colour via setBackgroundFill(theme.bgSecondary).
The 0.7.25 palette + widget-local QSS calls are kept, no longer load-
bearing for the strip's opacity but useful for child-widget theme
propagation and for scoping the ::item :hover/:selected/:pressed
rules on the menubar itself rather than relying on the top-level
cascade. Regression test tests/features/menubar_hover_stylesheet
extended with INV-8 — three new assertions that pin the
OpaqueMenuBar construction site, the setBackgroundFill call in
applyTheme, and the presence of paintEvent + fillRect inside
src/opaquemenubar.h. The full spec is rewritten with the
per-iteration history (0.6.42 → 0.7.26) and an explicit "manual
verification" recipe (bright wallpaper + dark bgSecondary +
~0.85 opacity on KWin) so any future drift is reproducible by hand.77/77 ctest green; drift check clean.
0.7.25
Theme: memory-DoS hardening on OSC 8 hyperlinks and Kitty APC graphics chunks, plus a menubar-opacity fix reported mid-session.
TerminalGrid). Two attacker-controlled accumulators were previously bounded only by the VT parser's per-envelope 10 MB ceiling, with no downstream ceiling of their own. A hostile program could emit one \x1b]8;;<10MB-URI>\x07 per scrollback line and wedge tens of GB of URI bytes into per-row HyperlinkSpan copies + scrollback, or keep sending \x1b_G...,m=1,...\x07 frames without ever closing with m=0, growing m_kittyChunkBuffer unbounded. src/terminalgrid.h now declares MAX_OSC8_URI_BYTES = 2048 (real URLs are <2 KiB) and MAX_KITTY_CHUNK_BYTES = 32 MiB (larger than any realistic chunked image upload). Oversized OSC 8 URIs take the invalid-scheme drop path; following text prints unlinked. The APC chunk path clears and shrink_to_fits the staging buffer on cap breach so a subsequent m=0 sees an empty buffer rather than attacker-prepended garbage. Regression test tests/features/osc8_apc_memory_caps locks 5 invariants — pre-fix fails INV-OSC8-B and INV-APC-B.WA_TranslucentBackground (MainWindow::applyTheme). User report 2026-04-24. The top-level QSS cascade could race with the compositor damage rect under WA_TranslucentBackground, so on frames where the compositor invalidated the chrome region before QSS polish produced a paint, the menubar showed through. applyTheme now installs a belt-and-suspenders opaque fill on m_menuBar: QPalette::Window = theme.bgSecondary (so autoFillBackground paints before QSS) + a widget-local setStyleSheet(QMenuBar ...) block. Mirrors the pattern already used on the custom title bar and status bar. tests/features/menubar_hover_stylesheet extended with INV-7.77/77 ctest green; drift check clean.
v0.7.24 — wide-char overwrite zeroes the mate
Tier 2 hardening: wide-char (CJK / wide emoji) overwrite no longer strands its mate.
TerminalGrid). A wide character occupies two adjacent cells — a first half (isWideChar=true, codepoint=CP) and a continuation (isWideCont=true, codepoint=0). Before this release, three write paths in src/terminalgrid.cpp left half-pairs in an inconsistent state when a new write landed on only one half:handlePrint narrow write over a continuation left the mate at col-1 still claiming isWideChar=true with no neighbor (rendered with a gap);handlePrint narrow write over a first half left the old continuation at col+1 claiming isWideCont=true with no mate (blocked selection / copy);handlePrint wide write shifted by one from an existing wide pair orphaned the old continuation at col+2;handleAsciiPrintRun had the same left/right edge issues on its write span.breakWidePairsAround(row, startCol, endCol) helper that runs before every write site: it blanks the stranded first half on the left edge and clears the stranded continuation on the right edge.Regression test tests/features/wide_char_overwrite_mate locks five subcases — pre-fix source fails four of them (INV-1, INV-2, INV-3, INV-4), post-fix all five pass.
See CHANGELOG.md for the full entry.
0.7.23 — Background Color Erase on scroll + erase paths
Theme: xterm-compatible Background Color Erase (BCE) across every scroll and erase path. 0.7.12 Tier 2 hardening item.
TerminalGrid). Apps
that paint with a non-default SGR background (\e[44m etc.) and
then scroll, insert lines, delete lines, delete chars, or insert
blanks expect the newly-exposed cells to inherit the current bg —
this is the xterm convention vim/less/tmux/mc/htop rely on for
full-screen painted backgrounds. Previously takeBlankedCellsRow()
hardcoded m_defaultBg, so CSI L / CSI M / CSI S / CSI T
/ LF-past-scroll-bottom all produced default-bg gaps through any
painted region. deleteChars / insertBlanks used raw
m_currentAttrs.bg without the .isValid() fallback that
clearRow already had. Consolidated the policy into one
TerminalGrid::eraseBg() helper; every erase/scroll callsite
now reads from it. Regression test:
tests/features/bce_scroll_erase/spec.md — 10 behavioral
subcases (IL, DL, SU, SD, DCH, ICH, ED2, EL0, LF-scroll,
SGR-reset-before-erase). Pre-fix source fails 5 subcases
(IL/DL/SU/SD/LF-scroll); post-fix all 10 pass.0.7.22 — Help → About menu (GUI version indicator)
Theme: user-visible About menu (user ask 2026-04-24 — "How do I see what version of Ants Terminal I am running? Can you please add a GUI version?").
Help → About Ants Terminal… menu. New rightmost menu on the menubar (matching Linux desktop HIG: Help is always last). Two actions:
ANTS_VERSION (read directly from CMake's
project-wide single source of truth, no hardcoded literal),
the Qt runtime version (via qVersion()), the Lua engine
version (when compiled with ANTS_LUA_PLUGINS), a one-line
summary, and a clickable GitHub homepage link
(TextBrowserInteraction enabled so URLs in the dialog open
in the user's browser).QMessageBox::aboutQt dialog.
Inherits future Qt-version bumps automatically.Pre-fix, ants-terminal --version on the command line was the
only path to read the running version. Regression test:
tests/features/help_about_menu/spec.md — 6 invariants
(Help-menu-last, About-action present, ANTS_VERSION
referenced not hardcoded, Qt::RichText +
Qt::TextBrowserInteraction set, About-Qt action routed to
QMessageBox::aboutQt, no "0.7." literal inside the
handler body).
0.7.21 — Lua sandbox hardening: string.dump strip + t-mode load + hook clear
Theme: Lua sandbox hardening trio from the 0.7.12 /indie-review.
Three small defense-in-depth fixes in LuaEngine, behavioral +
source-grep regression test locked to all three.
string.dump removed from the plugin sandbox.
string.dump(f) returns the bytecode serialization of a Lua
function. Lua 5.4 has no bytecode verifier — the loader parses
any byte sequence beginning with \x1b as a binary chunk, and
crafted bytecode can corrupt Lua's internal state and escape
the sandbox. load/loadstring/loadfile are already nilled
at init, so there is no supported round-trip from
string.dump back to executing bytecode, but a future C API
added to ants.* that wraps luaL_loadbuffer with plugin-
supplied data would reopen the attack surface. Closing the
primitive at the sandbox layer — lua_setfield(m_state, -2, "dump") scoped to the string table, not a blanket
lua_setglobal — is cheaper than auditing every future C API
for the same rule.LuaEngine::loadScript forces "t" (text-only) load mode.
The pre-fix path luaL_dofile → luaL_loadfile →
luaL_loadfilex(L, path, nullptr) accepted both text and
binary chunks at the loader level. The 0x1b-first-byte peek in
loadScript was the first gate; the loader call is now the
second gate. A future refactor that drops the peek still gets
rejection at the Lua level.lua_close in
shutdown. lua_close runs every pending __gc metamethod
in dependency order. Metamethods can execute arbitrary Lua
code which the count hook observes. If the hook fires mid-
close and walks back into registry data or the dying engine
pointer via __ants_engine, we get a UAF window. Clearing
the hook first with lua_sethook(m_state, nullptr, 0, 0)
removes that window; the C-side cleanup proceeds without any
Lua-level observer.Regression test: tests/features/lua_sandbox_hardening/spec.md —
6 invariants, behavioral (string.dump is nil, valid-text loads,
0x1b-first-byte rejected) plus source-grep on the three fix
tokens (luaL_loadfilex(..., "t"), lua_sethook(m_state, nullptr, 0, 0) before lua_close, lua_setfield(m_state, -2, "dump")
scoped to the string table). Verified to fail against pre-fix
source via git stash — 5 of 9 invariants flip red on the
regression, all green after the fix lands.
0.7.20 — Tier 2 hardening: debug.log perms + audit path-traversal + settings-dialog reload
Theme: Tier 2 hardening sweep — three open 📋 items from the
0.7.12 /indie-review landed as one release. Each has a behavioral
regression test, source-grep on the fix's load-bearing tokens, and
was verified to fail against pre-fix source via git stash before
locking.
debug.log lands 0600 regardless of umask.
~/.local/share/ants-terminal/debug.log was being created with
the process umask (typically 0644 under 0022). The log can
include PTY keystrokes (via the input / pty categories), AI
endpoint request+response bodies (network), OSC 133 HMAC digest
material (shell), and Claude transcript parse state (claude) —
every one of those is material that must not be world-readable.
DebugLog::setActive now calls setOwnerOnlyPerms twice after
the file opens: once on the QFileDevice to cover the just-opened
fd, and once on the path string to narrow any pre-existing 0644
file that append reused from a prior (pre-fix) run. Fix uses the
project-standard secureio.h helper, not a raw QFile::setPermissions
bitmask. Regression test: tests/features/debuglog_perms/spec.md
(4 invariants: fresh-open perms, clear-then-reopen, pre-existing
0644 narrowed to 0600, source uses the helper).file field. User-
supplied audit rules, audit_rules.json in a cloned project, and
external scanner regex outputs can all produce findings whose
file field is e.g. ../../etc/passwd. Pre-fix, six call sites
across AuditDialog naively concatenated m_projectPath + "/" + f.file and passed the result to readSnippet / lineIsCode /
comment scans / AI-triage POST bodies — a textbook CWE-22 +
OWASP LLM06 (sensitive-information disclosure via LLM) chain, since
the AI-triage surface exfiltrates snippet contents to the configured
/v1/chat/completions endpoint. New AuditDialog::resolveProjectPath
helper canonicalizes the candidate path (resolves .. and
dereferences symlinks in a single QFileInfo::canonicalFilePath
call), requires the result to be anchored under the canonical
project root (with a trailing-slash sentinel so /proj-foo can't
escape from /proj), and returns QString() on any rejection.
All six call sites migrated: dropFindingsInCommentsOrStrings,
inlineSuppressed, the enrichment pass, single-finding AI-triage
snippet fallback, batch AI-triage snippet fallback, and the
dropIfContextContains regex-captured-relPath read. Regression
test: tests/features/audit_path_traversal/spec.md — 5 invariants
behavioral (via a byte-faithful reference reimpl — production
helper is private on the heavy QDialog subclass) plus source-grep
on the production code to confirm migration + helper structure
(canonicalFilePath + anchored startsWith).MainWindow caches the SettingsDialog across Preferences...
opens; the dialog was constructed with &m_config and populated
its widgets from the then-current values at construction. When
QFileSystemWatcher fired onConfigFileChanged on an external
edit, m_config = Config() reloaded from disk but the cached
dialog still held pre-reload widget state. Next Preferences...
open would show stale values, and clicking OK would replay them
over the fresh Config — silently undoing the external edit.
onConfigFileChanged now closes the dialog if visible, calls
deleteLater(), and nulls the pointer, so the next open rebuilds
from the freshly reloaded Config. Regression test:
tests/features/settings_dialog_config_reload/spec.md —
4 source-grep invariants (cache nulled, visible-close gate,
deleteLater-not-delete, invalidation scoped to
onConfigFileChanged).0.7.19 — tab-rename persistence + AppStream CI un-break
Theme: CI un-break + tab-rename persistence. CI had been red since
0.7.17 on the AppStream metainfo validation step — the 0.7.17 release
description embedded git clone https://ghp_…@github.com/… as an
example of what the new secret-redactor scrubs, and appstreamcli
correctly rejects plaintext URLs in <description> bodies. Rewrote
the example to use an inline non-URL form; local
appstreamcli validate --explain now exits 0. Separately, user asked
whether manual tab renames (right-click → "Rename Tab…") survive
Ants restart. They didn't — the pin map lived only in MainWindow
memory and SessionManager didn't serialize it. Fixed by bumping
the session-file schema to V3 with a trailing pinnedTitle field;
V2 files still load with the out-param defaulting to empty.
m_tabTitlePins)
is now written to and read from each per-tab session file, so a
tab renamed to "Deploy" or "Prod DB" or "Claude #3" keeps that
label after the app exits and relaunches. SessionManager
schema bumped to V3: a trailing QString pinnedTitle field is
appended after the V2 cwd. V2 files continue to load via the
existing in.atEnd() gate — Ants 0.7.18 and earlier can't read
V3 files, but since session files are a per-user cache (not an
interchange format) that's by design. MainWindow::saveAllSessions
threads m_tabTitlePins.value(w) (keyed by the outer tab widget,
which may be a QSplitter for split tabs) into the save;
restoreSessions pulls the pin back, populates the in-memory
m_tabTitlePins map, and sets the tab label directly (pin takes
precedence over the shell-derived window title from the saved
grid's windowTitle()). Contract locked by
tests/features/tab_rename_persist/spec.md — 20 invariants
covering the V3 round-trip (three pin lengths including empty),
V2 backward compat (hand-crafted V2 stream → restore leaves
pinnedTitle out-param empty), and MainWindow source-grep that
both save-side and restore-side wiring remain threaded. Verified
to fail against pre-fix source before locking.appstreamcli validate --explain failed on the 0.7.17 / 0.7.18 release
descriptions because the 0.7.17 block contained
git clone https://ghp_…@github.com/… as a scrubber example,
and AppStream's description-has-plaintext-url rule rejects
raw URLs in description bodies. CI's "Validate AppStream
metainfo" step exited 3 on every push since 0.7.17. Rewrote the
example as <code>git clone</code> with an embedded <code>ghp_</code> token in the URL. CI build-test step should
be green again on this release.SessionManager::restore initializes optional out-params
before reading. Previously, callers that passed a
pre-populated QString *cwd or QString *pinnedTitle would
see their sentinel survive an older-format load (V2 files
leaving pinnedTitle untouched, V1 files leaving cwd
untouched). Fixed by clearing both out-params at function
entry regardless of the stream's version. The version-gated
read blocks still populate them only when the on-disk format
actually has the field; the clear-first guarantees they read
as empty rather than as whatever the caller happened to
pre-fill. Locked by tab_rename_persist I3.