Mermaid 0.25.0
Added
-
mermaid task <id> --send "<text>"-- you can talk to a task that is
already running. Attaching to a daemon task could watch it and kill it,
and that was all:subscribe_taskstreams a task's events and
cancel_taskfires its token, but nothing could put a message into a
run in flight. Noticing halfway through a twenty-minute task that you also
wanted the tests checked meant cancelling it or waiting it out and
starting again from--resume.The run now publishes an
EngineHandle-- its mailbox and its event bus --
the moment its engine exists, before the first model call. The daemon
registers it alongside the cancellation token it already kept, and drops
it with the task's event stream. The mailbox is the SAME channel every
effect result arrives on, which is the design and not an implementation
detail: a prompt sent from outside is indistinguishable from one the run
produced itself, so it goes through the same reducer, the same stale-turn
filter, the same recorder, and the same event log, and queues behind the
live turn exactly as a typed one does. There is no second way into the
state.A task that is not running says so and points at
mermaid run --resume,
rather than accepting a message with nowhere to go. The send is
non-blocking on the daemon's side, so a run that is behind on its own
effects cannot stall every other client.
Changed
-
Every provider streams through one read loop, and a slow reader now
reaches the socket instead of a queue. Five adapters each carried their
own copy of the same loop -- read a chunk, cap the reassembly buffer, split
frames, dispatch, decide whether the stream ended or was cut -- and only the
dispatch step was ever about the provider. The copies had drifted: Ollama
flushed the un-terminated frame left when a body closes mid-frame and the
four SSE adapters dropped theirs, so a server that ends its body directly
afterdata: {...}without the blank line loses its final frame on four
providers out of five. Nobody chose that; it is aFramingdecision written
down in one place now, with the reason next to it.What you can observe. Between the adapters and the screen sat an unbounded
staging channel and a relay task spawned per turn, there to stop aDone
event from overtaking a tool call the model had asked for -- which would
make the agent silently skip running it. The adapters' read loops were
already async, so they now await the send directly and the ordering holds by
construction, with no second channel to reorder anything. The bounded
channel that was always documented as the backpressure finally is it: a turn
whose consumer falls behind stalls the read and fills the provider's TCP
window, rather than growing a queue in memory. A turn also no longer leaks a
spawned task if it is cancelled at the wrong moment, because there is no
longer a task to leak.Meta joins the other four as a real adapter rather than a provider that
hand-rolled one, which is what removes the fifth copy of the loop. The wire
formats are now covered by one conformance suite: recorded response bodies
per provider, asserted in shared terms, so that Anthropic'smax_tokens,
Gemini'sMAX_TOKENS, OpenAI'slengthand Ollama'sdone_reason: "length"are provably the same fact to everything above them. Each scenario
runs twice, the second time one byte at a time, which is the first coverage
any adapter has had for a network chunk boundary landing inside a frame. -
The driving loop is a value now, and a timed-out headless run stops
leaking its MCP children.update(State, Msg) -> (State, Vec<Cmd>)is the
whole product; driving it is five lines -- stamp the clock, reduce, route the
commands, stop on exit -- and those five lines were written out longhand in
six places, each with its own spelling of the loop around them. The cost was
never the duplication, it was that fixes landed once per copy: #76 (a
timed-out child dropped its effect runner mid-flight and leaked the MCP
servers it was still holding) was fixed in the subagent's loop by moving the
deadline into theselect!, andmermaid runkept the identical bug,
unfixed, because it was a different function -- itstimeout()wrapper
returnedErrthrough?while the runner was still sitting on the caller's
stack.crate::engine::Enginenow owns the reducer state and the effect sink and
exposes that loop once, with three seams for the axes the callers really
differ on: where aCmdgoes (EffectSink), what watches each message
before the reducer consumes it (StepObserver), and when the loop stops
(DrivePolicy-- which turns "abort" versus "injectCancelTurnand give
the turn 15 seconds to unwind" from two undocumented behaviours into two
named ones). The deadline is aselect!arm, so a timed-out run still owns
its state and still reaches its own shutdown path.The kernel is deliberately synchronous and observer-free, because
--replay
folds a recorded log with no tokio runtime in sight; it now names dropping
the emitted commands as a policy (DropEffects) rather than leaving it to a
let (next, _cmds).All four drive loops are callers: the replay fold,
mermaid run, the
subagent's child, and the interactive TUI -- which keeps its ownselect!,
because terminal events and the$EDITORround-trip are genuinely
run-loop-owned, but now feeds onestepcall, and reaches its stated
"~30-line main loop" for the first time. What each of them contributes is
now a named thing rather than an inlined one: the--recordwriter and the
RunEventprojection are observers,Cmd::ComposeInEditoris a sink that
peels it off, and the child's progress relay is the observer wrapping the
state machine it already had.--replay,mermaid run,--record, the subagent, and the daemon's NDJSON
andsubscribe_taskstreams behave exactly as before -- theRunEventwire
is untouched and still v1. Seedocs/design/engine-extraction.md; the actor
form (send/subscribe), which daemon attach and multi-session need,
follows. -
subscribe_taskreplays what an attach missed instead of joining
from-now.mermaid task <id> --followused to start at whatever
happened next: attach at minute nine and you saw nine minutes of
silence, and never thesession_startedline that names the session --
the same empty-handed attach the terminal path was fixed for, still
shipping for every live one. The session event log is the durable record
of everything before the attach, so the daemon now reads it and replays
a catch-up first: identity, then the transcript committed so far as
coarsetext/reasoning/ tool lines (one per committed message
rather than the deltas that produced it), then the newest checklist.
The ack gained areplayedcount so a consumer that only wants what
happens from now skips exactly that many lines; theRunEventwire
itself is unchanged and still v1.Reaching the log mid-run needed the key to exist mid-run:
tasks.conversation_idwas stamped at terminal status, and is now
stamped when the run announces its session, with the end-of-run write
kept as the authority.mermaid task <id>shows the conversation while
the task is still running as a result. The receiver is attached before
the log is read, so a message committed during the read is replayed and
also delivered live -- an overlap bounded to that one message, and the
right way round, since repetition is recoverable by a consumer and a
hole is not. -
Modal precedence is one resolver, and every picker shares one
navigation core. Which surface owned a keystroke used to be a 25-deep
chain of early-return guards inhandle_key; which pane owned the bottom
of the screen was TWO hand-maintained ladders in the render layer (one
sizing the zone, one drawing it) that had to be edited in lockstep --
the family behind the vanished-composer bug.State::focus()now names
the precedence once (approval > question > confirm > picker > composer),
key routing dispatches on it, and the render layer's new single
BottomPanedecision derives from the same resolver -- what draws and
what receives keys are one authority, and the slash palette's filtered
entries are computed once per frame instead of once per ladder.The four
UiModepickers (model, conversations, rewind, plan config)
each hand-rolled the same Up/Down/Enter/Escape machine -- the
duplication family behind the paste-into-the-file-picker bug. The
machine now exists once (picker::picker_step); each handler keeps only
its confirm semantics and extra keys (query typing, value cycling). The
approval-modal and confirm-modal key handlers extracted verbatim into
named functions on the way. Frame output is pinned unchanged by the
snapshot and PTY suites. -
One store handle per process on the hot paths. Every runtime-store
touch used to run its ownRuntimeStore::open_default()-- the per-call
open, ACL probe, and migration check repeated on paths that fire per tool
call (tool-run bookkeeping, capability-probe caching, process upserts,
compaction rows). Those now share one process-wide handle via the new
with_shared_store, which opens lazily, never caches an open failure,
and evicts the handle after an operation error -- so a transient failure
(this store has lived on a drive that drops off the bus) self-heals
exactly like the open-per-call world did, minus the churn. One-shot
paths (CLI subcommands, startup, the daemon's own ownership of its
store) deliberately keep direct opens: the helper's doc states the
policy, and the store remains multi-process by design -- WAL plus the
owner-kind column keep a live session and a running daemon out of each
other's rows, which this change documents at the type. -
The tool-dispatch seam wears its three hats as three types. One tool
call used to cross the reducer/effect boundary as eleven loose fields on
Cmd::ExecuteTool, a 24-argumentdispatch_execute_tool, and a 16-argument
ExecContext::newfollowed by eight post-construction field-sets -- turn
plumbing, session identity, and policy inputs fused into one bag, with the
"field-set after construction on the live path" convention documented in
comments because the types could not say it. The seam now names them:
ToolDispatch(domain -- everything the reducer stamps from live session
state),TurnSignals(the owning scope's cancellation, background signal,
and shared web budget), andToolServices(the runner-bound config, task
ownership, and interaction back-channels).ExecContext::assemblebuilds
the flat context every tool already reads in one total step -- no
post-construction mutation -- and the six tool tests that each hand-rolled
the 16-argument constructor now share the one test helper. Checklist
evidence recording degrades to a graceful no-op without a broker instead of
requiring one, matching every other optional service. -
The pure MVU core no longer depends on
mermaid-runtime-- the crate
stack points one way.mermaid-domain's manifest-omission enforcement
("a reducer that wants to await cannot, because the runtime is not a
dependency") was true for direct use but not transitively: the crate
depended onmermaid-runtimefor shared vocabulary, pulling rusqlite and
the OS surface underneath the "pure" core. The vocabulary moved to the
bottom crate instead: the safety-policy types (SafetyMode,RiskClass,
ActionRequest,PolicyDecision, the floors,HostShell, the denial
markers) now live inmermaid_model::safety, and the durable-store row
DTOs inmermaid_model::records. Doing that flipped one edge the other
way --mermaid-modelitself depended onmermaid-runtimefor redaction
anddata_dir-- so those moved down too (mermaid_model::utils::redact,
mermaid_model::utils::dirs), completing the inversion:
model < runtime < domain < cli, acyclic, withmermaid-runtime
re-exporting every name its own API surfaces so no caller path changed.Two enforcement artifacts record the new shape. The layering guard's
may_useformermaid-domainandsrc/renderdropsruntimeentirely
-- namingmermaid_runtimefrom the pure layers is now a guard failure,
not just a taste violation. And the release pipeline publishes in the new
dependency order (model, runtime, domain, cli). -
The read-only lookup family is one request/response envelope. Eleven
Cmdvariants (ListConversations,LoadConversation,
ListAvailableModels,ListProjectFiles, and the seven
/runtime-family listings) paired 1:1 with elevenMsgvariants, each
pair carrying its own reducer arm and its own spawn-and-wrap block in the
effect dispatcher -- identical shape everywhere: ask for a listing, get a
value back, no turn scoping, no side effects. They are now one
Cmd::Query(Query)answered by oneMsg::QueryResult(QueryResult), with
the request/response pairs written down once in the newquerymodule.
The reducer routes results through a singlehandle_query_result; the
effect dispatcher runs them through a singledispatch_query(bodies
unchanged -- conversation reads and provider discovery stay async, store
reads and the project walk stay on the blocking pool).Cmd::tag()and
Cmd::summary()delegate to the query, so traces and--recordfiles
carry the exact strings they always did, andQueryResult's variants
keep the oldMsgnames so a recording reads the same one level deeper.Replay compatibility: recordings made before this change replay their
query-result lines as skipped entries (the same shape any newer-build
recording takes); everything else replays unchanged.MsgKindfolds the
four listing kinds and the runtime-store collapse into oneQueryResult
kind, withRuntimeStorekept for the generic text response. -
The three capability sources have their precedence written down, and
the static baseline is one constructor. Model capabilities come from
three places -- live probes (provider/models, Ollama/api/show,
cached inprovider_probes), the static catalog, and the adapter's own
advertised baseline -- but the precedence lived in scattered comments,
andcapabilities.rsstill introduced itself with "for Step 1 the
values are hardcoded; a future step can add per-model lookup or runtime
probing" -- a future that shipped long ago. The module doc now states
the chain (probe > catalog > adapter static) and where each source
lives. The five adapters that each hand-built the same struct -- five
copies of "tools on, windows None, no continuation" varying only in
vision and reasoning enum -- now route through one
ModelCapabilities::advertised(vision, reasoning)constructor, so
"static windows stay unknown until live discovery" is a property of the
constructor rather than a comment repeated per provider. Meta keeps its
documented muse-spark family limits by explicit struct-update over the
baseline -- the one sanctioned exception, now labeled as such. -
policy/mod.rssplits along its seams. The last un-decomposed file
in the policy module carried three concerns: the vocabulary (SafetyMode,
RiskClass,ActionRequest,PolicyDecision, the floors), the engine
that folds them into a verdict, and ~1,900 lines of end-to-end policy
tests. The vocabulary now lives inpolicy/types.rs, the engine and its
helpers inpolicy/engine.rs(tests ride with the engine, since a
verdict is only meaningful end to end), andmod.rsis the gateway that
keeps every existingcrate::policy::*/mermaid_runtime::*path
working. The now-unconsumedpub(crate) use shell::*glob is gone --
engine and tests import what they use by name. No behavior change. -
wrap.rs's tests moved home. The wrap extraction left its ~15 tests
behind inchat.rs's test module —wrap.rsitself showed zero tests while
the suite that pins its CJK widths, phantom-space, hard-break, and
style-preservation behavior lived in a different widget's file. The tests
(and theirfirst_segment_texthelper) now sit inrender::wrap::tests,
byte-identical;chat.rs's module keeps only chat-owned coverage. Same
cleanuptransition.rsgot, applied to the remaining instance. -
transition.rsis the turn-state machine its doc says it is. The
module opens with "helpers that enforce invariants during turn-state
transitions" — and then ~600 of its ~790 code lines were presentation:
action_display_for,display_info_for, the per-tool detail shaping
(diff summaries, web-fetch provenance lines, subagent spend), duration
and pluralization formatting. All of it moved byte-identically, tests
included, intoaction_display.rs, whose own doc says what it actually
is: tool calls and outcomes rendered as transcript action rows. The
state machine that gates "no follow-up model call with missing tool
outcomes" is now a ~150-line module a reader can hold in one look, and
the cohesion break stopped hiding behind a load-bearing doc comment. No
behavior change; the crate-root re-exports keep every consumer's names. -
Slash-command arity and usage text live in the command registry, not in
the reducer. Fourteen commands require an argument (/task,/pause,
/resume,/logs,/stop,/restart,/open,/approve,/deny,
/checkpoint,/restore,/model-info,/remember,/forget), and each
one carried a hand-typed"Usage: /…"string in its own reducer arm — 18
literals that could drift from the palette's argument hints, because the
registry that owns those hints carried no notion of arity. The hint's
bracket is now load-bearing (<...>required,[...]optional): the parser
consults it, answers a bare or blank invocation with a usage line derived
from the registry entry, and the fourteenSlashCmdvariants carry a plain
String— the missing-argument state is unrepresentable past the parser,
and the fourteen usage arms are one.Three user-visible edges moved with it. A required-arg command with a
blank argument (/task— trailing space) now shows usage instead of
dispatching an empty id downstream./remember's usage line reads
Usage: /remember <fact>(derived from its palette hint). And/restore's
palette hint gained precision (<checkpoint-id>) so the hint and the usage
line agree. Recordings from before this change replay unchanged except for
bare-invocation slash lines ({"Task":null}), which replay as skipped
entries — the shape a newer-build recording already takes. -
A new turn-scoped message or command can no longer silently skip the
staleness gates.Msg::turn_id()feeds the reducer's stale-turn filter
andCmd::scope_turn()feeds the effect runner's cancelled-turn tombstone
check — and both ended in a_ =>wildcard, the one construction
update_step's own exhaustiveness guarantee could not reach. A
turn-carrying variant added without touching those two lists would have
compiled clean and bypassed staleness/tombstone checking entirely, which
is precisely the bug class the filters exist to make impossible. Both
matches now name every variant and deny the two wildcard lints the same
wayupdate_stepdoes, so the next variant is a compile error until its
author decides which side of each gate it belongs on.Cmd::is_turn_scoped
is now defined asscope_turn().is_some()— the two were documented as
the same set but maintained as separate matches.