cmagent v0.3.4
Changelog
Notable changes by release. Date format YYYY-MM-DD.
0.3.4 -- 2026-05-31
Improvements
- First
/undoof a run notes that file edits aren't reverted: undo
rewinds the conversation only -- files already written to disk stay
changed. The/undooutput now appends a one-line reminder of this,
shown once per process (a TUI restart shows it again on the first
undo; a same-run session switch does not). Both TUIs share it. - "New version available" notice moved to exit: the update reminder
(still no auto-upgrade -- runcmagent updateto upgrade) is now
printed after the command finishes instead of before it. An
interactive TUI session tears down the alternate screen on exit, which
used to wipe a notice printed at startup; showing it on the restored
terminal makes it the last line you see, with minimal interference.
The daily background cache refresh is unchanged, so a long TUI session
that picks up a new release surfaces it on exit.
Fixes
- Background processes (e.g. a
nodedev server) are killed on exit:
a long-running command the agent started (npm run dev,node server.js) survived quitting cmagent.kill_on_droponly signalled
the direct child (sh -c ...), orphaning the realnodegrandchild.
cmagent now kills the whole process tree -- a process-group
SIGKILL on Unix (the child issetsid'd, so its PGID is known) /
taskkill /F /Ton Windows -- both for the explicitshellkill
action and on exit. On quit, if any session-started background
processes are still running, the TUI asks once whether to kill them
(default yes); declining leaves them running. (Fully&-detached
processes that were never tracked still can't be reached.) - Copy button no longer flashes a misleading "no selection" notice:
clicking the top-right copy button showed "copied ..." on mouse-down,
then the mouse-up immediately overwrote it with "click released (no
selection)". An empty left-release is the normal end of a plain click
(it also clobbered the jump-button's "jumped to latest"), so it now
leaves the status untouched; the "copied N chars" feedback still shows
when a drag actually selected text. - Canvas TUI now fires the SessionEnd hook and cleans the sandbox on
exit: the streaming TUI did both on exit; the canvas TUI (the
default) firedSessionStartbut neverSessionEndand never ran
sandbox cleanup -- so configured SessionEnd hooks silently didn't run
and sandbox resources weren't released. Both drivers now route their
final return-to-CLI through one sharedfinalize_tui_sessionteardown
(hook + context save + sandbox cleanup), so they can't drift again.
Child-process kill on exit is unchanged (it happens viaAgentdropkill_on_drop, independent of this path). The canvas's 3s
unwind-timeout + abort fallback for a hung turn is preserved.
- A pasted
@no longer opens the file picker: on Windows (where a
paste is a keystroke replay, not a bracketed-paste event), a@in
the pasted text used to open the@-file browser mid-paste, which
then swallowed the rest of the paste. A@now opens the picker only
for a genuine interactive keystroke; when the recent input reproduces
the clipboard (a paste in progress) the@stays literal text. Both
cases --@embedded mid-paste and a paste that begins with@--
are covered. Content-based, no timing.
0.3.3 -- 2026-05-30
Highlights
LSP language services wired into the agent
lsp_queryis now exposed to the main coding/chat agents (and the
shipped code-working profiles), so the agent can ask a language
server for definitions, references, hovers, and a token-lean file
outline instead of grepping. Server->client requests are answered
rather than dropped, so servers that expect a reply (e.g. config
requests) no longer stall.- LSP edits are wired into the write path:
renameandrename_file
apply the server's workspace edits across files. cmagent doctorgained a Language servers (LSP) section that
probes each server for runnability (shared cross-platform
path_probe, 5s cap) and a cold-index hint so a slow first query
isn't mistaken for a hang. The doctor no longer flags all-tools
agents as "missing lsp_query".
In-product documentation + help tool
- The public guides are embedded in the binary and exposed two ways: a
helptool the agent can read to answer "how does cmagent do X",
and acmagent docs [topic]CLI to read them from the terminal. The
helptool is wired into the user-facing agents with a config
migration for existing installs. - Docs reorganized: internal design docs moved under
docs/internal/;
the public set (README landing, tools / LSP / channels / commands /
media / hooks-cron / memory / configuration guides) is published to
GitHub.
apply_patch conflict-resolution mode
apply_patchaccepts a*** Resolve Conflict:mode that rewrites
Git merge-conflict markers, so the agent can resolve conflicts in
place instead of hand-editing<<<<<<</=======/>>>>>>>blocks.
gitignore-aware search
content_searchandglob_searchhonour.gitignore, so results
stop being drowned intarget/,node_modules/, and other ignored
trees.
@-file mentions and a file picker
- Typing
@in the TUI input opens a directory browser to pick a file
(Enter navigates into directories / selects, instead of submitting);
@pathmentions are expanded into the request at send time so the
agent receives the file's content.
Copy reply / activity to clipboard
Ctrl+Y(or a click on the copy affordance) copies the last agent
reply -- or a hovered activity entry -- to the clipboard as markdown.
Fixes
- Windows multi-line paste (Windows Terminal): crossterm never
emits a bracketed-paste event on Windows, so a multi-line paste
arrived as a rapid keystroke burst and submitted at the first
newline. Multi-line paste is now detected by matching the keystroke
burst against the clipboard prefix and re-inserted as one block;
the OS's keystroke replay is then absorbed by content (immune to the
replay's pace) rather than by a timer, and absorbed replay keys skip
the redraw so the input doesn't lag. Shared with the streaming TUI. - TUI garbled while running
npm/cargo/grep --color: tool
output carrying ANSI colour,\rprogress overlays, and cursor/erase
sequences was fed raw into the canvas renderer, drawing[0mjunk and
scrambling the layout. Tool-output preview lines are now sanitized
through a shared helper (the streaming TUI already did this); plain
text passes through unchanged. - Duplicate intermediate output: the canvas double-rendered
in-progress LLM prose during a tool-call wave (it showed twice, then
"merged" to one a moment later). Live buffers are cleared on turn
finalize / intermediate commit so each wave renders once. - Orphaned child processes on exit: a background process the agent
spawned (e.g. anodedev server) kept running after the TUI closed,
soCtrl+Cleft it alive. Spawned children are now killed on drop. - cliclack menus stair-stepped on Windows: prompt frames are written
with CRLF so each menu row starts at column 0 in raw mode instead of
drifting right. - TUI completer / help polish: full-row highlight band for the
selected completer row (no mid-row gap),/quitlisted once (not
/quit+/exit), consistent colour and grouped wrapping for long
/helprows, and a consumed/steerecho is struck through in place. cmagent doctor --fixreconciles installed agent tool-lists
(drops the deadmulti_editname, addslsp_querywhere the profile
should have it).- Build on macOS/BSD: the
libcdependency now applies to all Unix,
not just Linux.
0.3.2 -- 2026-05-28
Highlights
/plan <text> and /btw slash commands
/plan <text>builds the in-session plan by hand: each line (or
;-separated piece on a single line) becomes a step; leading list
markers (1.-*) are stripped. It appends -- existing steps
and their statuses are preserved -- and creates a plan if none is
active./plan runnow re-states the live plan in its kickoff so the
agent acts on a manually-set plan (the in-session plan isn't in the
system prompt).runis reserved for the driver./btw <question>is an ephemeral read-only side question: answered
using the current conversation as context, but the question and answer
are NOT added to the session, NOT saved, and NOT recorded in the
activity log / audit -- for a quick clarification without polluting the
context. A one-off no-toolsprovider.complete; a transient spinner
shows during it. (Claude Code / haermes v0.7 parity; scope: idle.)
Recall mid-run /steer and /queue for editing
- An un-consumed steer or queue message can now be pulled back into the
input box with the Up arrow (Claude Code parity). Newest steer wins
when both kinds are present; otherwise the newest queue. The body
goes into the input prefixed with/steer//queueunless the
recalled kind matches the currentbusy_input_modedefault (then
the prefix is omitted -- the bare body re-sends the same kind). - Canvas marks the original echo line with a strikethrough so the
withdrawn message is visible in scrollback; the sidebar's "Input"
panel and the live queue (state.steer_messages/
app.queued_inputs) both shrink as expected. tui2 is append-only,
so it prints a separate "↩ withdrew …" line with ANSI strikethrough
on the body instead. - When the input is empty and something is still recallable, the canvas
input box's own placeholder advertises it --↑ edit steer · or type
(or↑ edit queued · or type) instead of the generic hint -- so the
affordance is visible right where you're looking, not only on the
status line (which still also shows↑ recall steer (edit then Enter)). Cleared on Ctrl+C / backspace-to-empty. - Entries are pruned automatically once the agent drains the steer at
the next tool-call boundary, or once a queue is submitted as a new
turn -- the recall pool and hint stay in sync with what is actually
still pending. Shared helpers inshared/recall.rskeep canvas and
tui2 from drifting on the priority / prefix-omission rules.
Mid-run steering
- A message typed while the agent is working is now injected as guidance
at the next tool-call boundary of the running turn -- redirect it
without cancelling work in flight. Newgeneral.busy_input_mode
("steer", default, or"queue") sets the behavior;/steer <text>
and/queue <text>force either for one message regardless. When the
agent is idle,/steer//queueare equivalent to ordinary input.
Ctrl+C still cancels. (Previously a message typed mid-run was always
queued for the next turn -- that's now the opt-in"queue"mode.)
The canvas sidebar shows an "Input" panel of steered/queued messages
so typed input isn't lost in the scrolling transcript.
Self-learned rules (learn_rule)
- The agent can propose a durable behavioral rule (a project convention
or a preference you keep correcting) via the newlearn_ruletool.
After you confirm, it's appended to the agent's
~/.cmagent/agents/<name>/LEARNED.mdand becomes part of its system
prompt from the next session -- a plain, hand-editable, portable file
loaded alongsideRULES.md. The tool is the only sanctioned writer of
that one file (control-plane protection still blocks everything else
under~/.cmagent), never auto-writes (always asks), is bounded, and
records provenance per rule. Opt-in per agent via thetoolsallowlist
(added tochat,coding,admin). Contextual one-offs still belong
inbrainmemory; this is for always-on directives. cmagent doctornow flags agents whose installedconfig.tomlomits a
tool the current shipped template lists (e.g.learn_ruleafter an
upgrade --cmagent initnever overwrites an existing config). It's an
advisory only; you add the line yourself, since thetoolsallowlist is
a security boundary cmagent won't silently widen.
ACP session lifecycle (cmagent acp)
- The Agent Client Protocol stdio server (for editor integrations like
Zed) got a lifecycle pass:session/cancelnow produces an accurate
stopReason: "cancelled"on the prompt response (it always said
end_turn; cancellation already interrupted the agent, the label was
just wrong);tool_call_updatenow carries the toolkindand the
output as a content block so the client shows what a tool did, not
just that it finished;session/loadreplays the persisted
conversation assession/updates so a resumed session isn't blank;
the agent's user-invocable slash commands are advertised via
available_commands_updateon session start; and the Plan tool's
state is mirrored to ACPplanupdates. A tool that finished within
one poll window now still emits its initialtool_callbefore the
tool_call_update(clients drop updates for atoolCallIdthey never
saw). Deliberately not chasing
opencode's wider product surface (fork/resume/set-mode/auth/dynamic
MCP) -- those aren't ACP interop essentials.
Security
Indirect prompt-injection defenses for untrusted tool output
- Output from external tools (
web_fetch,web_search, MCP servers) is
now wrapped in an untrusted-data delimiter telling the model to treat
it as DATA, not instructions, before it reaches the LLM. The wrapper
is anti-spoof: each one carries a fresh random id on its open/close
markers and any forged delimiter inside the content is neutralized, so
a poisoned page can't "close" the block early and smuggle in trusted
text. The raw content still shows in the activity tree. - A detect-only scanner flags suspicious untrusted results. It never
blocks or alters the result -- it's a monitoring signal layered
behind the delimiter. Only high-confidence, near-zero-false-positive
signals are reported: forged tool-call tags (<tool_call>et al.),
LLM chat-template control tokens (Qwen/Llama/Mistral/Gemma/harmony +
reserved-token family), and hidden/bidirectional Unicode (zero-width
and direction-override characters used to conceal or reorder text).
Natural-language phrase heuristics are deliberately excluded as noise. - Findings are recorded on the offending message (durable, persisted
with the session, never sent to the provider), surfaced as a one-line
end-of-turn warning in both TUIs, and counted as a[!N]marker in
the session browser.
Control-plane files are off-limits to tools
- The agent's generic file tools (
file_write/file_edit/apply_patch/
multi_edit) and shell redirects can no longer read or write anything
under cmagent's own config dir (~/.cmagentor$CMAGENT_HOME): agent
profiles, skills,providers/*.toml(API keys),config.toml, data
DBs. This is an absolute deny -- unlike ordinaryforbidden_paths
it is not exemptable byextra_dirs, an admin/relaxed profile, or
prompt_threshold = "never", and it also resolves symlinks. It closes
self-escalation (an agent rewriting its own profile to widentoolsor
drop the threshold), persistent prompt injection (poisoning a
SKILL.md), and provider-key theft -- even for a fully-trusted agent or
a tool call injected into the model's response. Skill and agent
management still work through their dedicated paths (skill_manager,
cmagent config), which don't go through the file-path gate.
File path policy is now a hard wall under prompt_threshold = "never"
never(auto-approve) previously skipped file path validation along
with the prompts, so an unattended profile (e.g. the Ralph worker) ran
afile_write/file_editoutside the allowed paths with no check --
including a tool call a compromised gateway/router injected into the
model's response. Path policy (traversal / out-of-workspace / forbidden
paths) now denies on every path, regardless of threshold, mirroring how
the shell parser already behaved.neverstill means "don't prompt",
not "skip policy". Legitimate in-workspace writes are unaffected. This
is the cross-platform backstop where no OS sandbox is available (e.g.
Windows). Path checks also now cover both thepathandfile_path
argument spellings.
TOCTOU-safe atomic writes for credential / state files
- The OAuth token store and cron
jobs.jsonare now written atomically
(a fresh random temp file created withO_CREAT|O_EXCL+ mode 0600,
then renamed into place), closing a symlink / time-of-check-to-time-of-
use window on credential files.
Fixes
- macOS build fixed (
cmagent-security): thelibcdependency was
gated tocfg(target_os = "linux"), butspawn::detach_session_in_child
is#[cfg(unix)]and callslibc::setsid()-- so macOS/BSD failed to
compile (setsidnot found in cratelibc). The dep now covers all
cfg(unix); the Linux-only Landlock code stays gated by its own module
attributes, so no Linux-only symbols leak onto other targets. - Brain memory search now works for CJK (and Japanese/Korean/Thai):
brainsearch was plainLIKEsubstring matching, unranked. It's now
backed by an FTS5 trigram index (BM25-ranked, multi-term), which
unlike the defaultunicode61tokenizer can tokenize space-less
scripts -- CJK keyword recall actually works instead of treating a
whole run as one token. Queries< 3chars (below the trigram floor)
still fall back to LIKE; user punctuation is quoted so it can't be
parsed as FTS operators. The index is standalone and not
backfilled -- only memories written after the upgrade are
FTS-searchable (deliberate; avoids a migration and the external-content
corruption footgun). True semantic search stays out of core -- it's a
future MCP. Zero new dependencies; no token cost. - Canvas TUI no longer pegs the CPU on mouse movement: crossterm's
mouse capture reports motion events (mode 1003), and the event loop
redrew on every mouse event -- so just moving the pointer flooded
full redraws (and the backlog kept draining after you stopped, so it
"stayed high for a while").handle_mousenow returns whether
anything visible changed and the loop only redraws then; plain motion
(and right/middle buttons) are no-ops. Clicks, drags, releases, and
wheel scrolls still redraw. - Canvas TUI no longer pegs a CPU core during agent turns: while
the agent was working, the canvas forced a full transcript rebuild
every 120 ms -- and that rebuild re-ranbuild_turn_cells(markdown
parse + syntect highlighting) for every turn inturn_history(up
to 20), every tick. On a busy session that's the dominant cost
(measured ~50 ms/refresh in release, far worse in a debug build,
enough to saturate a core). Finalized turns never change, so they're
now rendered once into a per-turn cache (turn_cell_cache, keyed by
turn index) and reused; only the in-progress turn is re-rendered each
tick. Measured ~50 ms -> ~2.4 ms per refresh (~21x). Cache is
invalidated on width or/verbosechange and pruned as turns age out
of history; rendering output is unchanged (covered by a transparency
test). - Canvas TUI no longer pegs a CPU core on scroll / drag-select: the
per-frame transcript render styled and cloned every line in the
whole session (apply_selectionovercache.lines()), and the
scroll-bottom math (max_transcript_scroll) re-cloned + re-wrapped
every line on top -- both ran on every redraw, and a drag-select also
re-walked all lines perpoint_at. So scrolling, dragging a
selection, or resizing spiked a core on a long session even when
nothing changed. The cache now keeps a wrapped-row prefix index
(built once per content/width change with ratatui's own
line_count), so the renderer styles only the visible window,
max_transcript_scrollis O(1), andpoint_atis O(log N). Measured
~10.8 ms -> ~0.11 ms per frame at 4000 lines (~100x); the scrollbar
now also tracks wrapped rows instead of logical lines. Selection /
scroll output is unchanged (covered by the existing wrap-aware
point_attests, now routed through the cache). - Canvas TUI: cheap wrapped-row measurement (the real busy-run CPU
cost): aperfprofile during an LLM run showed the hot path was
grapheme-cluster segmentation (unicode_segmentation::Graphemes::next
~21%,ratatuiWordWrapper::next_line~8%) on the main thread --
not the network workers. It came from the wrapped-row index above
calling ratatui'sline_count()on EVERY line of the whole transcript
on each content change, andline_countruns the full grapheme +
word-wrap machinery. Since ratatui sums the same per-symbol
unicode_width, a line whose total display width already fits the
viewport occupies exactly one row -- so the index now takes that with
a cheap width sum and only falls back toline_countfor genuinely
overflowing lines (wide tables, long code). Measured 6.7 ms -> 0.7 ms
per rebuild at 4000 lines (~9.4x); wrap counts are identical
(verified againstline_countin tests). - Markdown renderer loads syntect once, not per render: a streaming
perfprofile then showed the remaining main-thread cost was syntect
reloading its entire bundled syntax + theme set on EVERY markdown
render --AnsiRenderer::newcalledSyntaxSet::load_defaults_newlinesThemeSet::load_defaultseach time, which decompress (flate2/miniz)
and deserialize (serde) hundreds of definitions. Because the
in-progress turn re-renders on each refresh, that ran several times a
second (theserde/flate2/ScopeRepositorycluster in the profile).
Both are now process-wideOnceLocksingletons loaded once. Measured
~582 us/render of pure reload overhead removed -> ~38 us/render with a
code block (~16x); highlighting output is unchanged.
transcript cache is flattened at the pane width, but a resize only
requested a redraw -- it never rebuilt the cache, so content kept the
old layout (tables, in particular, stayed at the previous column
widths) until the next message bumped state. The event loop now
detects a post-draw width change (last_refresh_width) and forces a
one-shot rebuild at the new width, the same way the first-frame
bootstrap already did.
- Mermaid diagrams: native CJK width via mmdflux 2.5: upgraded
mmdflux2.0.2 -> 2.5, which measures node-label width with
unicode-width(the 2.0.x line counted characters, mis-sizing boxes
with CJK/wide labels). cmagent had carried an ASCII-placeholder
workaround (substitute width-matched ASCII -> render -> restore +
patch trailing spaces) to compensate; that's now removed -- the
mermaid source (CJK and all) goes straight to mmdflux, which sizes the
boxes correctly. No behavior change for existing diagrams (they were
already aligned via the workaround); ~70 fewer lines and more robust
for edge cases. (Note: this only affects real```mermaidblocks --
hand-drawn ASCII diagrams in a plain fence are rendered verbatim, as
they must be.) - Table columns stay aligned with arrows / ambiguous-width glyphs:
the table renderer measures cell width with plain.width(), which
treats East-Asian Ambiguous glyphs (arrows like->/\u{2192},
some punctuation/symbols) as 1 column -- matching the default of the
common terminals (xterm, iTerm2, Windows Terminal, kitty, alacritty,
gnome-terminal). A mid-cycle attempt to switch this towidth_cjk
(ambiguous = 2) over-padded any row containing such a glyph on those
terminals, drifting its right|border left; that has been reverted.
Box-drawing borders are width 1 everywhere, and pure-ASCII / pure-CJK
tables align under either metric, so only ambiguous-glyph rows were
affected. (A terminal explicitly set to ambiguous = wide is the rare
case and would want a future config knob, not a hard-coded flip.) - Markdown tables wrapped in a bare code fence now render as tables:
models very often wrap a table in a```fence (especially when
"quoting" a doc), which rendered as raw, space-aligned pipes -- and
those columns don't line up once cells contain CJK / wide glyphs
(space padding can't track display width). A bare```fence (no
language) whose body is a well-formed table is now re-rendered as a
real, width-aware box. Gated to bare fences, so```markdown/
```text(an explicit "show the raw syntax" signal) and real code
stay literal. Render-time, so it also fixes such tables in existing
session history on reload. - Malformed markdown tables now render as tables, not raw pipes:
LLMs frequently emit a table whose|---|delimiter row has a
different column count than the header (or miscount it) -- GFM then
silently treats the whole block as a paragraph and the canvas/streaming
TUIs showed raw| a | b |pipes that stayed broken. The shared
markdown renderer now repairs the delimiter row to match the header's
column count (preserving:alignment) before parsing, so the table
boxes correctly. Both TUIs benefit (shared renderer). Scope-guarded:
only rewrites a line that is already a pure dashes/colons delimiter, so
setext headings (text+---with no pipe), fenced code, and prose
containing a|are untouched; well-formed tables are unchanged. path/file_pathare now interchangeable in tool calls: file
tools (file_read,file_write,file_edit,diff_preview,
apply_patch,list_dir) name their argumentpath, but models
trained on Claude Code'sfile_pathconvention frequently sent
{"file_path": ...}and got a confusingpath is requiredback (and
lsp_queryusesfile_path, compounding the mix-up). The dispatch
funnel now mirrors the two keys (whichever is present is copied to the
other, non-clobbering, bidirectional), so either spelling works for
every tool;required_path_argalso acceptsfile_pathdirectly as a
fallback. No per-tool changes, no allowlist.cmagent doctordetects (and--fixrepairs) a stale
max_context_tokens: a provider config written against an older
catalog can pin its context window far below the model's real size --
e.g. a DeepSeek V4 config frozen at 128K from before the catalog
listed V4's 1M, whichcmagent init(writes only missing files) and
the wizard (reads the catalog only at creation) never update. Doctor
now compares each provider config'smax_context_tokensto the
catalog model'scontext_windowand warns when it's meaningfully
lower (>10% gap, so rounding like 204800 vs 205000 isn't flagged);
cmagent doctor --fixraises it to the catalog value. It only ever
raises toward the model's real capability, never lowers, and skips
models not in the catalog (custom endpoints). New shared
provider_catalog::lookup_context_window(factored alongside
lookup_max_outputso the two can't drift).- Re-install /
skill updateno longer wipes a plugin's slash
commands: enabling slash commands for a plugin
(skill slash <plugin> --enable) sets auser_invocableoverlay in
the manifest, but every (re)install rewrote the manifest entry with
user_invocable: None, so reinstalling -- orskill update, which
reinstalls a GitHub bundle -- silently dropped the slash commands on
the next/reload-skills. Install now carries forward any existing
overlay and applies it to the freshly loaded skill, so the commands
survive an update and stay live without waiting for a reload. Fresh
installs still default off. (/reload-skillsitself was never broken
-- it correctly reports 0 when no installed skill is invocable.) cmagent skill remove <plugin>left orphan entries: removing a
plugin bundle by its root name (e.g.webnovel-writer) deleted the
on-disk directory but left everywebnovel-writer/<child>manifest
entry dangling -- and still printed "Removed" -- so a later
cmagent skill updateresurrected the whole bundle. Remove is now
bundle-aware: it drops the directory and all<name>/<child>
manifest entries in one go, reports each removed skill, and says "not
found" only when nothing actually matched. A trailing-slash boundary
keepswebnovelfrom matchingwebnovel-writer.cmagent skill updatehandled only ClawHub skills: update asked the
ClawHub catalog for every installed skill, so anything sourced from
GitHub 404'd -- and for a multi-skill plugin bundle (e.g.
webnovel-writer, whose children all share one repo source) it tried
once per child, producing a wall of 404s. Update now routes each
installed skill by its source: ClawHub catalog skills (?slug=) are
re-fetched as before; GitHub-sourced skills are deduped by repo and
updated as one unit (singleSKILL.mdor a whole marketplace plugin,
reinstalling all bundle children in one fetch); skills with no remote
source are reported as skipped instead of fetched. A plugin reinstall
re-runs the dependency auto-setup (same PEP-668-aware installer as
cmagent skill setup), so a changedrequirements.txtstays
installed.- Skill Python deps install under PEP 668: on a system whose Python is
flaggedEXTERNALLY-MANAGED(Debian 12+ / Ubuntu 23.04+), a skill's
pip install -r requirements.txtwas refused, socmagent skill setup
failed and the in-app plugin install reported "installed" while
cmagent doctorkept saying "pip pending" -- three surfaces
disagreeing. Root cause: the dep installer never cleared PEP 668, so the
install never went through the installer (no sentinel written), and
the CLI used a second, drifted pip launcher. Now there is one shared
launcher (skill_install::run_safe_step, used by both the in-app
auto-setup andcmagent skill setup); on the externally-managed signal
it retries once with--user --break-system-packages(per-user site
only -- never touches system packages, even as root). The install
succeeds, the sentinel is written, and the in-app report, the CLI, and
doctor all agree. Any other pip failure still fails fast. - Esc interrupts a running turn (both TUIs): previously only Ctrl+C
reliably stopped the agent mid-run. Esc now cancels too, matching
Claude Code. In the streaming TUI, Esc only flipped a local "busy"
flag without actually cancelling -- the agent kept running while the
UI claimed it stopped; it now signals cancellation. In the canvas TUI,
Esc-to-cancel was gated on a 120 ms-polled busy snapshot that lagged
real state and could no-op mid-run; it now reads the live agent status
(the slash-command popup's own Esc-to-dismiss still wins when open). - Assistant prose was wiped when the next tool call started (canvas):
when the model emitted text and then called another tool, the
already-shown prose vanished. A turn kept only oneresponse_text
(set at turn end), while the canvas rendered the live streaming buffer
-- which is reset at each LLM iteration, so earlier prose was lost and
only the final wave's text survived. Each tool-call wave's prose is now
committed to the turn record as the wave closes, and the canvas renders
committed prose plus the live buffer together, so nothing disappears
mid-turn (and the full text is kept in history). Cancelling now also
shows all prose so far, not just the current partial. cmagent doctornoise reduction:Proxy: noneis now a pass (✔,
"all providers connect directly") instead of an advisory -- a direct
connection is the normal default, not a finding. Themessaging_send
allowlist warning now fires only for user-facing (Main) agents; sub-/sys
workers (sub-coder, sys-ralph-worker, ...) never handle channels, so a
single global outbound opt-in no longer flags all ~13 of them. The
Branding section collapses to one line for the built-in default (the
product/tagline/footer detail shows only when actually overridden).- Agent config wizard: tool-exclude step is now opt-in: after picking
a risk tier (which already yields a complete tool set), the wizard used
to always present a "Tools to exclude" multiselect you had to step
through even to exclude nothing. It's now gated behind an optional
"Exclude specific tools? (optional)" confirm -- default no, so picking a
tier is enough; defaults to yes only when editing a profile that already
has exclusions. - Browser
nonemode:[browser] modeaccepts"none"(also"off"
/"disabled") andcmagent config browseroffers it -- the right
choice on a machine with no browser (common on Linux). It turns the
browser tools off explicitly and skips launch-mode auto-detection;
cmagent doctorreports it as a deliberate "disabled by config", not a
warning. Thecmagent config browserbinary picker also gains an
explicitautochoice (re-detect each launch) so you can reset off a
pinned path. Andcmagent doctorno longer warns about launch mode
with no browser present -- an absent browser is an environment fact (a
headless Linux box), not a misconfiguration, so it's now an info note,
not a yellow warning you have to silence by settingnone. (noneis
still there for explicitly turning the tools off.) - Interactive commands no longer wedge the TUI: a command that opens
the controlling terminal to read input --sudoasking for a password,
ssh, agitcredential prompt -- used to hijack the TUI's terminal
and hang forever (the keyboard belongs to the TUI, so you could neither
type the password nor escape). Shell commands now spawn in their own
session with no controlling terminal (Unix:setsid), so such a command
fails fast with a clear message (sudo: no tty present and no askpass program specified) instead of locking up. Covers both the agentshell
tool and the!<cmd>TUI shortcut, on Linux and macOS. - Markdown renderer ate text after a
<: the HTML-stripping pre-pass
treated any<as a tag start, so a<inside an inline code span
(e.g.`for (int i=0; i<n; i++)`) or a lone<in prose (e.g.
x < 10) dropped everything to the end of the line. It now copies
inline code spans verbatim and treats a<with no closing>as a
literal character; real HTML tags and<br>hard breaks still strip
as before. Affected both TUIs (shared renderer). - Local providers no longer hit the 90s idle-timeout: the streaming
idle watchdog now exempts local backends (loopback / RFC1918 / CGNAT /
.local/ IPv6 ULA + link-local base URLs), raising their limit to
20 min so a slow local model (ollama, llama.cpp) isn't aborted
mid-generation.
0.3.1 -- 2026-05-26
Highlights
Conversation state unified (durable turns, status, derived screen)
- The user's message is now persisted the moment it's submitted, and
every turn outcome stamps a status and saves. A turn interrupted by
Ctrl+C, a timeout, an error, or the tool-iteration cap is no longer
lost -- its partial content and an "aborted" marker survive a
restart. Newturn_statuson each message; zero migration (old
sessions load as "complete"). /undoand/redoare now durable: undo a few turns, quit, reopen,
and they stay gone (redo is in-memory only, by design). The resumed
transcript is rebuilt from the conversation messages rather than the
never-truncated audit log, so undo is reflected on reopen.- After an interrupted turn, the next request tells the model the
previous response was cut off so it continues from your correction
instead of treating the partial as complete. /undo//redoshow append-only annotations (Last:/Undo:/
Redo:) with coloured prefixes and a real strikethrough on the
undone input, rendered identically in both TUIs.- The session browser shows each session's last-turn status
(interrupted (cancelled)/unanswered/incomplete). - Design + plan:
docs/internal/plans/2026-05-26-chat-state-*.md.
Per-provider proxy
- New optional
proxyfield in a provider's TOML (e.g.
proxy = "http://127.0.0.1:7890"); routes just that provider's API
calls through it. When unset, the standardHTTP(S)_PROXY/
ALL_PROXYenv vars still apply. Settable viacmagent config
(add + edit flows);cmagent doctorshows each provider's proxy
source. Credentials in the URL are masked in logs. Motivated by
reachingapi.x.aifrom a network where it's otherwise blocked
while other providers stay direct.
Fixes
- TUI console-log corruption: a WARN/ERROR written while the canvas
TUI owned the screen could corrupt the input row with no recovery
(e.g. an xAI stream timeout). Console logging is now suppressed for
the alt-screen lifetime regardless of how the TUI was launched
(direct subcommand or interactive menu). - Canvas remembers the selected agent across restarts: switching
agent in the canvas TUI now persists, matching the streaming TUI
(the two had drifted).
Behavior changes
- An explicit
--providerflag now outranks a persisted in-TUI
/modelselection (a one-off launch override wins over the saved
pick). Effective priority: default < agent endpoint </model<
--provider.
0.3.0 -- 2026-05-25
Highlights
Brand identity surfaces
cmagent --versionand the TUI canvas welcome screen now
render a branded banner instead of the bare clap version
string. ANSI Shadow CM logo with name / tagline / footer
bottom-aligned to the right; falls back to a compact 3-line
form on terminals too narrow to fit the banner alongside.- TUI canvas welcome screen renders in the transcript area
whenever the cache is empty (fresh session / new session),
auto-replaced by transcript content on the first user
message or history recall. Lists "Quick start" commands
(/help,/agent,/model,/goal,/plan) so first-
time / occasional users don't have to fumble through
/helpto find their way around. cmagent doctorgains a "Branding" section showing the
active bundle's source (env var / user file / built-in
default) plus name / tagline / footer values -- the first
thing an OEM packager needs when debugging a custom
rebrand.- Brand bundle lives in
assets/branding.toml(compiled
into the binary viainclude_str!) with override chain:
$CMAGENT_BRANDINGenv var >~/.cmagent/branding.tomlcompiled-in default. Schema covers product name, banner
ASCII, tagline, footer, and welcome-screen quick-start
list. Newcmagent_config::brandingmodule exposes
BrandingConfig::load()andrender_banner_block/
render_compact_versionhelpers used by all surfaces. - Workspace version unified: gateway / provider / tool
crates were pinning their ownversion = "0.2.1"instead
ofversion.workspace = true, drifting from the workspace
bump. Aligned all three so the next bump propagates
everywhere by changing one line.
Claude Code plugin install
cmagent skill install <owner>/<repo>now probes for a
.claude-plugin/marketplace.jsonwhen the repo has no
root-levelSKILL.md, downloads the tarball, and extracts
the full<plugin>/subtree (skills + scripts/ + references/- templates/ + agents/, only
.claude-plugin/metadata is
skipped). Skills register under qualified names
<plugin>/<skill>;${CLAUDE_PLUGIN_ROOT}/...references in
SKILL.md bash snippets now resolve to real on-disk files.
- templates/ + agents/, only
- Accepts any common GitHub slug form: bare
owner/repo,
https://github.com/owner/repo(with or without.git/
trailing path),github.com/owner/repo, mixed scheme case. - After install,
npm install/pip install -r requirements.txt
/bash setup.shrun automatically in each skill directory
and the plugin root. The detector scans the plugin root plus
the conventional subdirsscripts/,src/,tools/,
dashboard/,server/. Every match contributes its own
step, so a plugin shipping both ascripts/requirements.txt
and adashboard/requirements.txt(e.g. webnovel-writer)
has both installed at plugin time instead of one ambushing
the first skill invocation. Sentinel files
(.cmagent-installed) make re-install a no-op when nothing
changed. pipinvocation triespython3 -X utf8 -m pipfirst, then
python -X utf8 -m pip,pip3, and finallypip. On
Debian / Ubuntu thepip/pip3binaries belong to the
python3-pipapt package and are often absent on minimal
systems even whenpython3is installed; the new ordering
recovers without forcing the user to install the package.
Every candidate also setsPYTHONUTF8=1/
PYTHONIOENCODING=utf-8in the child env so Windows pip's
auto_decodedoesn't fall back to the system locale
(CP936 / GBK on Chinese Windows) and reject a perfectly
valid UTF-8requirements.txtwith a multi-byte decode
error.- Same install pipeline available to agents via
skill_manager { action: "install", slug: ... }.
Skill slash-command toggle
- New per-skill
user_invocablemanifest overlay
(~/.cmagent/skills/manifest.toml). Lets the user enable or
disable/<skill-name>slash exposure for an entire plugin
without editing eachSKILL.md(a re-install would overwrite
hand edits anyway). cmagent'suser-invocable: falsedefault
is preserved -- the overlay is opt-in. - Four entry points: CLI
cmagent skill slash <plugin> --enable|--disable|--status, agent tool
skill_manager { action: "set_slash", plugin, enable }, a
cliclack confirm at the end of plugin install, and a new
"Skills" page in the TUI/configpanel that lets the user
multi-select which plugins are slash-enabled.
/reload-skills
- New slash command that re-discovers skills from
~/.cmagent/skills/and rebuilds the agent's slash-command
table without restarting the session. Wired into the agent
(Agent::reload_skills) plus the TUI autocomplete so a/
press shows freshly installed/skill-nameentries
immediately.cmagent_config::ConfigLoaderis nowCloneso
the reloader closure can capture it at session start.
/goal autonomous loop
- New
/goal <description>slash command sets a session goal
the agent then drives toward without further user prompts.
After each worker turn a judge LLM (same provider as the
worker for now) reads the conversation transcript and emits
{"met": bool, "feedback": "..."}. Whenmet=falsethe
feedback is injected as the next user message and the worker
runs another turn; whenmet=truethe goal clears and the
final response carries a[goal] DONEsummary. Capped at 50
iterations to bound runaway. Bare/goalreports current
state;/goal clearcancels. Mirrors Codex CLI's/goaland
Claude Code's v2.1.139 equivalent. - Session persistence: active goal text + iteration counter
are written to a newcontext_state.goal_statecolumn
(sqlite migration v006, additive, defaults to'').
Restarting the process resumes the goal -- the next user
message runs through the judge loop with the saved
iteration count. - Per-agent
[goal]config block inagent.toml:
judge_provider+judge_modelroute the judge call to a
separate provider (Anthropic's design: worker on Opus,
judge on Haiku);max_iterationsoverrides the global cap.
Missing or typo'd judge provider falls back to the worker
with a warning -- the loop never fails to start because of
a bad judge config. - TUI status bar shows
| goal N/M <text>when a goal is
active, mirrored from newAgentState.goal_text/
goal_iteration/goal_max_iterationsfields kept in
sync from every goal mutation. The truncated text preview
(32 chars) keeps the bar within narrow-terminal widths. /cleararchives the in-flight goal along with the rest of
the conversation. A goal belongs to the conversation that
set it; carrying it through a/clearwould have the next
message auto-driven by an invisible goal.
Plugin sub-agents
- Claude Code plugin
agents/<name>.mdfiles are now spawnable
viaspawn_agentusing the<plugin>:<agent>name shape
(e.g.webnovel-writer:context-agent). Resolved on-the-fly:
the spawner falls through to
~/.cmagent/skills/<plugin>/agents/<agent>.md, parses the
YAML frontmatter + body, translates the Claude Code tool
list (Read/Grep/Bash/Agent...) to cmagent's
tool names (file_read/content_search/shell/
spawn_agent...), and synthesises an in-memory
AgentProfilewithkind = Sub. Nothing is written to
~/.cmagent/agents/; the plugin remains the source of
truth. Unknown Claude Code tool names are dropped silently
rather than erroring -- plugin authors can declare them as
native cmagent agents if they really want them.
Streaming robustness
- Streaming LLM requests now hard-stop after 90 seconds of
silence (no SSE event arrived). xAI Grok in particular
sometimes sits on an open connection for 10+ minutes
without emitting anything before the TLS decoder gives up;
the new timeout surfaces a clean "stream stalled" error
instead of leaving the user staring at a spinner. - On any streaming error (decode failure, idle timeout,
network drop) the partial text + thinking already buffered
in SharedState is preserved into the conversation before
the error message is appended. Previously the model's
early reasoning was discarded -- a reasoning model that
emitted 300+ chars of thinking before stalling left no
trace after the error returned. - Ctrl+C cancellation now also preserves thinking-only
partials (notextchunks yet but a non-emptythinking
buffer). The old code requiredpartial_textto be
non-empty before saving anything; with Grok's reasoning
models that meant cancelling mid-thought wasted minutes
of model state.
TUI
- Terminal resize no longer leaves a "ghost" status bar +
divider + input row stranded mid-screen. The previous
resize()only cleared the NEW fixed-area rows; the old
ones, drawn at the old window's bottom, stayed visible
inside the new scroll region. Resize now mirrors
resume(): hard-clears every row, re-establishes the
scroll region for the new geometry, and replays the chat
buffer so the conversation survives the resize.
Iteration cap + doom-loop
codingagent / preset cap raised from 30 to 100. Long
legitimate tasks (project bootstrap, plugin install,
multi-file refactor) routinely run past 30 -- the doom-loop
detector still catches actually-stuck cycles independently.- Doom-loop detection now checks cycles up to length 5 (was 3).
Patterns likeread -> grep -> read -> list_dir -> read
that previously slipped through length-3 detection now fire
after the standard 3 repetitions. Recent-sig window bumped
from 20 to 25 to fit the new max. - Cycle-detection logic extracted to a pure
detect_cycle
helper with unit-test coverage (length 1-5 cycles, distinct
progress doesn't fire, tail-only scan ignores warmup noise).
Agent loop polish
/<skill>invoked with no arguments no longer hands the LLM
an empty user message. Substitutes an explicit kickoff that
names the skill so the model pairs the turn with the
## Skill:section in the system prompt instead of
declaring "user message is empty" and improvising tool calls.skill_manager set_slashreports schema-quoting errors when
the model uses the wrong param (slugisinstall-only,
plugin+enablebelong toset_slash), and accepts
string"true"/"false"/"yes"/"1"boolean forms
alongside JSON booleans -- XML tool-call protocols often
deliver booleans as strings, which previously dead-ended in
a doom loop.slugandnameare now accepted as aliases
forpluginsince models trained against theinstall
action habitually reuseslug.skill_manager listnow shows a SLASH column and renders
qualified<plugin>/<skill>names, so an LLM verifying its
ownset_slashcall has a deterministic signal instead of
mistaking the agent-profile "enabled skills" list (a
separate gate) for slash-command exposure.- New
skill_manageractionsenable_slashanddisable_slash
take only{plugin}-- no boolean to forget. Models
empirically reach for verb-named actions more reliably than
boolean flag params, so this is now the recommended way to
toggle slash exposure.set_slashkeeps its
{plugin, enable}shape for back-compat, withenable
defaulting totruewhen omitted (the common-case intent). skill_manager set_slashnow re-reads the manifest from
disk aftersave_manifestand emits an explicit
"Verified on disk" line on success, or a "WARNING: on-disk
verification failed" block listing the mismatched keys when
in-memory state and persisted state diverge. The "no skills
matched" error also lists the plugin names that DO exist in
the manifest, so a typo recovers on the next attempt instead
of doom-looping./reload-skillsand the TUI autocomplete refresh now
re-read the agent profile from disk on each call. Previously
the reloader captured a snapshot ofagent_profile.skills
at session start, so a mid-sessionskill_manager install
(which writes new qualified names into the profile) was
invisible to the reload -- every Optional plugin skill got
filtered out and the reload reported "0 skill(s) ... 0 slash
command(s) active". Fixed in three places (infra.rs,
gateway builder, TUIApp::refresh_slash_commands); falls
back to the startup snapshot when the profile becomes
unreadable mid-session.
Plan tool
- New
update_plantool (RiskLevel::Low) lets the agent
maintain a visible roadmap of named steps for the current
task. Actions:set(replace plan with title + steps,
all start Pending),advance(mark current InProgress
step Done and promote the next Pending step InProgress),
mark_step(set any step's status by 1-based index;
statuses pending / in_progress / done / skipped),clear. SharedPlanslot onAgent; SQLite persistence via v007
migration (context_state.plan_jsoncolumn) so plans
survive process restart and/switch./planslash command prints the current plan;/plan clear
drops it (user override; the LLM otherwise drives mutation
viaupdate_plan)./plan run [N]-- user-triggered driver loop. Re-enters the
agent with a short synthetic "continue with the next plan
step" message until the plan reaches a terminal state
(no Pending + no InProgress), the user cancels (Ctrl+C
flips SharedState.status to Cancelled), or N iterations
elapse (default N=10, clamp 1..=50). Completion check is
structural, not LLM-judged -- the deliberate distinction
from/goal. Each iteration is a fullhandle_message_full
pass so hooks, skill selection, auto-compact, and the retry
observer all keep working; the TUI sees the conversation
unfold in real time and the Plan chip / sidebar panel
update as the LLM callsupdate_plan(advance).- Step status glyphs use geometric Unicode (filled square /
triangle / empty square / dotted square) so the semantics
are cross-cultural --xfor Done was avoided because it
reads as "wrong / rejected" in Chinese chat UX. - Both TUIs render Plan progress in the status bar:
Plan [done/total] <current step>. Canvas TUI also adds a
Plan section to the sidebar with per-step status rows;
in-progress step is bolded/accented.
Retry indicator
- Canvas TUI now renders a
\u{21BB} retry N/M . <category> . Xs
chip in the status bar whenRetryingProvideris sleeping
between attempts -- a stalled-looking turn is now visibly
distinct from "stuck". Sub-second backoffs render as
0.<digit>sso the user sees the wait is short. - New
cmagent_provider::traits::RetryObserverasync trait
(withon_retry/on_donehooks) plumbed through the
Provider trait via a default-noopattach_retry_observer.
AgentRetryObservermirrors the in-flight retry into
AgentState.retry_status; cleared onon_done. Agent::attach_retry_observer_to_provider()runs at agent
construction AND on every/modelswap so the indicator
keeps reaching SharedState across provider swaps.
Goal progress chip and sidebar panel
- Canvas TUI now surfaces an active
/goalin two places:
a sidebar Goal section (two-row layout: header with the
iteration counter, body line with the goal text truncated
to sidebar width) AND a status-bar chip when the sidebar
is hidden. The chip is gated on!sidebar_opento avoid
doubling the same info on the most-scanned line, matching
the Plan chip behaviour. - Both surfaces use two display modes:
- default:
Goal (#N)/Goal #N <text>-- iteration
counter alone, no cap. The cap (50) divided by iter is
NOT real progress because the judge can declare met at
any iteration, so showing the ratio is misleading; - near-cap (iteration >= 80% of cap):
Goal \u{26A0} (#N / M)/Goal \u{26A0} N/M <text>in WARNING color,
surfacing both numbers because the ratio matters once
the loop is about to auto-stop.
- default:
- Closes the "/goal sidebar not shown" + "/goal progress
display N/M not informative" gaps Daisy reported. tui2
already carried a comparablegoal N/M ...indicator.
Input handler nav keys
- Canvas TUI input box now handles Home / End / PageUp /
PageDown -- previously they fell into the input handler's
_ => {}no-op arm.- Home / End: cursor to start / end of the buffer. For a
single-line draft this matches "start / end of line";
multi-line "start of current visual row" can ride later
as Ctrl+A / Ctrl+E if needed. - PageUp / PageDown: scroll the transcript by 10 lines
while keeping the input focused. Lets the user peek at
scrollback mid-draft without Tab-ing focus away. Same
step size as the Transcript-focused mapping so the
gesture is uniform across panes. Detachesfollow_tail
on PageUp so the manual scroll doesn't snap back.
- Home / End: cursor to start / end of the buffer. For a
Memory review hardening
- Per-pass due-check guards: weekly/monthly/yearly reviews
now check the__last_*cursor before running, so stale
periods don't re-run. - LLM can now mark a review period as
SKIPwhen nothing
meaningful happened, suppressing the "nothing summarized"
penalty.
Bug fixes (post-v0.2.1 commits)
- Providers: SSE streaming tool calls now preserve the
first chunk'sargumentsbytes when xAI Grok packsidand
initial args in the same frame. Previously the parser
dropped the leading bytes, leaving the accumulated JSON
unparseable and the tool call rejected as "missing required
parameter". Fix introducesStreamEvent::ToolCallStart's
initial_argumentsfield and updates every adapter. - Providers: xAI Grok catalog refreshed to the 4.x lineup
(grok-4.3,grok-4.20-0309-reasoning, etc.) and the
reasoning_effortparameter is no longer sent -- Grok
returns 400 for unrecognised params. - Agent: Ctrl+C during streaming now preserves partial
text + thinking content in conversation history. The user's
message and any LLM bytes already emitted survive so the next
turn can continue from where the model stopped. - Release/Windows:
Compress-Archiveretries with backoff
when antivirus scanning briefly holds the freshly built
cmagent.exelock. - Debug:
/debugviewer renders the prompt in
system -> tools -> messages -> paramsorder to mirror
Anthropic's prompt-cache layering.
Migration notes
ManifestEntry(skills/manifest.toml) gains an optional
user_invocable: Option<bool>field with#[serde(default)].
Old manifests load unchanged.TuiOptsgains a publicagent_skills_filter: Vec<String>
field. Any out-of-tree integration that constructsTuiOpts
must populate it (emptyVec::new()is fine).Agent::with_skill_reloader(SkillReloader)is the new
builder hook for hot-reloading skills. None-by-default
(sub-agents, tests, gateway-local builds) print a clear
"no reloader configured" message when the user invokes
/reload-skillswithout one.
Streaming / provider fixes (post-v0.2.1, batch 2)
- Empty-args tool calls (
stream tool call arguments failed to parse; falling back to {}): three independent root causes
surfaced across DeepSeek / GLM / Grok testing and got handled.parse_anthropic_sse_framereadscontent_block_start.input
when non-empty. Some Anthropic-compatible providers (GLM via
zhipu-anthropic) pack the full tool input there instead of
streaminginput_json_deltaevents; without seeding
initial_argumentsthe accumulator finalised with empty
args and tools rejected as "X is required".parse_openai_sse_frameemitsToolCallStartwhen EITHER
idornameis present (DeepSeek V3 reasoning mode splits
them across separate frames), acceptsfunction.arguments
as either a JSON string OR a raw object, and walks every
tool_calls[]entry instead of returning on the first.SseFrameParsersignature changed toVec<StreamEvent>.
Multi-payload frames (DeepSeek reasoning mode packs
reasoning_contentnext to atool_calls.arguments
fragment) used to drop everything but the first event;
args fragments riding alongside reasoning got discarded
and the accumulator finalised with truncated JSON.- Accumulator in
stream_llm_requestnow preserves non-empty
id/nameacross multipleToolCallStartevents so
later partial frames don't clobber earlier ones.
- Truncation surface:
ToolCall.parse_error: Option<String>
field. The stream finaliser sets it when accumulated arguments
JSON fails to parse; dispatch skips tool execution and pairs
the call with a denied_result spelling out the cause
("Tool call 'X' arguments JSON arrived incomplete (N bytes
received before stream ended; serde parse: ...). Retry with
shorter content or split..."). Previously the model saw the
misleading "X is required" the tool rejected with and retried
the same oversize content forever. max_tokensdefaults overhauled:
ProviderConfig::max_tokensis nowOption<u32>(wasu32
with a 4096 default that capped most modern models well below
their stated max). Resolved at load time via a three-layer
fallback:- User's
~/.cmagent/providers/<id>.tomlexplicit value. (backend, model)lookup in the embedded
assets/provider_catalog.toml-- uses each model's stated
max_output(Opus 32k, Sonnet 16k, GPT-4o 16k, GLM 16k,
DeepSeek V3 8k).None. OpenAI-compat senders omit the field (provider
uses model default). Anthropic wire requiresmax_tokens
so the builder falls back to 8192 last-resort.
Catalog access cached behindprovider_catalog::embedded()
(OnceLock).
- User's
- Doom-loop ordering: Nudge no longer back-fills "skipped"
tool_result blocks (tools DO run that iteration), and the
system("nudge")message is parked and emitted AFTER all
realtool_resultrows land. The previous order
assistant(tool_calls) -> tool_result("skipped") -> system(nudge) -> tool_result(real)violated OpenAI's grammar
(toolmust immediately follow the matchingassistant),
and our own orphan filter dropped the real results. ForceStop
still back-fills because it returns without executing. - Orphan tool message filter:
build_chat_requestnow
routes context messages throughdrop_orphan_tool_messages
before splicing them after the system prompt. Tool messages
whosetool_call_idisn't in the most recent assistant's
tool_callsare dropped (and counted in a single WARN). Used
to be that legacy SQLite rows with unpairedtoolentries
forced/clearto recover; the filter now self-heals them. - Sub-agent inheritance fixes:
AgentSpawnerImpl::with_max_tool_iterations(n)-- the
parent'ssession.max_tool_iterationsis forwarded to every
sub-agent. Previously sub-agents silently used the built-in
default of 10 even when the main profile raised the cap.
resolve_max_iterations(parent, profile)takes max(parent,
sub-profile) so a sub-agent's profile can raise the ceiling
further but can't accidentally lower it.- Sub-agent tool completions also write to the parent's
tool_outputs,tool_calls, andcurrent_turn.tool_calls
so Ctrl+O Activity shows nested work. Previously only
live_toolswas forwarded (in-chat spinner) and the
Activity viewer saw only the wrapperspawn_agentcall
without details.
- Streaming stall + zellij scrollback:
- Streaming loop gains a 90s idle timeout. Grok was hanging
minutes after the upstream had stopped sending bytes; the
timeout surfaces a clear stall error instead. print_chatreasserts the chat scroll region right before
every\x1b[S(SU). Mouse-wheel scrollback in zellij /
Windows Terminal silently drops DECSTBM, and without the
reassertion subsequent chat scrolls would clobber the
status row.
- Streaming loop gains a 90s idle timeout. Grok was hanging
TUI rendering fixes (post-v0.2.1, batch 2)
- Narrow-status truncation:
fit_status_linebudgets the
trailing space + right-aligned text first, then truncates left
by display width (CJK chars count as 2 cols) instead of byte
count. The old code only checkedleft_w > cols, so a
multi-byte right + 2-space combo could push the line past
colsand the terminal wrapped it onto the input row. - Tool-line truncation respects terminal width: the tree
rows under├─ ⚙ toolused to be hard-capped at 80 / 60
chars regardless of terminal width.print_chat_oneline
now stores the full untruncated source (prefixed with a
\x1Fsentinel) inrecent_linesand middle-truncates
with…against the CURRENT terminal width via
tui_util::fit_visual_middle. Resize replay re-fits to the
new width. - SGR preserved in tool result previews: tool output rows
used to route throughsanitize_chat_line, which stripped
ANSI colour entirely. Newsanitize_preview_linekeeps SGR
(16-color, 256-color, 24-bit truecolor), expands\tto four
spaces, collapses\r-based progress overlays to the last
meaningful frame, and drops only the unsafe ESC sequences
that would steer the host terminal. - Live thinking branch coalescing: the
|- thinking ... \- done · ...block stopped getting sliced
into ~80- or ~700-char chunks. Two separate bugs.live_tools.len()baseline captured at block open;
thinking_phase_endedonly fires when the count GROWS past
the baseline (a new tool wave) rather than every render
while ANY tool was live.streaming_textbaseline + 32-char threshold: DeepSeek
reasoning mode emits stray single-charcontentdeltas
(a\n, a space) interleaved with reasoning. Raw
!streaming_text.is_empty()flipped on every such char
and closed the block.
- UTF-8 char-boundary audit + shared helpers: nine sites
acrosscmagent-channels,cmagent-interface, and
cmagent-securitybyte-sliced user-supplied&strwithout
rounding to a UTF-8 boundary -- CJK / emoji / arrow glyphs
straddling the byte budget panicked the process. The
TUI thinking renderer's 800-byte cap hit→(U+2192,
3 bytes at 798..801) and brought down the live render loop
mid-stream. Fix: consolidate the helpers into a new
cmagent_config::text_utilmodule
(ceil_char_boundary,floor_char_boundary,
safe_byte_slice,truncate_to_char_boundary) and route
every byte-indexed slice through them. Rule codified in
CLAUDE.md Anti-Patterns. Affected: chat splitters
(traits/slack/weixin), inbound filename sanitiser, debug-log
previews (weixin/lunkr), agent description trim, memory TUI
label clip, thinking-block cap. - Ask / permission popup top border lingering: three
cascading bugs in the popup machinery.ASK_PANEL_HEIGHT(12) andask_panel::render's hardcoded
max_panel_rows(15) disagreed -- panel painted 3 more
rows thanclear_popup_panelcleared, leaving a│strip
at the border row (whichrender_bottom_areadoesn't
clean whileagent_busy = true).set_panel_height/clear_popup_panel/
render_popup_panel/clear_scroll_regionall subtracted
the constantFIXED_LINESinstead of the dynamic
last_fixed_rows. Multi-line input would float the popup
underneath the input rows.TerminalManager::active_popup: Option<(panel_start, height)>snapshots the painted region at open time and the
dismiss path clears the exact same rows -- no re-deriving
from a possibly-changedlast_fixed_rows.print_chat::chat_scroll_endreturnspanel_start - 1
while a popup is open. Hardcodingrows - last_fixed_rows
re-enlarged the scroll region behind the popup's back, and
aprint_systemcall DURING the popup (the "Permission:
allowed" line) would scroll the popup's╭───top border
UP into chat history as part of SU. The dismiss path then
had nothing to clear and╭───stayed stranded.- Permission popup
execute_optionswapped order:
clear_popup_panelFIRST, thenprint_system. Avoids the
"Permission: ..." line rendering twice (once at SU's
landing row, once at the bottom from the recent_lines
restore).
- Ctrl+L = full screen redraw: bound in the main TUI key
handler. Walks the same path as a resize event (clear every
row, reset scroll region, replayrecent_linesbottom-up).
Manual recovery for terminal-side scrollback corruption that
the automatic paths can't see.
Migration notes (post-v0.2.1, batch 2)
cmagent_provider::types::ToolCallgains an optional
parse_error: Option<String>field with#[serde(default)]
andskip_serializing_if. Old session-history rows
deserialise without the field. Out-of-tree code that
constructsToolCallliterals must passparse_error: None.cmagent_config::provider::ProviderConfig::max_tokensis
nowOption<u32>(wasu32). User TOML values that
previously parsed as4096(the old default) now load as
Some(4096)only when explicitly set; absence means
"consult catalog, then provider default". This is a behaviour
change but only in the upward direction -- truncations stop
happening, no new ones introduced.cmagent_provider::base::BaseProvider::max_tokensmirrors
the sameOption<u32>change.cmagent_provider::types::SseFrameParserreturns
Vec<StreamEvent>instead ofOption<StreamEvent>. All
in-tree implementations updated; out-of-tree adapters need
to return eithervec![ev]orVec::new().cmagent_config::text_utilis the new home for char-boundary
helpers. Direct byte slicing on user-supplied strings is now
flagged in CLAUDE.md as an Anti-Pattern; new code MUST route
throughceil_char_boundary/floor_char_boundary/
safe_byte_slice/truncate_to_char_boundary.
v0.2.1 -- 2026-05-20
Bug-fix and polish release focused on the Windows Terminal
experience plus a handful of agent-loop / tool correctness
fixes surfaced while dogfooding v0.2.0.
Platforms: Linux x86_64 / aarch64, macOS x86_64 / aarch64,
Windows x86_64.
Highlights
TUI / CLI flicker on Windows Terminal
- Wrap every multi-write render in cmagent's own
terminal.rs(status bar, popups, activity line, permission
prompt, scroll-region clear) in DEC mode 2026 synchronized
output markers. GPU-accelerated terminals paint the whole
frame atomically instead of mid-redraw tearing. - Vendor + patch
cliclackunder3rd/cliclack/with the same
DEC 2026 wrap PLUS a write-then-erase ordering fix. Windows
Terminal's mode 2026 implementation defers text output but
not cursor / erase escapes -- the upstream "clear before
write" order leaves a visible "menu blanked" moment that the
patch eliminates by writing the new frame first and erasing
trailing rows after.
CLI menu navigation
cliclackselect / multiselect: arrow keys wrap around at
the boundaries (top↑jumps to bottom, bottom↓jumps to
top).- cmagent's
select_*helpers default the cursor to the middle
of the list when noinitial_idxis given AND the list has
five or more items. Combined with wrap-around this drops the
worst-case keystrokes-to-any-item fromlen - 1to
(len / 2) + 1.
Tools
trash: switch from a hand-rolled "move into~/.cmagent- trash/" implementation to thetrashcrate, which calls the
platform-native API (IFileOperationon Windows,NSWorkspace
on macOS, freedesktop XDG spec on Linux). Files now land in
the real OS Recycle Bin / Trash and restore through Explorer /
Finder / Files / Nautilus the way users expect. Fixes a
regression on native Windows where the tool failed with
"HOME not set" because the previous implementation only
looked at$HOME.shell: TUI label forwait/killactions now shows the
task_idinstead of "(unknown command)". The dispatch path
was unaffected -- only the user-visible label was wrong.
Agent loop correctness
- Doom-loop detection now appends the assistant
tool_use
message to context BEFORE backfilling the skiptool_result
placeholders. Prior order left orphantool_resultblocks
with no precedingtool_useparent; on the next LLM call
GLM's Claude-compat layer returned a server-side
AttributeError, Anthropic-direct returned an invalid-request
error, and/clearwas the only recovery. Now both branches
(skip and execute) share the same precondition.
TUI input
- Pasting multi-line content after a Ctrl+Enter no longer
submits the buffer prematurely. Two compounding bugs fixed:
the clipboard cache wasn't invalidated across boundary
events (Ctrl+Enter, Esc), so a paste after the user copied
new content compared against the wrong clipboard; and a
short first line (e.g."Hi\n...") reached the embedded
newline before the length-gated matcher could engage, so the
\nevent fell through to the standard Enter handler. Added
a short-prefix clipboard check gated on
PASTE_CONTEXT_GAP(30 ms Linux / 250 ms Windows) plus
cache invalidation on non-text events.
Self-update
cmagent updatenow streams the artifact via
response.bytes_stream()and feeds acliclack::progress_bar
/ spinner. Before: the download went through
response.bytes().awaitwhich buffers the whole body
silently -- on a slow GitHub mirror the CLI looked frozen for
minutes between "Downloading vX.Y.Z..." and "Updated to
vX.Y.Z". Now the user sees percentage progress (or cumulative
MB on a CDN that strips Content-Length).
Migration notes
- No schema changes. No config changes. No public API changes.
- A new
3rd/cliclack/directory ships in the repo (vendored
cliclack 0.5.4 + cmagent patches). Built automatically via
[patch.crates-io]in the workspaceCargo.toml. When
upstream cliclack ships the same fixes the vendored copy can
be dropped without any code change in cmagent's own crates.
v0.2.0 -- 2026-05-19
Minor release covering ~5 weeks of development since v0.1.0.
Public APIs unchanged; one additive SQLite schema migration.
Platforms: Linux x86_64 / aarch64, macOS x86_64 / aarch64,
Windows x86_64.
Highlights
Memory & context
- Memory review system rewritten for correctness: cursor-based
backfill with bail-on-failure (no more silent loss of failed
days), sparsity-aware merging (0/1/2/3+ entries take different
paths), hardened SUMMARY/DETAIL parser tolerant of markdown /
bold / case variants, four-line safety system prompt locked
by tests. - Context compaction: kept tail sanitised after truncation (no
orphan tool_use / tool_result pairs), token estimator covers
tool_calls + reasoning + images + codex reasoning, section-
aware condense priority preserves the highest-value content. - Session todo list persists across restart (SQLite migration
v005, additivecontext_state.todos_json). - Compaction + review prompts are now domain-neutral -- sales,
admin, writing, research, not just coding.
Providers
- Codex (gpt-5.5) provider: streaming-only
complete(), encrypted
reasoning items round-trip across turns. - Anthropic + OpenAI token semantics unified:
input_tokensmeans
"full prompt size including cache" on both, so cache ratio
never displays > 100%. - Per-turn reasoning-effort selector.
- Retry on transient failures, structured error taxonomy,
estimated session cost. - Live model lists for Ollama and LM Studio.
- Provider import from Codex CLI.
- DeepSeek V4 thinking-mode:
reasoning_contentpreserved across
turns so follow-up requests don't get rejected.
Security
- Permission UI collapsed from 5 options to 3 (Approve once /
Approve and remember / Deny). Per-tool + per-shell-program
approvals persist toworkspace.tomlso a restart doesn't
re-prompt. - Security model refactor: prompt threshold, shell parser,
sandbox layering cleaned up.
Tools
file_editabsorbsmulti_edit; line-ending / indent /
smart-quote fallbacks for cross-platform editing.- Per-model edit-tool selection guidance in the system prompt.
- Browser tool: three-layer SSRF guard (scheme allowlist +
private-host block + secret-prefix scan on raw and URL-decoded
forms), untrusted-output marker on every page payload.
UI / TUI
- Input soft-wrap, grapheme awareness, scroll-to-cursor.
- Paste-burst detector hardening.
- Process panic hook routes panics to tracing instead of
corrupting the TUI border row. - Consecutive tool-call rows merge on session resume.
Architecture
agent/module restructured into focused submodules:
mod.rs1384 -> 808 lines,review/directory (7 files,
was 1870 lines),turn/directory (7 files, was 1404 lines).
Same public API; clearer per-file responsibility.- Unified outbound messaging across Lunkr / Telegram / Slack /
Discord / WeChat with channel-scope enforcement.
Migration notes
- SQLite: migration v005 adds
context_state.todos_json
(additive,DEFAULT '', no data migration needed; old
sessions pick up the empty default on load). - No config schema changes.
- Public Rust APIs unchanged; agent module split is internal.
Detailed development log
The sections below are the unedited day-by-day dev log from
the v0.1.0 -> v0.2.0 iteration. Skip unless you need context
on a specific change.
v0.1.0 -- 2026-05-09
Initial public release.
Platforms: Linux x86_64 / aarch64, macOS x86_64 / aarch64, Windows x86_64.
Highlights
- Multi-provider agent loop (Anthropic, OpenAI-compatible, GLM) with SSE streaming
- 20+ built-in tools: file operations, shell, web fetch/search, CDP browser automation,
persistent memory (brain), sub-agent delegation, unified messaging - Channel integrations: Telegram, Lunkr (p2p + group), Slack, Discord, WeChat
- SKILL.md-based prompt extensions with keyword/tag activation and slash command support
- 4-layer security model: input guard, application policy, OS sandbox, output safety
- Gateway (HTTP + WebSocket, multi-user RBAC), ACP stdio server, Ralph Loop
- Config wizard,
cmagent doctor, session undo/redo/retry
Development log (pre-release)
[Unreleased] -- 2026-05-19 (agent module restructure)
User feedback that triggered this round: "review the code, split
the long files, add doc comments to anything that's missing
them." Three of the longest files in cmagent-core/src/agent/
sat in the 1300--1900 line range and were the obvious targets.
agent/mod.rs: 1384 -> 808 lines
Was a single file mixing the Agent struct definition, ~50
builder methods, the security verdict logic, audit writers, tool
formatting helpers, and the doom-loop detector. Split into:
audit.rs(154) -- audit-log writers (turn / assistant-with-tool-calls
/ tool-result summary).doom.rs(102) --DoomAction+ cycle detector. Pure self
mutation; trivially unit-testable in isolation.tool_format.rs(206) --format_tool_action,
dedup_tool_calls,extract_file_markers,
is_valid_file_marker,strip_file_markers,
tool_call_summary_from_json. Pure functions; the four
external callers (TUI, gateway, workspace browser, audit
writer) already imported these viacmagent_core::agent::*,
so the module path stays unchanged.security_check.rs(191) --check_security+ its three
layered checks (allowlist / shell parser / risk-level prompt).
mod.rs keeps the Agent struct definition, the builder
methods, and the public types (AgentResponse, SecurityVerdict,
TurnOverrides). Builder methods stay with the struct on
purpose: separating them would split a type definition from its
construction, which hurts readability more than it helps.
agent/review.rs: 1870 lines -> review/ directory (1850 across 7 files)
Promoted to a sub-module so each level of the hierarchy gets its
own file:
review/mod.rs(175) -- public API (list_review_summaries,
wait_for_review,Agent::maybe_trigger_review) +
ReviewGuardDrop guard.review/cursor.rs(141) -- cursor keys,SummaryOutcome,
backfill caps, once-per-day gate.review/calendar.rs(136) -- ISO week / month arithmetic
(iso_week_date_range,week_belongs_to_month,
previous_month, ...).review/parser.rs(264) --parse_summary_response+
truncate_to_summary+ section header detection.review/merge.rs(174) --ReviewLevel+merge_texts+
the shared safety-constraint system prompt.review/passes.rs(711) -- the four hierarchical passes
(run_daily_pass/ weekly / monthly / yearly) and their
per-period summarisers.review/storage.rs(246) -- brain key formatting, summary
writes, prune, turn counter.
Each sub-module owns its tests where they exercise that module's
private helpers. Total test count for review went from 21 (in
one file) to 21 (distributed across 7 files); each cargo test agent::review::<module> now scopes to a focused subset.
agent/turn_loop.rs: 1404 lines -> turn/ directory (1613 across 7 files)
The hardest of the three. handle_message_full is a 1100-line
mega-function whose loop body shares half a dozen local variables
across iterations (iterations, empty_retry_count,
pending_file_markers, deadline, context_len_before_turn,
active_skill_indices); the loop itself cannot be split without
turning those locals into struct fields or refactoring control
flow through enums. Instead, split the surrounding helpers and
keep the loop in one file:
turn/dispatch.rs(833) --handle_message,
handle_message_with_images,handle_message_full. The
control flow.turn/state.rs(188) -- SharedState writes: status,
current task, live tools tree, turn-history record, token
stats sync, tool-result mirroring.turn/tools.rs(229) -- parallel tool execution + per-call
hook firing + live-state forwarding to parent agent.turn/llm.rs(226) --LlmCallOutcomeenum +
call_llm_for_iteration+ SSE streaming driver.turn/inputs.rs(76) -- inbound hook chain +
user-message append + audit.turn/persist.rs(39) -- "Remember" branch of the permission
prompt (per-tool allowlist persistence).turn/mod.rs(22) -- module declarations.
Each sub-module is an impl Agent { ... } block. Rust merges
them into the same type at compile time; the split is purely
organisational.
Honest tradeoff: total line count goes up
Splitting did NOT reduce total agent-module size. agent/
went from ~5300 lines (4 files) to ~5400 (16 files). Helper
extraction in Rust pays a per-function boilerplate cost
(signature + return type + captured-state clones) that often
exceeds what the inline block contained. The win is per-file
scope, not byte count: the longest file dropped from 1870 to 833,
and 11 of 16 files now sit under 250 lines.
Doc comments added to extension-point traits and safety helpers
cmagent_provider::Provider-- documented the
invariants the agent relies on (capability stability, OpenAI
token semantics oncomplete()/stream(), error vs cancel).cmagent_tool::Tool-- name stability contract, flat schema
requirement (Claude on Vertex and OpenAI tool APIs reject
nestedoneOf/anyOfat the root), risk-level effect on
prompt threshold.cmagent_security::Sandbox--build_commandis the sandbox
boundary;is_availableis the runtime probe used by
auto-detection.validate_navigate_urlin the browser tool -- documented the
three-layer SSRF guard (scheme allowlist, private-host block,
secret-prefix scan on raw + percent-decoded URL).wrap_untrustedin the browser tool -- documented why every
page payload carries{"untrusted": true, "source": "browser"}
(prompt-injection boundary marker).
Incidentally fixed: two pre-existing test bugs
tests/session_test.rs::test_context_state_save_and_load
was missing thetodos_jsonfield after the v005 migration
added it. The test compiled until clippy ran on
--all-targets; surfaced by the workspace clippy gate.cmagent_storage::tests::gather_includes_audit_tail_after_compaction
usedUtc::now() - 6hto seed an "earlier" compaction event.
When the test suite ran between UTC 00:00 and 06:00 the
6-hours-ago timestamp landed on the previous day; the daily
LIKE 'YYYY-MM-DD%'filter then dropped the compaction, and
the assertion failed. Replaced with a fixed-date timestamp.
[Unreleased] -- 2026-05-18 (todo persistence)
Session todo list now survives process restart
User-visible bug: the agent's todo list (recorded via the todo
tool's add / complete / clear actions) lived only in memory.
Process restart, crash, or session switch silently dropped the
whole list. Worse, the conversation history still mentioned the
todos (tool calls are audited), so an LLM resuming the session
would believe they existed -- but todo list would return "No
tasks." A misleading half-state, not a clean failure.
Fix: persist the todo list alongside conversation messages.
- New SQLite column
context_state.todos_json(migration v005,
ALTER TABLE ... ADD COLUMN ... DEFAULT ''; no data migration,
old rows pick up the empty default). cmagent_tool::builtin::todo::TodoItemgets serde
Serialize/Deserialize; new helpersnew_todo_list_from_json+
serialize_todo_listbridge the in-memorySharedTodoList
and the persisted JSON blob.ContextManagermirrors the column aspub todos_json: String,
same pattern as the existingtool_outputs_jsonfield. Load
populates, save persists, clear wipes.Agent::newseeds the SharedTodoList from
context.todos_jsonif the caller pre-loaded the context;
Agent::reset_sessiondoes the same on session switch.
Followup fix: todo mutations now actually reach disk
After the persistence work above shipped, the user asked "do
completed todos actually hit the database?" -- which surfaced a
second bug. Three save sites in the agent loop bypassed the
Agent::save_context wrapper and called self.context.save()
directly:
turn_loop.rs:451after an empty-turn warningturn_loop.rs:528after each normal turn (the main path!)commands.rs:65after/compact
The raw save persisted whatever was already in
context.todos_json, which was always one turn stale. Every
mutation made during the just-finished turn was silently
dropped at the SQLite write.
All three now route through save_context, which syncs the
in-memory SharedTodoList + state.tool_outputs into the
ContextState immediately before the SQLite write. Comments at
each site flag the trap so a future "just call save()" pattern
doesn't re-introduce it.
Regression test
test_todo_changes_persist_across_agent_restart walks the full
path: mutate agent.todos, run save_context, open a new
ContextManager on the same DB, load -- the items + their done
flags survive. Test access via a #[doc(hidden)] pub fn todos_for_test() accessor on Agent that clones the underlying
Arc; production code mutates the same handle via
TodoTool::execute.
[Unreleased] -- 2026-05-18 (context compaction round)
Compaction: tool-call chain safety + accurate token estimate
Two bugs that could cause the LLM call after compaction to fail
at the wire (Anthropic rejected the request) but presented as
subtle confusing errors:
-
truncate_old_messageswas role-aware but not tool-call-pair
aware. The kept tail could start with an orphan tool result
(no preceding assistant tool_use) or an assistant tool_use with
no following tool_result -- both rejected by Anthropic and
OpenAI. The summary it injects (user-role) could also collide
with a leading user message in the kept tail, violating
Anthropic's strict role alternation.Fix: new
sanitize_kept_tailruns after the token-budget cut.
It drops front messages until the tail starts with an assistant
whose tool_calls are fully answered by following tool messages,
and never with a user message that would consecutive-user with
the summary. -
estimate_tokensonly measuredcontent. It ignored
tool_callsJSON (long for shell commands and plan_tasks),
reasoning_content(DeepSeek thinking chains run thousands of
tokens),codex_reasoning_items(OpenAI Responses API
encrypted blobs), and image data. Token tracking drifted from
reality, so compaction triggered too late and the next turn
could overrun the actual provider limit.Fix: new
estimate_message_tokens(&ChatMessage)sums every
relevant field. Per-image cost capped at 1000 tokens (safe
upper bound for current vision tariffs). The string-based
estimate_tokensstays for non-message callers (status display,
memory snapshots).
Compaction: behavior polish
Smaller fixes flagged in the same review:
-
Turn-start path removed. The previous code ran a full
aggressivecompact()at the start of each turn if total
tokens >= 80k (fixed threshold). On a 200k-context provider
this fired at 40% capacity, shredding cache prefix needlessly.
The agent loop already calls stagedauto_compact(mask at
80%, prune at 85%, aggressive at 90% of max_context) on every
iteration -- that's the right behaviour. Manual/compact
still does the full sequence for users who want it. -
condense_previous_summarynow section-aware. Old code
kept lines from the top of the prior summary until the char
budget ran out, which dropped## Pending User Asksand
## Exact Identifiersfirst because they sit near the bottom
-- exactly the sections the LLM most needs to keep working.
Now: parse into## Headingsections, include in priority
order (Objective > Pending User Asks > Open Issues > Exact
Identifiers > Progress > Key Decisions > Technical Context >
Memory), re-assemble in original document order. -
extract_key_filesrecognises\\separators. The
fallback-summary "Key files: ..." hint was always empty on
Windows because the path predicate required/. -
Compaction prompt is now domain-neutral. Same fix as the
memory-review prompt earlier: "AI assistant" + instruct domain
inference, broaden PRESERVE list to cover customer names /
ticket IDs / document titles alongside coding identifiers.
Testcompaction_prompt_is_domain_neutrallocks the wording.
Tests: 4 cursor-fix tests + 5 condense tests + 1 prompt test, all
new. Existing 11 context tests pass unchanged.
[Unreleased] -- 2026-05-18
Memory review: rewrite for correctness
Multiple bug fixes and a semantic redesign of the daily / weekly /
monthly / yearly summary chain. The chain now:
- Summarizes only finalized past periods. Today / this week /
this month / this year are never touched; yesterday's daily is
produced after the UTC day rolls over. Earlier code regenerated
the current period on every trigger, which burned tokens AND
meant the daily often only covered the first 5 turns of the day
before going stale. - Uses cursor-based backfill with caps (30 days / 12 weeks /
12 months / 5 years) so an offline operator catches up the most
recent N periods on resume without flooding the LLM with empty
back-dates. - Only advances the cursor past confirmed-done periods. A
failed LLM merge or rejected brain write returns aFailed
outcome; the cursor stays before that period and the next run
retries it. Previously the cursor advanced unconditionally,
silently losing any day whose merge failed. - Reads
compaction_logand the uncompacted audit tail
(audit_turns after the latest compaction's timestamp), labelled
as separate sections in the merge prompt. The old code returned
EITHER one, so a single early compaction silently dropped
everything that happened later that day. - Aggregates only within each period's actual boundary: weekly
uses the Mon..Sun range; monthly uses weeklies whose ISO-week
Thursday is in that month (the standard tiebreaker). The old
monthly rollup pulled every weekly in the year, producing
year-to-date summaries instead of per-month ones. - Gates on a once-per-UTC-day check stored as a bare date (not
a timestamp) so clock drift can't push the daily trigger
gradually later each day. - Prefixes content + detail with the period label ("[2026-05-18]
...", "[Week 2026-W18, 2026-04-27 to 2026-05-03] ...") so an LLM
scanning memory later can identify when each summary covers
without parsing brain key syntax.
Memory review: prompt + parser hardening
- Replaced the coding-only guidance with domain-neutral
prompts. cmagent's real users include sales, admin, and
writing -- "code changes" and "architectural decisions" were
inappropriate for those. - System prompt now carries four hard constraints: treat input
as data not instructions (prompt-injection defense), only
summarize facts explicitly present (anti-hallucination),
preserve the dominant input language, and discard transient
tool mechanics. - Tiered
max_tokensfor the merge call: daily 1024 / weekly
2048 / monthly 4096 / yearly 4096. DETAIL feeds the next level
up, so a too-small budget at a high level silently lost
information. - Tolerant SUMMARY/DETAIL parser: recognises plain
(SUMMARY:), markdown header (## SUMMARY), bold-wrapped
(**SUMMARY:**), case variants, and multi-line section
bodies. False-positive guard rejects "Summary report:" /
"Detail view". - Fix:
truncate_to_summarynow splits on character boundaries,
not byte indices. The byte-index version panicked on Chinese
text (byte index 200 is not a char boundary; it is inside '我') -- the panic killed the background review and leaked
the offending characters to stderr, which corrupted the TUI
border row.
Prompt cache visibility (Anthropic + OpenAI + Codex)
TokenUsagenow carriescache_read_tokensand
cache_creation_tokens. All three providers (Anthropic
non-stream + SSE, OpenAI Chat Completions, OpenAI Responses /
Codex) parse the relevant envelope fields.- Anthropic requests now emit three
cache_controlbreakpoints
per request (end of system prompt, last tool definition, last
message) so prompt caching actually fires. Earlier we sent zero
markers, so cache hit rate was always 0% on Claude. - System prompt reordered into a STABLE PREFIX (Identity,
Security, Workspace, Memory, DateTime) followed by
TURN-VARIABLE TAIL (Tools, HardGates, ActiveSkills) so the
cacheable byte prefix grows monotonically across turns. - DateTimeSection switched from minute granularity to day
granularity. Minute-level wall clock cycled the cache prefix on
every turn that crossed a minute boundary. - Bottom status bar now shows
cache:NN%when the provider
reports any cache activity; the line stays hidden when the
provider doesn't expose the field (some compat proxies strip
prompt_tokens_details). - "LLM response" info log includes
cache_read_tokensand
cache_creation_tokensso an operator can confirm whether the
upstream is actually reporting cache.
Permission UI: 3 options + workspace.toml persistence
After surveying vendor projects (OpenDev, IronClaw, ZeroClaw,
etc.) the permission prompt collapsed from 5 options to 3:
[y] Approve once
[a] Approve and remember (workspace)
[n] Deny
[a]persists to<workspace>/.cmagent/workspace.toml
[permissions]. Smart granule selection: shell tool stores
PROGRAM names extracted from the command line (socargo test
approves all futurecargo ...); other tools store the tool
name.- Persisted allowlists are loaded into the in-memory
session_allowed_*HashSets atAgent::new, so an approval
from the previous run takes effect on the next session without
restart. - New
/permissionsslash command:list(default) and
remove <kind> <value>. Removal clears the in-memory set too. - Drops the "Allow whole shell tool" footgun -- not reachable
from the UI; only available via hand-edit of
workspace.tomlshell_commands. - Gateway HTTP/WS handlers accept both the new "remember"
decision string and the legacy "allow_session" key for one
release of backwards compat with external IDE clients.
Panic safety: install hook to keep TUI clean
A tokio task panicking in the background used to dump
thread '...' panicked at '...' plus the
note: run with RUST_BACKTRACE=1 ... line to stderr, which lands
inside the TUI's fixed scroll region and corrupts the input
border / status row. The panic payload also embeds whatever
variable caused the panic, so user input or session IDs leaked
on screen.
- New panic hook (
logging::install_panic_hook) captures every
panic to~/.cmagent/data/logs/panics.logplus a
tracing::errorevent with a loud
"PANIC in background task: ..."prefix. - In TUI/ACP mode, suppresses the default stderr dump entirely.
Non-TUI invocations (e.g.cmagent -m) keep the default
behavior so developers still see panics in their console. - The tracing event flows through
TuiNotifyLayerinto the chat
area, so the user actually SEES that something crashed (the
earlier silent-failure behavior was the real bug). - End-to-end test (
panic_event_reaches_tui_notify_sink) wires
a tracing subscriber + TuiNotifyLayer + channel and confirms
a synthetic panic arrives in the channel with the loud prefix.
TUI: merge consecutive tool-call rows on session resume
When loading session history, consecutive assistant audit rows
with empty content and a tool_calls JSON column used to
render as separate ⏺ [tool calls: shell] blocks. They now
collapse into a single ⎿ [N tool calls: ...] line. Detection
handles both the new audit format (content = [tool calls: ...]) and the legacy empty-content + tool_calls JSON.
[Unreleased] -- 2026-05-15
Codex provider: gpt-5.5 model, streaming-only complete()
OpenAI removed gpt-5-codex from the ChatGPT-account Codex endpoint
and the endpoint now requires stream=true on every request.
- Catalog default model is now
gpt-5.5. The wizard's "Default
model is gpt-5-codex" hint is updated to match. OpenAiCodexProvider::complete()is now a thin collector over
stream()-- one place owns the SSE plumbing, 401 refresh, and
retry logic.
Migration: existing ~/.cmagent/providers/openai-codex.toml
files that have model = "gpt-5-codex" will fail at runtime once
the server stops serving the old id. Edit the provider config to
model = "gpt-5.5" (or re-run cmagent config provider -> Import from Codex CLI).
Provider config: max_output_tokens / temperature capability flags
FeaturesConfig gains max_output_tokens: Option<bool> and
temperature: Option<bool>. When false, the request payload omits
the corresponding parameter. The ChatGPT-account Codex endpoint
rejects both fields with 400 errors; its backend defaults now set
both to false. All other backends default both to true, no
behaviour change. Only the openai_codex provider currently checks
these flags at request-build time -- doc-comments on the fields
spell that out so nobody sets temperature = false on an anthropic
config and expects temperature to disappear.
Tools: merge multi_edit into file_edit
multi_edit is removed; file_edit now accepts both shapes:
- single edit (legacy):
{path, old_string, new_string} - batched edits:
{path, edits: [{old_string, new_string}, ...]} - cross-file batch:
{edits: [{path, old_string, new_string}, ...]}
Single-edit input keeps the historical
"Replaced N occurrence(s) in PATH" output; batched input returns
the per-edit success/failure summary that multi_edit used to
produce. The line-ending-tolerant + indent-tolerant + smart-quote
fallbacks apply equally to both shapes. Breaking for any caller that
hard-coded the multi_edit tool name.
Tools: line-ending / indent / smart-quote fallbacks in file_edit
apply_replace (used by file_edit) now tries three fallbacks
before reporting "old_string not found", each gated on a
unique-match check so we never silently edit the wrong location:
- CRLF/LF normalisation -- Windows checkouts edit cleanly even
when the model emits LF in tool-call JSON. - Leading-whitespace tolerance -- recovers when the model
misremembered indentation. Multiple matches surface
"matches N locations when indentation is ignored" instead of
silently picking one. - Typographic-punctuation normalisation -- catches smart quotes
/ em-dashes / NBSP slipping in through browser/chat copy paste.
The fallback ladder is documented in the
"old_string not found" hint message that now points at
apply_patch for ambiguous or large changes.
Prompt: edit-tool selection guidance for every model
Previously only family-specific guidance (OPENAI_TOOL_GUIDANCE,
GLM_TOOL_GUIDANCE, ...) shipped, and none of them mentioned
apply_patch. Result: every long edit defaulted to file_edit,
which then tripped on old_string verbatim mismatch.
A new shared "Choosing an edit tool" block injects for every
model (Claude included). It spells out file_edit vs
apply_patch vs file_write, and specifically says to switch to
apply_patch after a single failed old_string not found. The
file_edit and apply_patch tool descriptions now reinforce the
same selection logic.
TUI: input soft-wrap + grapheme awareness + scroll-to-cursor
Long input lines no longer overflow the terminal width. The wrap
walks the buffer by grapheme cluster (so emoji ZWJ sequences and
combining marks stay intact) and reflows automatically on resize.
When the wrapped buffer outgrows the visible window, the input
scrolls so the cursor stays on screen.
TUI: paste-burst detector hardening
Previous detector turned any Enter within 10ms of the prior key
into a paste-newline. New tracker requires three consecutive
fast-typed Char events before classifying Enter as part of a
paste, plus a 120ms post-burst window for the trailing newline that
often lags the last char. Windows uses a 60ms threshold instead of
8ms because console event delivery has wider jitter.
Provider: per-turn reasoning effort selector
thinking.reasoning_effort = "auto" (also surfaced as the "auto"
thinking mode in the provider-add wizard) picks low / medium / high per request based on the user's latest message. High keywords
(debug, error, crash, plus CJK equivalents) → high; low keywords
(search, lookup, find, plus CJK) → low; everything else →
medium. Fixed-tier configs are unchanged.
Provider: retry on transient failures
retry_max and retry_backoff_ms (previously dead config fields)
now drive a real retry wrapper. Only ErrorCategory::is_transient()
failures (network, 5xx, rate-limit) retry; auth / quota /
bad-request / config errors fail fast. Backoff is exponential with
full jitter, capped at 30s. retry_max = 0 (the default) leaves
the inner provider unwrapped so existing configs see no behavioural
change.
Provider: error taxonomy + estimated session cost
ProviderError::category() returns ErrorCategory { Auth, Quota, RateLimit, Network, Server, BadRequest, Config, Unknown } with
label() / is_transient() helpers so retry policy and UI labels
stop re-implementing the HTTP-status + body-keyword classifier on
every site.
The catalog now propagates input_cost_per_mtok /
output_cost_per_mtok into the saved provider config, and the
status view shows an Est cost line under token usage. Hidden when
both prices are zero (local providers, custom endpoints).
Codex: encrypted reasoning items round-trip across turns
The openai_codex provider already requested encrypted reasoning
items but discarded them on receive. They're now captured (both
non-streaming and streaming paths) and stored on the assistant
ChatMessage; the next request emits them back unchanged in the
input array (without the local id, which store=false cannot
resolve) so multi-turn reasoning chains stay coherent. Cross-turn
id-dedup avoids duplicate replays.
Docs: architecture map
docs/architecture.md covers crate layout, turn flow, streaming +
rendering pipeline, security layers, extension traits, and a
"Where to look for ..." index spanning every subsystem that's
landed since v0.1.0.
[Unreleased] -- 2026-05-14
Provider: import from Codex CLI
cmagent config provider -> Import from Codex CLI reads
~/.codex/auth.json (or $CODEX_HOME/auth.json) and wires the
appropriate provider config without any hand-editing.
Two flows, depending on how codex was logged in:
- API-key flow: writes a standard openai provider config and
saves the key to<base>/.env. Billed against the OpenAI API. - ChatGPT-account OAuth flow: writes a new
openai_codex
provider that talks the Responses API at
chatgpt.com/backend-api/codex. Tokens are copied (not shared) to
<base>/data/codex_auth.jsonso cmagent and the codex CLI never
fight over the same refresh token; cmagent refreshes via
auth.openai.com/oauth/tokenahead ofexpand on 401. The
wizard requires an explicit one-time ToS acknowledgement before
importing -- using a non-codex client with ChatGPT-account
credentials may violate OpenAI's terms.
cmagent doctor reports the status of both
~/.codex/auth.json and cmagent's own token store. Walkthrough in
docs/codex-import.md.
Provider: live model lists for Ollama and LM Studio
The provider-add wizard previously offered a hard-coded "common
examples" list for Ollama and never asked LM Studio at all, so users
had to type model ids by hand. Catalog entries now carry an optional
dynamic_models_url; when present, the wizard probes the endpoint
(Ollama /api/tags and OpenAI-style /v1/models shapes both
recognised) and offers the live list with a manual fallback. Probe
failures degrade to the static catalog list with a warning.
[Unreleased] -- 2026-05-01
Preserve reasoning_content across turns (DeepSeek V4 thinking mode)
DeepSeek V4 returns a reasoning_content field on assistant turns
(thinking mode) and rejects follow-up requests that don't echo the
prior turn's reasoning chain back:
API error 400: The reasoning_content in the thinking mode must
be passed back to the API.
The OpenAI provider was stripping reasoning entirely when
serializing message history. Three changes round-trip it:
-
ChatMessagegainedreasoning_content: Option<String>
(skip-serializing-if-none for back-compat with old session
JSON and providers that don't accept the field). -
New constructor
ChatMessage::assistant_with_tools_and_thinking;
the agent's turn-loop now uses it for both tool-call turns and
final-answer turns so every prior assistant message carries its
reasoning. -
The OAI request body serializer
(OpenAiProvider::build_request_body) only includes
reasoning_contenton outgoing messages when the model is in
the round-trip allowlist (requires_reasoning_round_trip,
currentlydeepseek-v4-*). Other thinking-mode models on the
OpenAI-compatible wire (DeepSeek R1's docs say to omit
reasoning_content; GLM / Qwen behavior unverified) are
unaffected -- they still get the prior pre-fix behavior of no
reasoning in messages. Storage inChatMessagehappens
regardless so the data survives a model switch within a
session.Additional case: legacy session histories saved before
ChatMessagegained the field have None on every assistant
turn. For V4 the field MUST be present (DeepSeek rejects
follow-ups whose prior assistant turn lacks it), so the new
build_reasoning_content_for_messagehelper emits an empty
string in that case -- the field is there, just empty, which
V4 accepts. Without this fallback, switching an existing
session to V4 would 400 on the first turn.
Provider catalog: deepseek-v4-pro / deepseek-v4-flash flipped
to supports_thinking = true to reflect actual V4 behavior.
Tests:
crates/cmagent-provider/src/types.rs::tests(4):
assistant_message_omits_reasoning_when_absent,
assistant_with_thinking_preserves_reasoning,
assistant_with_empty_thinking_drops_field,
chat_message_deserialize_back_compat.crates/cmagent-provider/src/openai.rs::tests(1):
round_trip_only_for_deepseek_v4locks in the allowlist
so other models (R1, V3, GPT, GLM, Qwen) stay opt-out.
/model picker: read providers from disk on every open
Editing a provider via the in-TUI /config wizard wrote the new
config to ~/.cmagent/providers/*.toml but the subsequent
/model picker still showed the snapshot loaded at TUI startup.
Users had to exit and re-launch to pick the new model. Both ends
fixed:
tui2/key_handler.rs::UserCommand::Modelnow does
ConfigLoader::from_default().load_providers()on each open
(matching the/agentpicker's existing fresh-load pattern)
instead of using the cachedopts.providerssnapshot.agent/commands.rs::handle_command(UserCommand::Model)reloads
providers_configfrom disk before resolving the new
provider_id:model_id. Without this, edits tobase_url/
api_key_envmade via/configwould be ignored on switch
because the agent's snapshot was taken at build time.
DeepSeek provider catalog
Add the V4 lineup (deepseek-v4-pro, deepseek-v4-flash) to
assets/provider_catalog.toml per the model ids returned by the
DeepSeek API. deepseek-v4-pro is the new default; flash ships
as a cheap-tier alternative. Old deepseek-chat (V3 alias) and
deepseek-reasoner (R1) entries kept for backward compatibility.
Pricing is a placeholder until DeepSeek publishes official numbers.
Doctor: provider key diagnostics
cmagent doctor now prints the masked api key + length next to
each provider's "set" line so users can sanity-check the key
cmagent loaded against what their dashboard shows. Catches stale
.env files and copy-paste truncations that the bare "is set"
line previously hid. Trailing whitespace also gets a dedicated
warning since load_env_file already trims it but the dashboard
might not.
Lunkr p2p: keyboard response classifier bound to fid=1212
The Lunkr server moved inline keyboard responses from fid=1211
(multiplexed with the chat-window-opened P2pPing) to fid=1212
(dedicated channel) in the 2026-04-27 protocol revision. The
classifier in cmagent_channels::lunkr::p2p previously matched
attachments[0].t == 22 on any fid -- behavior happened to keep
working under both protocols, but the loose check would also
classify any future stray t=22 payload on an unrelated fid as
a keyboard response.
p2p::classify now requires the canonical wire shape
(fid==1212 && attachments[0].t==22) for the KeyboardResponse
arm. Legacy fid=1211 + t=22 payloads fall through to
OtherSignal; bare t=22 with no fid falls through to
NotSignal. The fid==1212 branch documents the exact line to
widen if a mixed-server environment ever needs to bridge to
older Lunkr backends. [9ba5b9f]
Tests added/updated: test_classify_keyboard_response (now
includes fid=1212), test_classify_keyboard_response_legacy_fid_1211_no_longer_matches,
test_classify_keyboard_response_no_fid_no_longer_matches, and
test_classify_keyboard_response_priority_over_other_p2p_meaning.
Doc updates: crates/cmagent-channels/src/lunkr/p2p.rs module
header (fid layout table), keyboard.rs module header (wire
format note), and the dispatch comment in lunkr/mod.rs all
describe the new fid layout.
[Unreleased] -- 2026-04-22 to 2026-04-24
Highlight: Unified messaging tools
Replaced the per-channel outbound tool surface (lunkr_send_message,
telegram_send_message, <kind>_search_contacts, ...) with two
action-dispatched tools:
messaging_query(Low risk) -- read surface:list_channels,
describe_channel,search_contacts,list_chats,
list_messages,download_attachment.messaging_send(Medium risk) -- write surface:send_message,
send_file,edit_message,delete_message,send_buttons,
notify, plus platform-specific actions (send_embed,
open_modal,create_thread,add_friend,accept_friend,
send_pat).
Channels: Lunkr, Telegram, Slack, Discord, WeChat. The LLM uses
describe_channel to learn what each adapter supports before
attempting a call. Inbound channel sessions are scoped: every action
except notify is restricted to the originating channel.
Design doc: docs/internal/plans/2026-04-24-unified-messaging.md.
Added
OutboundChannel::describe()returning aChannelDescriptor
capability sheet with structured per-action support objects (e.g.
send_buttons.wait_response = true). [03c863b]MessagingRegistrykeyed by channel kind with shared
Arc<dyn OutboundChannel>and operator-sideChannelMeta
(display_name,outbound_enabled,notify_target). [1028495]messaging_send.send_buttonsacceptswait_response: true+
timeout_seconds; Lunkr's p2p bridge backs the blocking flow.
[6d1db12]messaging_query.search_contacts/list_chats/list_messages
fully wired through to adapter methods (commit 3/5 had left them
asnot_implementedstubs). [b3f1cdf]- Centralised
cmagent_channels::outbound::CHANNEL_KINDSconstant;
removed five duplicated copies. [d2e11c1] - Channel scope detection in
src/infra.rs::build_agent(CLI / TUI
path) so resuming alunkr-*session enforces the same scope as
the gateway path. [d2e11c1] - Lunkr clone-role self-send guard (
check_self_send) with a clear
error message; only the operator's exact uid is rejected, not
every#Urecipient. [2d8398c] - Discovery wires
messaging_query/messaging_sendinto
discover_tool_names()so the config wizard andcmagent doctor
can see them. [48090b0] - Design document for the rollout:
docs/internal/plans/2026-04-24-unified-messaging.md.
[1f57984]
Changed
- Shipped agent profiles migrated:
chataddsmessaging_query
(read-only);codingandadminadd both tools. [6b545cc] - Lunkr / Telegram / Slack / Discord / WeChat registrations now
prefer the account withallow_outbound_send=trueover the first
enabled account so opt-in always wins regardless of TOML order.
[d2e11c1, 2d8398c] messaging_sendparameters schema documentstrigger_id,
target,message,request_id,wait_response,
timeout_seconds;refwidened to accept string OR object for
Lunkr's compound attachment shape. [d2e11c1]- Telegram / Slack / Discord / WeChat are registered in the
messaging registry even withoutallow_outbound_sendso
inbound-only sessions can still calldescribe_channel; send
actions stay gated byoutbound_enabled. [d2e11c1] cmagent doctorwarns when an agent's explicit tool allowlist
excludesmessaging_sendwhile at least one channel has
allow_outbound_send=true. [b0b41d7, 6b545cc]
Removed
- All
<kind>_send_message,<kind>_send_file,
<kind>_search_contacts,<kind>_list_chats,
<kind>_list_messages,<kind>_notifyper-channel tools. [6b545cc] lunkr_download_file(replaced by
messaging_query.download_attachment). [6b545cc]lunkr_send_keyboard(replaced by
messaging_send.send_buttons { wait_response: true }). [6d1db12]cmagent-tool::builtin::outboundmodule (legacy tool factories).
[6b545cc]- Synthesized
CHANNEL_OUTBOUND_HINTS.mdagent.md section --
unified tools always register, sodescribe_channelalready
surfaces availability. [6b545cc]
Other
TUI
/status"Core" / "Extended" tool lists word-wrap to terminal
width via the new[status:list]INDENT|LABEL|itemsmarker.
Narrow windows no longer drop tools off the right edge. [fe0eebe]- Streaming thinking preview collapses embedded newlines and word-
wraps long lines (CJK and ASCII alike). Continuation lines align
to the tree gutter. [e5f8632]
Outbound config wizard
Enable outbound send?prompt added to both new-account and
edit-account flows. Toggling off clears stalenotify_*keys.
[283782a]- Wizard now lists 5 outbound-capable channels (Lunkr, Telegram,
Slack, Discord, WeChat) consistently via the central constant.
[283782a, d2e11c1] - Workspace prompt in channel edit is optional rather than
required (previously refused to advance with an empty value).
[ba4076c]
Lunkr
- Image attachments are downloaded inline into
ChannelEvent.images
so vision-capable providers see them natively. [5a815c7] - Downloaded images persist under the workspace
.cmagent/dl/lunkr/
tree, namespaced by account so multiple accounts don't collide.
[1f24e36]
Vision
- Native-first fallback: when a provider exposes vision, images
ride along inChatMessage.images; otherwise thevisiontool
is registered as a fallback. Telegram image attachments are now
preserved end-to-end. [d290408]
Doctor
- Detects legacy
[security]schema (autonomy,max_tool_risk,
separate path fields) in user configs and points to the new
field names. [74016f0]
[Unreleased] -- 2026-04-21 to 2026-04-22 (security model refactor)
Three-phase refactor of the agent security schema. Final shape is
documented in docs/security-model.md.
Changed
max_tool_risk->max_skill_risk(gates skills + MCPs only,
not the tool allowlist). Path fields (allowed_paths,
workspace.extra_dirs,sandbox_extra_read_paths,
sandbox_extra_write_paths) consolidated into a single
extra_dirs = [{ path, mode }]list. [d3319df]- Skill / MCP filtering enforces the risk ceiling at runtime
rather than only at registration time. [16a72b2] autonomy->prompt_thresholdwith three values:medium
(ask for medium+high risk),high(ask for high only),never
(autonomous). [87aa5e0]
Added
docs/security-model.md: full description of the four-layer
security model and the runtime gate. [619867a]cargo fmt --allbaseline applied across the workspace. [422ca8b]- Hermes Agent research notes; Python cache added to
.gitignore.
[6db384b]
[Unreleased] -- 2026-05-18 (workspace.toml encapsulation)
Refactored
cmagent_config::workspace_toml::WorkspaceToml: new unified
read/write handle for.cmagent/workspace.toml. All five
previous direct read-modify-write call sites migrated to use
this type, eliminating the race where concurrent writers could
clobber unrelated sections (most notably[permissions]).
Exposes typed helpers for[workspace],[sessions], and
[permissions]; rawtoml::Valueaccess retained for future
call sites not yet modelled.src/workspace.rs:load_workspace_config/save_workspace_config
now delegate toWorkspaceTomlinstead of parsing and
serialising aWorkspaceConfigstruct in isolation.cmagent-gatewayutil:register_in_workspace/update_session_name
delegate toWorkspaceToml::update.cmagent-interfacetui_util:save_temp_model,save_workspace_setting,
rename_session,workspace_sessionsdelegate toWorkspaceToml.cmagent-interfaceworkspace_browser:load_sessions,
delete_session,rename_sessiondelegate toWorkspaceToml.