Skip to content

cmagent v0.3.4

Choose a tag to compare

@coremail-cyt coremail-cyt released this 31 May 02:13
· 26 commits to main since this release

Changelog

Notable changes by release. Date format YYYY-MM-DD.

0.3.4 -- 2026-05-31

Improvements

  • First /undo of a run notes that file edits aren't reverted: undo
    rewinds the conversation only -- files already written to disk stay
    changed. The /undo output 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 -- run cmagent update to 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 node dev server) are killed on exit:
    a long-running command the agent started (npm run dev, node server.js) survived quitting cmagent. kill_on_drop only signalled
    the direct child (sh -c ...), orphaning the real node grandchild.
    cmagent now kills the whole process tree -- a process-group
    SIGKILL on Unix (the child is setsid'd, so its PGID is known) /
    taskkill /F /T on Windows -- both for the explicit shell kill
    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) fired SessionStart but never SessionEnd and 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 shared finalize_tui_session teardown
    (hook + context save + sandbox cleanup), so they can't drift again.
    Child-process kill on exit is unchanged (it happens via Agent drop
    • kill_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_query is 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: rename and rename_file
    apply the server's workspace edits across files.
  • cmagent doctor gained 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
    help tool the agent can read to answer "how does cmagent do X",
    and a cmagent docs [topic] CLI to read them from the terminal. The
    help tool 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_patch accepts 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_search and glob_search honour .gitignore, so results
    stop being drowned in target/, 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);
    @path mentions 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, \r progress overlays, and cursor/erase
    sequences was fed raw into the canvas renderer, drawing [0m junk 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. a node dev server) kept running after the TUI closed,
    so Ctrl+C left 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), /quit listed once (not
    /quit + /exit), consistent colour and grouped wrapping for long
    /help rows, and a consumed /steer echo is struck through in place.
  • cmagent doctor --fix reconciles installed agent tool-lists
    (drops the dead multi_edit name, adds lsp_query where the profile
    should have it).
  • Build on macOS/BSD: the libc dependency 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 run now 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). run is 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-tools provider.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 / /queue unless the
    recalled kind matches the current busy_input_mode default (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 in shared/recall.rs keep 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. New general.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 / /queue are 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 new learn_rule tool.
    After you confirm, it's appended to the agent's
    ~/.cmagent/agents/<name>/LEARNED.md and becomes part of its system
    prompt from the next session -- a plain, hand-editable, portable file
    loaded alongside RULES.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 the tools allowlist
    (added to chat, coding, admin). Contextual one-offs still belong
    in brain memory; this is for always-on directives.
  • cmagent doctor now flags agents whose installed config.toml omits a
    tool the current shipped template lists (e.g. learn_rule after an
    upgrade -- cmagent init never overwrites an existing config). It's an
    advisory only; you add the line yourself, since the tools allowlist 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/cancel now 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_update now carries the tool kind and the
    output as a content block so the client shows what a tool did, not
    just that it finished; session/load replays the persisted
    conversation as session/updates so a resumed session isn't blank;
    the agent's user-invocable slash commands are advertised via
    available_commands_update on session start; and the Plan tool's
    state is mirrored to ACP plan updates. A tool that finished within
    one poll window now still emits its initial tool_call before the
    tool_call_update (clients drop updates for a toolCallId they 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 (~/.cmagent or $CMAGENT_HOME): agent
    profiles, skills, providers/*.toml (API keys), config.toml, data
    DBs. This is an absolute deny -- unlike ordinary forbidden_paths
    it is not exemptable by extra_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 widen tools or
    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
    a file_write / file_edit outside 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. never still 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 the path and file_path
    argument spellings.

TOCTOU-safe atomic writes for credential / state files

  • The OAuth token store and cron jobs.json are now written atomically
    (a fresh random temp file created with O_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): the libc dependency was
    gated to cfg(target_os = "linux"), but spawn::detach_session_in_child
    is #[cfg(unix)] and calls libc::setsid() -- so macOS/BSD failed to
    compile (setsid not found in crate libc). 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):
    brain search was plain LIKE substring matching, unranked. It's now
    backed by an FTS5 trigram index (BM25-ranked, multi-term), which
    unlike the default unicode61 tokenizer can tokenize space-less
    scripts -- CJK keyword recall actually works instead of treating a
    whole run as one token. Queries < 3 chars (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_mouse now 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-ran build_turn_cells (markdown
    parse + syntect highlighting) for every turn in turn_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 /verbose change 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_selection over cache.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 per point_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_scroll is O(1), and point_at is 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_at tests, now routed through the cache).
  • Canvas TUI: cheap wrapped-row measurement (the real busy-run CPU
    cost)
    : a perf profile during an LLM run showed the hot path was
    grapheme-cluster segmentation (unicode_segmentation::Graphemes::next
    ~21%, ratatui WordWrapper::next_line ~8%) on the main thread --
    not the network workers. It came from the wrapped-row index above
    calling ratatui's line_count() on EVERY line of the whole transcript
    on each content change, and line_count runs 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 to line_count for 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 against line_count in tests).
  • Markdown renderer loads syntect once, not per render: a streaming
    perf profile then showed the remaining main-thread cost was syntect
    reloading its entire bundled syntax + theme set on EVERY markdown
    render -- AnsiRenderer::new called SyntaxSet::load_defaults_newlines
    • ThemeSet::load_defaults each 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 (the serde/flate2/ScopeRepository cluster in the profile).
      Both are now process-wide OnceLock singletons 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
    mmdflux 2.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 ```mermaid blocks --
    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 to width_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_path are now interchangeable in tool calls: file
    tools (file_read, file_write, file_edit, diff_preview,
    apply_patch, list_dir) name their argument path, but models
    trained on Claude Code's file_path convention frequently sent
    {"file_path": ...} and got a confusing path is required back (and
    lsp_query uses file_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_arg also accepts file_path directly as a
    fallback. No per-tool changes, no allowlist.
  • cmagent doctor detects (and --fix repairs) 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, which cmagent init (writes only missing files) and
    the wizard (reads the catalog only at creation) never update. Doctor
    now compares each provider config's max_context_tokens to the
    catalog model's context_window and warns when it's meaningfully
    lower (>10% gap, so rounding like 204800 vs 205000 isn't flagged);
    cmagent doctor --fix raises 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_output so the two can't drift).
  • Re-install / skill update no longer wipes a plugin's slash
    commands
    : enabling slash commands for a plugin
    (skill slash <plugin> --enable) sets a user_invocable overlay in
    the manifest, but every (re)install rewrote the manifest entry with
    user_invocable: None, so reinstalling -- or skill 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-skills itself 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 every webnovel-writer/<child> manifest
    entry dangling -- and still printed "Removed" -- so a later
    cmagent skill update resurrected 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
    keeps webnovel from matching webnovel-writer.
  • cmagent skill update handled 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 (single SKILL.md or 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 changed requirements.txt stays
    installed.
  • Skill Python deps install under PEP 668: on a system whose Python is
    flagged EXTERNALLY-MANAGED (Debian 12+ / Ubuntu 23.04+), a skill's
    pip install -r requirements.txt was refused, so cmagent skill setup
    failed and the in-app plugin install reported "installed" while
    cmagent doctor kept 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 and cmagent 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 one response_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 doctor noise reduction: Proxy: none is now a pass (✔,
    "all providers connect directly") instead of an advisory -- a direct
    connection is the normal default, not a finding. The messaging_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 none mode: [browser] mode accepts "none" (also "off"
    / "disabled") and cmagent config browser offers 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 doctor reports it as a deliberate "disabled by config", not a
    warning. The cmagent config browser binary picker also gains an
    explicit auto choice (re-detect each launch) so you can reset off a
    pinned path. And cmagent doctor no 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 setting none. (none is
    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 -- sudo asking for a password,
    ssh, a git credential 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 agent shell
    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. New turn_status on each message; zero migration (old
    sessions load as "complete").
  • /undo and /redo are 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 / /redo show 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 proxy field 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 standard HTTP(S)_PROXY /
    ALL_PROXY env vars still apply. Settable via cmagent config
    (add + edit flows); cmagent doctor shows each provider's proxy
    source. Credentials in the URL are masked in logs. Motivated by
    reaching api.x.ai from 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 --provider flag now outranks a persisted in-TUI
    /model selection (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 --version and 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
    /help to find their way around.
  • cmagent doctor gains 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 via include_str!) with override chain:
    $CMAGENT_BRANDING env var > ~/.cmagent/branding.toml

    compiled-in default. Schema covers product name, banner
    ASCII, tagline, footer, and welcome-screen quick-start
    list. New cmagent_config::branding module exposes
    BrandingConfig::load() and render_banner_block /
    render_compact_version helpers used by all surfaces.

  • Workspace version unified: gateway / provider / tool
    crates were pinning their own version = "0.2.1" instead
    of version.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.json when the repo has no
    root-level SKILL.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.
  • 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.sh run automatically in each skill directory
    and the plugin root. The detector scans the plugin root plus
    the conventional subdirs scripts/, src/, tools/,
    dashboard/, server/. Every match contributes its own
    step, so a plugin shipping both a scripts/requirements.txt
    and a dashboard/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.
  • pip invocation tries python3 -X utf8 -m pip first, then
    python -X utf8 -m pip, pip3, and finally pip. On
    Debian / Ubuntu the pip / pip3 binaries belong to the
    python3-pip apt package and are often absent on minimal
    systems even when python3 is installed; the new ordering
    recovers without forcing the user to install the package.
    Every candidate also sets PYTHONUTF8=1 /
    PYTHONIOENCODING=utf-8 in the child env so Windows pip's
    auto_decode doesn't fall back to the system locale
    (CP936 / GBK on Chinese Windows) and reject a perfectly
    valid UTF-8 requirements.txt with 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_invocable manifest overlay
    (~/.cmagent/skills/manifest.toml). Lets the user enable or
    disable /<skill-name> slash exposure for an entire plugin
    without editing each SKILL.md (a re-install would overwrite
    hand edits anyway). cmagent's user-invocable: false default
    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 /config panel 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-name entries
    immediately. cmagent_config::ConfigLoader is now Clone so
    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": "..."}. When met=false the
    feedback is injected as the next user message and the worker
    runs another turn; when met=true the goal clears and the
    final response carries a [goal] DONE summary. Capped at 50
    iterations to bound runaway. Bare /goal reports current
    state; /goal clear cancels. Mirrors Codex CLI's /goal and
    Claude Code's v2.1.139 equivalent.
  • Session persistence: active goal text + iteration counter
    are written to a new context_state.goal_state column
    (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 in agent.toml:
    judge_provider + judge_model route the judge call to a
    separate provider (Anthropic's design: worker on Opus,
    judge on Haiku); max_iterations overrides 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 new AgentState.goal_text /
    goal_iteration / goal_max_iterations fields kept in
    sync from every goal mutation. The truncated text preview
    (32 chars) keeps the bar within narrow-terminal widths.
  • /clear archives the in-flight goal along with the rest of
    the conversation. A goal belongs to the conversation that
    set it; carrying it through a /clear would have the next
    message auto-driven by an invisible goal.

Plugin sub-agents

  • Claude Code plugin agents/<name>.md files are now spawnable
    via spawn_agent using 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
    AgentProfile with kind = 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 (no text chunks yet but a non-empty thinking
    buffer). The old code required partial_text to 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

  • coding agent / 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 like read -> 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_slash reports schema-quoting errors when
    the model uses the wrong param (slug is install-only,
    plugin + enable belong to set_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. slug and name are now accepted as aliases
    for plugin since models trained against the install
    action habitually reuse slug.
  • skill_manager list now shows a SLASH column and renders
    qualified <plugin>/<skill> names, so an LLM verifying its
    own set_slash call has a deterministic signal instead of
    mistaking the agent-profile "enabled skills" list (a
    separate gate) for slash-command exposure.
  • New skill_manager actions enable_slash and disable_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_slash keeps its
    {plugin, enable} shape for back-compat, with enable
    defaulting to true when omitted (the common-case intent).
  • skill_manager set_slash now re-reads the manifest from
    disk after save_manifest and 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-skills and the TUI autocomplete refresh now
    re-read the agent profile from disk on each call. Previously
    the reloader captured a snapshot of agent_profile.skills
    at session start, so a mid-session skill_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, TUI App::refresh_slash_commands); falls
    back to the startup snapshot when the profile becomes
    unreadable mid-session.

Plan tool

  • New update_plan tool (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.
  • SharedPlan slot on Agent; SQLite persistence via v007
    migration (context_state.plan_json column) so plans
    survive process restart and /switch.
  • /plan slash command prints the current plan; /plan clear
    drops it (user override; the LLM otherwise drives mutation
    via update_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 full handle_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 calls update_plan(advance).
  • Step status glyphs use geometric Unicode (filled square /
    triangle / empty square / dotted square) so the semantics
    are cross-cultural -- x for 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 when RetryingProvider is sleeping
    between attempts -- a stalled-looking turn is now visibly
    distinct from "stuck". Sub-second backoffs render as
    0.<digit>s so the user sees the wait is short.
  • New cmagent_provider::traits::RetryObserver async trait
    (with on_retry / on_done hooks) plumbed through the
    Provider trait via a default-noop attach_retry_observer.
    AgentRetryObserver mirrors the in-flight retry into
    AgentState.retry_status; cleared on on_done.
  • Agent::attach_retry_observer_to_provider() runs at agent
    construction AND on every /model swap so the indicator
    keeps reaching SharedState across provider swaps.

Goal progress chip and sidebar panel

  • Canvas TUI now surfaces an active /goal in 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_open to 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.
  • Closes the "/goal sidebar not shown" + "/goal progress
    display N/M not informative" gaps Daisy reported. tui2
    already carried a comparable goal 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. Detaches follow_tail
      on PageUp so the manual scroll doesn't snap back.

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 SKIP when 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's arguments bytes when xAI Grok packs id and
    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 introduces StreamEvent::ToolCallStart's
    initial_arguments field 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_effort parameter 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-Archive retries with backoff
    when antivirus scanning briefly holds the freshly built
    cmagent.exe lock.
  • Debug: /debug viewer renders the prompt in
    system -> tools -> messages -> params order 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.
  • TuiOpts gains a public agent_skills_filter: Vec<String>
    field. Any out-of-tree integration that constructs TuiOpts
    must populate it (empty Vec::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-skills without 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_frame reads content_block_start.input
      when non-empty. Some Anthropic-compatible providers (GLM via
      zhipu-anthropic) pack the full tool input there instead of
      streaming input_json_delta events; without seeding
      initial_arguments the accumulator finalised with empty
      args and tools rejected as "X is required".
    • parse_openai_sse_frame emits ToolCallStart when EITHER
      id or name is present (DeepSeek V3 reasoning mode splits
      them across separate frames), accepts function.arguments
      as either a JSON string OR a raw object, and walks every
      tool_calls[] entry instead of returning on the first.
    • SseFrameParser signature changed to Vec<StreamEvent>.
      Multi-payload frames (DeepSeek reasoning mode packs
      reasoning_content next to a tool_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_request now preserves non-empty
      id / name across multiple ToolCallStart events 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_tokens defaults overhauled:
    ProviderConfig::max_tokens is now Option<u32> (was u32
    with a 4096 default that capped most modern models well below
    their stated max). Resolved at load time via a three-layer
    fallback:
    1. User's ~/.cmagent/providers/<id>.toml explicit value.
    2. (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).
    3. None. OpenAI-compat senders omit the field (provider
      uses model default). Anthropic wire requires max_tokens
      so the builder falls back to 8192 last-resort.
      Catalog access cached behind provider_catalog::embedded()
      (OnceLock).
  • 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
    real tool_result rows land. The previous order
    assistant(tool_calls) -> tool_result("skipped") -> system(nudge) -> tool_result(real) violated OpenAI's grammar
    (tool must immediately follow the matching assistant),
    and our own orphan filter dropped the real results. ForceStop
    still back-fills because it returns without executing.
  • Orphan tool message filter: build_chat_request now
    routes context messages through drop_orphan_tool_messages
    before splicing them after the system prompt. Tool messages
    whose tool_call_id isn't in the most recent assistant's
    tool_calls are dropped (and counted in a single WARN). Used
    to be that legacy SQLite rows with unpaired tool entries
    forced /clear to recover; the filter now self-heals them.
  • Sub-agent inheritance fixes:
    • AgentSpawnerImpl::with_max_tool_iterations(n) -- the
      parent's session.max_tool_iterations is 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, and current_turn.tool_calls
      so Ctrl+O Activity shows nested work. Previously only
      live_tools was forwarded (in-chat spinner) and the
      Activity viewer saw only the wrapper spawn_agent call
      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_chat reasserts 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.

TUI rendering fixes (post-v0.2.1, batch 2)

  • Narrow-status truncation: fit_status_line budgets 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 checked left_w > cols, so a
    multi-byte right + 2-space combo could push the line past
    cols and the terminal wrapped it onto the input row.
  • Tool-line truncation respects terminal width: the tree
    rows under ├─ ⚙ tool used to be hard-capped at 80 / 60
    chars regardless of terminal width. print_chat_oneline
    now stores the full untruncated source (prefixed with a
    \x1F sentinel) in recent_lines and 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 through sanitize_chat_line, which stripped
    ANSI colour entirely. New sanitize_preview_line keeps SGR
    (16-color, 256-color, 24-bit truecolor), expands \t to 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_ended only fires when the count GROWS past
      the baseline (a new tool wave) rather than every render
      while ANY tool was live.
    • streaming_text baseline + 32-char threshold: DeepSeek
      reasoning mode emits stray single-char content deltas
      (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
    across cmagent-channels, cmagent-interface, and
    cmagent-security byte-sliced user-supplied &str without
    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_util module
    (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) and ask_panel::render's hardcoded
      max_panel_rows (15) disagreed -- panel painted 3 more
      rows than clear_popup_panel cleared, leaving a │ strip
      at the border row (which render_bottom_area doesn't
      clean while agent_busy = true).
    • set_panel_height / clear_popup_panel /
      render_popup_panel / clear_scroll_region all subtracted
      the constant FIXED_LINES instead 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-changed last_fixed_rows.
    • print_chat::chat_scroll_end returns panel_start - 1
      while a popup is open. Hardcoding rows - last_fixed_rows
      re-enlarged the scroll region behind the popup's back, and
      a print_system call 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_option swapped order:
      clear_popup_panel FIRST, then print_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, replay recent_lines bottom-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::ToolCall gains an optional
    parse_error: Option<String> field with #[serde(default)]
    and skip_serializing_if. Old session-history rows
    deserialise without the field. Out-of-tree code that
    constructs ToolCall literals must pass parse_error: None.
  • cmagent_config::provider::ProviderConfig::max_tokens is
    now Option<u32> (was u32). User TOML values that
    previously parsed as 4096 (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_tokens mirrors
    the same Option<u32> change.
  • cmagent_provider::types::SseFrameParser returns
    Vec<StreamEvent> instead of Option<StreamEvent>. All
    in-tree implementations updated; out-of-tree adapters need
    to return either vec![ev] or Vec::new().
  • cmagent_config::text_util is 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
    through ceil_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 cliclack under 3rd/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

  • cliclack select / 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 no initial_idx is given AND the list has
    five or more items. Combined with wrap-around this drops the
    worst-case keystrokes-to-any-item from len - 1 to
    (len / 2) + 1.

Tools

  • trash: switch from a hand-rolled "move into ~/.cmagent- trash/" implementation to the trash crate, which calls the
    platform-native API (IFileOperation on 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 for wait / kill actions now shows the
    task_id instead 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 skip tool_result
    placeholders. Prior order left orphan tool_result blocks
    with no preceding tool_use parent; on the next LLM call
    GLM's Claude-compat layer returned a server-side
    AttributeError, Anthropic-direct returned an invalid-request
    error, and /clear was 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
    \n event 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 update now streams the artifact via
    response.bytes_stream() and feeds a cliclack::progress_bar
    / spinner. Before: the download went through
    response.bytes().await which 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 workspace Cargo.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, additive context_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_tokens means
    "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_content preserved 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 to workspace.toml so a restart doesn't
    re-prompt.
  • Security model refactor: prompt threshold, shell parser,
    sandbox layering cleaned up.

Tools

  • file_edit absorbs multi_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.rs 1384 -> 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 via cmagent_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) +
    ReviewGuard Drop 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) -- LlmCallOutcome enum +
    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 on complete()/stream(), error vs cancel).
  • cmagent_tool::Tool -- name stability contract, flat schema
    requirement (Claude on Vertex and OpenAI tool APIs reject
    nested oneOf/anyOf at the root), risk-level effect on
    prompt threshold.
  • cmagent_security::Sandbox -- build_command is the sandbox
    boundary; is_available is the runtime probe used by
    auto-detection.
  • validate_navigate_url in the browser tool -- documented the
    three-layer SSRF guard (scheme allowlist, private-host block,
    secret-prefix scan on raw + percent-decoded URL).
  • wrap_untrusted in 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 the todos_json field 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
    used Utc::now() - 6h to 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::TodoItem gets serde
    Serialize/Deserialize; new helpers new_todo_list_from_json +
    serialize_todo_list bridge the in-memory SharedTodoList
    and the persisted JSON blob.
  • ContextManager mirrors the column as pub todos_json: String,
    same pattern as the existing tool_outputs_json field. Load
    populates, save persists, clear wipes.
  • Agent::new seeds the SharedTodoList from
    context.todos_json if the caller pre-loaded the context;
    Agent::reset_session does 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:451 after an empty-turn warning
  • turn_loop.rs:528 after each normal turn (the main path!)
  • commands.rs:65 after /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_messages was 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_tail runs 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_tokens only measured content. It ignored
    tool_calls JSON (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_tokens stays 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
    aggressive compact() 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 staged auto_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_summary now section-aware. Old code
    kept lines from the top of the prior summary until the char
    budget ran out, which dropped ## Pending User Asks and
    ## Exact Identifiers first because they sit near the bottom
    -- exactly the sections the LLM most needs to keep working.
    Now: parse into ## Heading sections, 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_files recognises \\ 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.
    Test compaction_prompt_is_domain_neutral locks 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 a Failed
    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_log and 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_tokens for 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_summary now 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)

  • TokenUsage now carries cache_read_tokens and
    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_control breakpoints
    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_tokens and
    cache_creation_tokens so 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 (so cargo test
    approves all future cargo ...); other tools store the tool
    name.
  • Persisted allowlists are loaded into the in-memory
    session_allowed_* HashSets at Agent::new, so an approval
    from the previous run takes effect on the next session without
    restart.
  • New /permissions slash 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.toml shell_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.log plus a
    tracing::error event 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 TuiNotifyLayer into 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:

  1. CRLF/LF normalisation -- Windows checkouts edit cleanly even
    when the model emits LF in tool-call JSON.
  2. Leading-whitespace tolerance -- recovers when the model
    misremembered indentation. Multiple matches surface
    "matches N locations when indentation is ignored" instead of
    silently picking one.
  3. 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.json so cmagent and the codex CLI never
    fight over the same refresh token; cmagent refreshes via
    auth.openai.com/oauth/token ahead of exp and 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:

  1. ChatMessage gained reasoning_content: Option<String>
    (skip-serializing-if-none for back-compat with old session
    JSON and providers that don't accept the field).

  2. 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.

  3. The OAI request body serializer
    (OpenAiProvider::build_request_body) only includes
    reasoning_content on outgoing messages when the model is in
    the round-trip allowlist (requires_reasoning_round_trip,
    currently deepseek-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 in ChatMessage happens
    regardless so the data survives a model switch within a
    session.

    Additional case: legacy session histories saved before
    ChatMessage gained 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_message helper 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_v4 locks 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::Model now does
    ConfigLoader::from_default().load_providers() on each open
    (matching the /agent picker's existing fresh-load pattern)
    instead of using the cached opts.providers snapshot.
  • agent/commands.rs::handle_command(UserCommand::Model) reloads
    providers_config from disk before resolving the new
    provider_id:model_id. Without this, edits to base_url /
    api_key_env made via /config would 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 a ChannelDescriptor
    capability sheet with structured per-action support objects (e.g.
    send_buttons.wait_response = true). [03c863b]
  • MessagingRegistry keyed by channel kind with shared
    Arc<dyn OutboundChannel> and operator-side ChannelMeta
    (display_name, outbound_enabled, notify_target). [1028495]
  • messaging_send.send_buttons accepts wait_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
    as not_implemented stubs). [b3f1cdf]
  • Centralised cmagent_channels::outbound::CHANNEL_KINDS constant;
    removed five duplicated copies. [d2e11c1]
  • Channel scope detection in src/infra.rs::build_agent (CLI / TUI
    path) so resuming a lunkr-* 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 #U recipient. [2d8398c]
  • Discovery wires messaging_query / messaging_send into
    discover_tool_names() so the config wizard and cmagent 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: chat adds messaging_query
    (read-only); coding and admin add both tools. [6b545cc]
  • Lunkr / Telegram / Slack / Discord / WeChat registrations now
    prefer the account with allow_outbound_send=true over the first
    enabled account so opt-in always wins regardless of TOML order.
    [d2e11c1, 2d8398c]
  • messaging_send parameters schema documents trigger_id,
    target, message, request_id, wait_response,
    timeout_seconds; ref widened to accept string OR object for
    Lunkr's compound attachment shape. [d2e11c1]
  • Telegram / Slack / Discord / WeChat are registered in the
    messaging registry even without allow_outbound_send so
    inbound-only sessions can still call describe_channel; send
    actions stay gated by outbound_enabled. [d2e11c1]
  • cmagent doctor warns when an agent's explicit tool allowlist
    excludes messaging_send while 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>_notify per-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::outbound module (legacy tool factories).
    [6b545cc]
  • Synthesized CHANNEL_OUTBOUND_HINTS.md agent.md section --
    unified tools always register, so describe_channel already
    surfaces availability. [6b545cc]

Other

TUI

  • /status "Core" / "Extended" tool lists word-wrap to terminal
    width via the new [status:list]INDENT|LABEL|items marker.
    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 stale notify_* 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 in ChatMessage.images; otherwise the vision tool
    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_threshold with 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 --all baseline 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]; raw toml::Value access retained for future
    call sites not yet modelled.
  • src/workspace.rs: load_workspace_config / save_workspace_config
    now delegate to WorkspaceToml instead of parsing and
    serialising a WorkspaceConfig struct in isolation.
  • cmagent-gateway util: register_in_workspace / update_session_name
    delegate to WorkspaceToml::update.
  • cmagent-interface tui_util: save_temp_model, save_workspace_setting,
    rename_session, workspace_sessions delegate to WorkspaceToml.
  • cmagent-interface workspace_browser: load_sessions,
    delete_session, rename_session delegate to WorkspaceToml.