Skip to main content

mermaid_cli/effect/
mod.rs

1//! The effect runner: dispatches `Cmd` values into tokio tasks.
2//!
3//! There are exactly two places in the codebase that spawn a tokio
4//! task: this module and tests. Everywhere else asks the
5//! reducer to return a `Cmd`, and the runner handles it. That
6//! centralization is what makes structured concurrency per turn
7//! actually work — nothing can accidentally spawn a detached task
8//! that outlives the turn it was started for.
9//!
10//! Architecture:
11//!
12//! ```text
13//!   main loop ── reducer ── Cmd ── dispatch ── EffectRunner
14//!                                                 ├── TurnScope(turn A) ── JoinSet
15//!                                                 ├── TurnScope(turn B) ── JoinSet
16//!                                                 └── detached effects (Save, Exit, …)
17//!                                                       ↓
18//!                                              Msg via mpsc::Sender<Msg>
19//!                                                       ↓
20//!                                                 main loop (next iteration)
21//! ```
22//!
23//! The runner dispatches every `Cmd` variant to a real handler —
24//! model streaming (`CallModel` → `ModelProvider::chat`), tool
25//! execution (`ExecuteTool` → `ToolExecutor::execute`), persistence
26//! (`SaveConversation`, `LoadConversation`, `PersistLastModel`,
27//! `PersistReasoningFor`, `RefreshInstructions`), MCP lifecycle
28//! (`InitMcpServers`, `StopMcpServer`), local side-effects
29//! (`WriteImageToTemp`, `OpenInSystem`, `PullOllamaModel`,
30//! `SetTerminalTitle`, `DismissStatusAfter`). Cancellation flows
31//! through `Cmd::CancelScope(TurnId)` → the scope's
32//! `CancellationToken`.
33
34mod config_watch;
35mod middleware;
36mod turn_scope;
37
38use std::collections::HashMap;
39use std::collections::VecDeque;
40use std::path::PathBuf;
41use std::sync::Arc;
42use std::time::Instant;
43
44use tokio::sync::mpsc;
45
46use crate::app::{Config, MemoryConfig};
47use crate::domain::{
48    Cmd, CompactionPolicy, CompactionRequest, CompactionResult, CompactionTrigger, Msg, TurnId,
49};
50use crate::models::{ModelError, TokenUsage};
51use crate::providers::ctx::{ExecContext, StreamContext};
52use crate::providers::model::ModelProvider;
53use crate::providers::{ProviderFactory, StreamEvent, ToolRegistry};
54
55pub use middleware::{DEFAULT_MAX_ATTEMPTS, retry_transient_http};
56pub use turn_scope::TurnScope;
57
58#[cfg(not(test))]
59const CANCEL_DRAIN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
60#[cfg(test)]
61const CANCEL_DRAIN_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(50);
62
63/// F38: how many recently-cancelled `TurnId`s to remember as tombstones.
64/// Turn ids are strictly monotonic and never reused, so a stray turn-scoped
65/// `Cmd` for a cancelled turn can only ever be a post-cancel straggler that
66/// lands within a few turns of the cancel. A small bounded ring is plenty;
67/// older entries age out so the set never grows across a long session.
68const CANCELLED_TOMBSTONE_CAP: usize = 256;
69
70/// Single channel back to the reducer. `EffectRunner` holds the
71/// sender; every spawned task clones this so it can emit `Msg` as
72/// work progresses. Bounded capacity applies natural backpressure —
73/// if the main loop can't keep up, the provider's streaming send
74/// `.await`s and the whole pipeline throttles.
75pub type MsgSender = mpsc::Sender<Msg>;
76
77/// Bounded channel capacity for the effect → reducer stream. 512 is
78/// generous — a single streaming chunk fits comfortably, and the
79/// main loop drains at ~60 Hz so backlog rarely grows. Bigger wastes
80/// RAM; smaller introduces spurious backpressure on bursty tool
81/// output.
82pub const MSG_CHANNEL_CAPACITY: usize = 512;
83
84/// The runner. One instance per process, constructed by
85/// `app::run` and consumed when the main loop exits.
86pub struct EffectRunner {
87    msg_tx: MsgSender,
88    /// Per-turn scopes. Populated lazily: the first `Cmd` bearing a
89    /// TurnId creates a scope; `Cmd::CancelScope` tears it down.
90    /// Empty (drained) scopes are reaped by `reap_empty_scopes`, which
91    /// runs at the top of every `dispatch` call so the map stays
92    /// bounded across long sessions (F12).
93    scopes: HashMap<TurnId, TurnScope>,
94    /// F38: bounded tombstone ring of `TurnId`s whose scope has been
95    /// cancelled+dropped. A turn-scoped `Cmd` (`CallModel` / `ExecuteTool` /
96    /// `CompactConversation`) bearing a tombstoned id is dropped in `dispatch`
97    /// instead of resurrecting a fresh, un-cancelled scope through
98    /// `scope_mut`'s `or_insert_with`. Bounded to `CANCELLED_TOMBSTONE_CAP`.
99    cancelled_turns: VecDeque<TurnId>,
100    /// Detached work (saves, persists, MCP lifecycle) lives here.
101    /// This one set never gets cancelled piecemeal — shutdown drains
102    /// it during `EffectRunner::shutdown`.
103    detached: tokio::task::JoinSet<()>,
104    /// MCP manager handle is held elsewhere (`crate::mcp` has a
105    /// `OnceLock` for its global manager); we just note workdir so
106    /// handlers can construct absolute paths.
107    workdir: PathBuf,
108    /// Lazy provider registry. `CallModel` resolves through this.
109    /// Tests that don't care about real providers leave this `None`
110    /// and observe the fallback `UpstreamError` Msg; production
111    /// construction via `with_bindings` sets it.
112    providers: Option<Arc<ProviderFactory>>,
113    /// Shared tool registry. See `providers` — same optionality
114    /// rationale for unit tests.
115    tools: Option<Arc<ToolRegistry>>,
116    /// Durable runtime task that owns work launched by this runner.
117    task_id: Option<String>,
118    /// Interactive TUI runners write OSC 2 terminal-title updates.
119    /// Headless `mermaid run` must suppress them so stdout stays
120    /// machine-readable for JSON/markdown/text output modes.
121    terminal_title_enabled: bool,
122    /// Inline-approval broker. `Some` only for interactive TUI runs (set via
123    /// `with_interactive_approvals`); headless + child runners leave it `None`,
124    /// so the gate falls back to the out-of-band DB-approval flow.
125    approval: Option<crate::providers::ApprovalBroker>,
126    /// Abort handle for the background config watcher (#45). It's a perpetual
127    /// loop living in `detached`, so `shutdown` aborts it explicitly before
128    /// draining — otherwise the drain would block on it until the timeout.
129    config_watch: Option<tokio::task::AbortHandle>,
130}
131
132impl EffectRunner {
133    /// Create an unused runner. Pair with `msg_rx` from `channel()`.
134    pub fn new(msg_tx: MsgSender, workdir: PathBuf) -> Self {
135        Self {
136            msg_tx,
137            scopes: HashMap::new(),
138            cancelled_turns: VecDeque::new(),
139            detached: tokio::task::JoinSet::new(),
140            workdir,
141            providers: None,
142            tools: None,
143            task_id: None,
144            terminal_title_enabled: true,
145            approval: None,
146            config_watch: None,
147        }
148    }
149
150    /// Enable inline approval prompts (interactive TUI only). The gate then
151    /// pauses gated tools and routes the user's decision through the
152    /// `ApprovalBroker` instead of writing an out-of-band DB approval row.
153    pub fn with_interactive_approvals(mut self) -> Self {
154        self.approval = Some(crate::providers::ApprovalBroker::new(self.msg_tx.clone()));
155        self
156    }
157
158    /// Start the background config watcher (#45): it polls `MERMAID.md` + memory
159    /// and emits `Msg::InstructionsChanged`/`MemoryChanged` on change, so the
160    /// reducer reads them as injected data instead of refreshing inline. Call
161    /// once at startup. Live-loop only — a replay driver feeds the recorded
162    /// Changed Msgs rather than polling.
163    pub fn spawn_config_watcher(&mut self, cwd: PathBuf, memory: MemoryConfig) {
164        let handle = self.detached.spawn(config_watch::config_watcher(
165            self.msg_tx.clone(),
166            cwd,
167            memory,
168        ));
169        self.config_watch = Some(handle);
170    }
171
172    /// Attach a durable runtime task id so tool runs, approvals,
173    /// checkpoints, compactions, and background processes can be linked.
174    pub fn with_task_id(mut self, task_id: Option<String>) -> Self {
175        self.task_id = task_id;
176        self
177    }
178
179    /// Disable terminal-title writes for non-interactive callers.
180    pub fn without_terminal_title(mut self) -> Self {
181        self.terminal_title_enabled = false;
182        self
183    }
184
185    /// Attach provider + tool registries. Production wiring uses
186    /// this; unit tests that don't need real dispatch can skip.
187    /// Without bindings, `CallModel` / `ExecuteTool` emit well-
188    /// formed error Msgs so the reducer still transitions cleanly.
189    pub fn with_bindings(
190        mut self,
191        providers: Arc<ProviderFactory>,
192        tools: Arc<ToolRegistry>,
193    ) -> Self {
194        self.providers = Some(providers);
195        self.tools = Some(tools);
196        self
197    }
198
199    /// Pair-constructor: returns both the runner and the receiving
200    /// end of the Msg channel. Preferred for production wiring
201    /// because it keeps the channel capacity constant in one place.
202    pub fn pair(workdir: PathBuf) -> (Self, mpsc::Receiver<Msg>) {
203        let (tx, rx) = mpsc::channel(MSG_CHANNEL_CAPACITY);
204        (Self::new(tx, workdir), rx)
205    }
206
207    /// Pair constructor that also wires the real provider factory +
208    /// tool registry. Used by `app::run_interactive`.
209    pub fn pair_with_bindings(
210        workdir: PathBuf,
211        config: Config,
212        tools: Arc<ToolRegistry>,
213    ) -> (Self, mpsc::Receiver<Msg>) {
214        let providers = Arc::new(ProviderFactory::new(config));
215        Self::pair_from(workdir, providers, tools)
216    }
217
218    /// Pair constructor that takes a pre-built `ProviderFactory`.
219    /// Used when the caller needs to share a `ProviderFactory` with
220    /// the `SubagentSpawner` so subagents can issue model calls
221    /// through the same cache.
222    pub fn pair_from(
223        workdir: PathBuf,
224        providers: Arc<ProviderFactory>,
225        tools: Arc<ToolRegistry>,
226    ) -> (Self, mpsc::Receiver<Msg>) {
227        let (tx, rx) = mpsc::channel(MSG_CHANNEL_CAPACITY);
228        (Self::new(tx, workdir).with_bindings(providers, tools), rx)
229    }
230
231    pub fn pair_from_with_task(
232        workdir: PathBuf,
233        providers: Arc<ProviderFactory>,
234        tools: Arc<ToolRegistry>,
235        task_id: Option<String>,
236    ) -> (Self, mpsc::Receiver<Msg>) {
237        let (runner, rx) = Self::pair_from(workdir, providers, tools);
238        (runner.with_task_id(task_id), rx)
239    }
240
241    /// Construct a runner that shares a pre-derived cancellation
242    /// token for its turn scopes. Used by `SubagentSpawner` so the
243    /// child runner's work aborts as soon as the parent's `ctx.token`
244    /// fires.
245    pub fn new_child(
246        msg_tx: MsgSender,
247        workdir: PathBuf,
248        providers: Arc<ProviderFactory>,
249        tools: Arc<ToolRegistry>,
250    ) -> Self {
251        // A subagent's runner is never the interactive top-level, so it must
252        // NOT emit OSC 2 terminal-title escapes: in a headless `mermaid run`
253        // the parent suppresses them, but an un-suppressed child leaks
254        // `\x1b]2;…\x07` into stdout and corrupts `--format json`/`text` output.
255        Self::new(msg_tx, workdir)
256            .with_bindings(providers, tools)
257            .without_terminal_title()
258    }
259
260    /// Get or create the scope for a turn. Idempotent. The scope is
261    /// retained until `CancelScope` tears it down or it naturally
262    /// drains.
263    fn scope_mut(&mut self, turn: TurnId) -> &mut TurnScope {
264        self.scopes
265            .entry(turn)
266            .or_insert_with(|| TurnScope::new(turn))
267    }
268
269    /// F38: record a cancelled turn in the bounded tombstone ring, evicting the
270    /// oldest id at capacity. Skips duplicates so a re-cancel doesn't churn the
271    /// ring (membership is all `is_tombstoned` checks).
272    fn tombstone_turn(&mut self, turn: TurnId) {
273        if self.cancelled_turns.contains(&turn) {
274            return;
275        }
276        if self.cancelled_turns.len() >= CANCELLED_TOMBSTONE_CAP {
277            self.cancelled_turns.pop_front();
278        }
279        self.cancelled_turns.push_back(turn);
280    }
281
282    /// F38: true iff `turn`'s scope was cancelled (tombstoned). New turn-scoped
283    /// work for such a turn is dropped rather than spinning up a fresh scope.
284    fn is_tombstoned(&self, turn: TurnId) -> bool {
285        self.cancelled_turns.contains(&turn)
286    }
287
288    /// Drop the scope for a turn, signalling cancellation to every
289    /// child first. Safe to call for non-existent turns.
290    ///
291    /// After the scope is cancelled, a detached task moves it off the
292    /// runner, drains its `JoinSet` (so child tasks unwind), then emits
293    /// `Msg::TurnCancelled(turn)` so the reducer can transition
294    /// `Cancelling → Idle`. Without this terminal event the TUI would
295    /// stick in `Cancelling` — the reducer has no other way to learn
296    /// that the abort fully landed.
297    fn drop_scope(&mut self, turn: TurnId) {
298        // F38: tombstone this turn so a stray post-cancel turn-scoped Cmd can't
299        // resurrect an un-cancelled scope for it. Recorded for both the live and
300        // already-reaped branches below — once cancelled, a turn is dead either
301        // way (turn ids are monotonic and never reused).
302        self.tombstone_turn(turn);
303        if let Some(mut scope) = self.scopes.remove(&turn) {
304            scope.cancel();
305            let tx = self.msg_tx.clone();
306            self.detached.spawn(async move {
307                if tokio::time::timeout(CANCEL_DRAIN_TIMEOUT, scope.drain())
308                    .await
309                    .is_err()
310                {
311                    tracing::warn!(
312                        turn = %turn,
313                        timeout_ms = CANCEL_DRAIN_TIMEOUT.as_millis(),
314                        "cancel drain timed out; aborting remaining scoped tasks"
315                    );
316                }
317                let _ = tx.send(Msg::TurnCancelled(turn)).await;
318            });
319        } else {
320            // The scope was already reaped — its `JoinSet` drained to empty
321            // and `reap_empty_scopes` (top of `dispatch`) removed it before
322            // this cancel landed. The reducer is still in `Cancelling` with
323            // no other way to learn the turn ended, so emit the terminal
324            // event anyway. Idempotent: `handle_turn_cancelled` no-ops on
325            // any turn that isn't currently `Cancelling`.
326            let tx = self.msg_tx.clone();
327            self.detached.spawn(async move {
328                let _ = tx.send(Msg::TurnCancelled(turn)).await;
329            });
330        }
331    }
332
333    /// Number of active per-turn scopes. Tests use this to observe
334    /// lifecycle without racing on internal state.
335    pub fn scope_count(&self) -> usize {
336        self.scopes.len()
337    }
338
339    /// F12: remove scope entries whose `JoinSet` is empty — every
340    /// child task has completed, so the scope is just an orphan key
341    /// in the map. Called at the top of `dispatch` so the map stays
342    /// bounded over long sessions. Cheap: one linear walk, no async.
343    ///
344    /// `JoinSet::is_empty` only returns true after completed tasks are
345    /// harvested via `join_next`/`try_join_next`, so we first drain
346    /// any ready completions per scope.
347    fn reap_empty_scopes(&mut self) {
348        self.reap_detached();
349        self.scopes.retain(|_, scope| {
350            scope.drain_completed();
351            !scope.is_empty()
352        });
353    }
354
355    /// Harvest finished detached tasks. Without this the `detached` JoinSet
356    /// grows for the whole session (every fire-and-forget effect lingers as a
357    /// completed-but-unjoined handle), and a panicking detached task vanishes
358    /// without a trace. Non-blocking — only already-finished tasks are taken (#38).
359    fn reap_detached(&mut self) {
360        while let Some(result) = self.detached.try_join_next() {
361            if let Err(e) = result
362                && !e.is_cancelled()
363            {
364                tracing::warn!(error = %e, "effect: detached task panicked");
365            }
366        }
367    }
368
369    /// Route a single `Cmd` into the appropriate spawn + handler.
370    /// Returns immediately; handlers work asynchronously and emit
371    /// `Msg` back through the sender channel.
372    pub fn dispatch(&mut self, cmd: Cmd) {
373        // F12: reap any drained scopes before touching the map. Keeps
374        // `scope_count()` bounded as the session grows.
375        self.reap_empty_scopes();
376        tracing::trace!(cmd = %cmd.summary(), "effect: dispatch");
377
378        // F38: refuse to spawn fresh work for a turn we've already cancelled.
379        // Only the scope-spawning variants carry a `scope_turn()`; `CancelScope`
380        // returns `None` here so a re-cancel still reaches `drop_scope` (which
381        // re-emits the terminal `TurnCancelled` the reducer needs). Turn ids are
382        // monotonic and never reused, so a tombstoned id can only be a stray
383        // post-cancel straggler — dropping it stops `scope_mut`'s `or_insert_with`
384        // from resurrecting an un-cancelled scope.
385        if let Some(turn) = cmd.scope_turn()
386            && self.is_tombstoned(turn)
387        {
388            tracing::debug!(
389                cmd = %cmd.summary(),
390                turn = %turn,
391                "effect: dropping turn-scoped cmd for an already-cancelled turn"
392            );
393            return;
394        }
395
396        match cmd {
397            Cmd::CallModel { turn, mut request } => {
398                let tx = self.msg_tx.clone();
399                let providers = self.providers.clone();
400                // Enrich `request.tools` with every user-facing
401                // tool in the bound registry. The reducer has
402                // already populated MCP tools from `state.mcp`;
403                // built-ins come from the runner (which holds the
404                // registry). This keeps `ChatRequest.tools` the
405                // single source of truth for what the model sees.
406                if let Some(tools) = &self.tools {
407                    let mut enriched = tools.describe_all();
408                    // Report the built-in tool-schema token cost so the
409                    // reducer's /context preview can fold it into its MCP-only
410                    // estimate and agree with what the model actually sees.
411                    let builtin_tokens = crate::domain::estimate_tool_schema_tokens(&enriched);
412                    // Best-effort and cosmetic (the /context preview). This is the
413                    // synchronous dispatch path so we can't await; if the bounded
414                    // channel is momentarily full under heavy streaming, log the
415                    // drop rather than swallowing it silently — the estimate just
416                    // stays briefly stale (#F43).
417                    if let Err(e) = tx.try_send(Msg::BuiltinToolSchemaTokens(builtin_tokens)) {
418                        tracing::debug!(
419                            error = %e,
420                            "effect: dropped builtin tool-schema token estimate (channel full); \
421                             /context preview may be briefly stale"
422                        );
423                    }
424                    enriched.append(&mut request.tools);
425                    request.tools = enriched;
426                }
427                // Detached + off the blocking pool: never run a plugin hook on
428                // the synchronous dispatch path (it would freeze input/render).
429                self.detached.spawn(fire_plugin_hooks(
430                    "prompt_submit",
431                    serde_json::json!({
432                        "turn_id": turn.0,
433                        "model_id": request.model_id.clone(),
434                        "message_count": request.messages.len(),
435                        "tool_count": request.tools.len(),
436                    }),
437                ));
438                let scope = self.scope_mut(turn);
439                let token = scope.token();
440                scope.spawn(async move {
441                    use futures::FutureExt;
442                    let fallback_tx = tx.clone();
443                    if std::panic::AssertUnwindSafe(dispatch_call_model(
444                        tx, providers, turn, request, token,
445                    ))
446                    .catch_unwind()
447                    .await
448                    .is_err()
449                    {
450                        // The dispatch task panicked. A turn whose model call
451                        // never emits a terminal Msg stays in `Generating`
452                        // forever; emit one so the reducer can leave that state
453                        // instead of wedging (#43).
454                        tracing::error!(turn = %turn, "dispatch_call_model panicked");
455                        let _ = fallback_tx
456                            .send(Msg::UpstreamError {
457                                turn,
458                                error: crate::models::UserFacingError {
459                                    summary: "Internal error".to_string(),
460                                    message: "The model dispatch task panicked unexpectedly."
461                                        .to_string(),
462                                    suggestion: "This is a bug. Please retry; if it persists, \
463                                                 check the logs."
464                                        .to_string(),
465                                    category: crate::models::ErrorCategory::Internal,
466                                    recoverable: true,
467                                },
468                            })
469                            .await;
470                    }
471                });
472            },
473            Cmd::CompactConversation { turn, mut request } => {
474                let tx = self.msg_tx.clone();
475                let providers = self.providers.clone();
476                if let Some(tools) = &self.tools {
477                    let mut enriched = tools.describe_all();
478                    enriched.append(&mut request.chat.tools);
479                    request.chat.tools = enriched;
480                }
481                // Capture the trigger before `request` moves into the task, so a
482                // panic fallback can still name which compaction failed.
483                let trigger = request.trigger;
484                let scope = self.scope_mut(turn);
485                let token = scope.token();
486                scope.spawn(async move {
487                    use futures::FutureExt;
488                    let fallback_tx = tx.clone();
489                    if std::panic::AssertUnwindSafe(dispatch_compact_conversation(
490                        tx, providers, turn, request, token,
491                    ))
492                    .catch_unwind()
493                    .await
494                    .is_err()
495                    {
496                        // The compaction task panicked. Without a terminal
497                        // `CompactionFinished`/`CompactionFailed`, the reducer
498                        // wedges in `Compacting` until Ctrl+C; emit a failure so
499                        // it can recover, mirroring `CallModel`/`ExecuteTool`
500                        // (#43, F37).
501                        tracing::error!(turn = %turn, "dispatch_compact_conversation panicked");
502                        let _ = fallback_tx
503                            .send(Msg::CompactionFailed {
504                                turn,
505                                trigger,
506                                message: "the compaction task panicked unexpectedly".to_string(),
507                                kind: crate::domain::StatusKind::Error,
508                            })
509                            .await;
510                    }
511                });
512            },
513            Cmd::ExecuteTool {
514                turn,
515                call_id,
516                source,
517                model_id,
518                safety_mode,
519                intent,
520            } => {
521                let tx = self.msg_tx.clone();
522                let tools = self.tools.clone();
523                let workdir = self.workdir.clone();
524                // Pass the shared Config from ProviderFactory so
525                // subagents inherit it (F7). Falls back to
526                // Config::default() when providers aren't bound (unit
527                // tests without real wiring).
528                let config = self
529                    .providers
530                    .as_ref()
531                    .map(|p| Arc::new(p.config().clone()))
532                    .unwrap_or_else(|| Arc::new(crate::app::Config::default()));
533                // Auto mode: build an LLM classifier to vet borderline
534                // actions. Only when a provider is bound (real wiring); the
535                // gate fails safe to "escalate" when it's `None`. The vet
536                // uses the configured classifier model, else the session model.
537                let classifier: Option<Arc<dyn crate::providers::AutoClassifier>> =
538                    if safety_mode == crate::runtime::SafetyMode::Auto {
539                        self.providers.as_ref().map(|p| {
540                            let model = config
541                                .safety
542                                .auto_classifier_model
543                                .clone()
544                                .unwrap_or_else(|| model_id.clone());
545                            Arc::new(crate::providers::ModelAutoClassifier::new(p.clone(), model))
546                                as Arc<dyn crate::providers::AutoClassifier>
547                        })
548                    } else {
549                        None
550                    };
551                let task_id = self.task_id.clone();
552                let approval = self.approval.clone();
553                let scope = self.scope_mut(turn);
554                let token = scope.token();
555                let background = scope.background_token();
556                scope.spawn(async move {
557                    use futures::FutureExt;
558                    let fallback_tx = tx.clone();
559                    if std::panic::AssertUnwindSafe(dispatch_execute_tool(
560                        tx,
561                        tools,
562                        workdir,
563                        turn,
564                        call_id,
565                        source,
566                        token,
567                        background,
568                        config,
569                        model_id,
570                        task_id,
571                        safety_mode,
572                        intent,
573                        classifier,
574                        approval,
575                    ))
576                    .catch_unwind()
577                    .await
578                    .is_err()
579                    {
580                        // The tool task panicked. Its turn waits on a
581                        // `ToolFinished` for this `call_id` that will now never
582                        // arrive; emit a terminal error outcome so the turn
583                        // doesn't wedge (#43).
584                        tracing::error!(
585                            turn = %turn,
586                            call_id = call_id.0,
587                            "dispatch_execute_tool panicked"
588                        );
589                        let _ = fallback_tx
590                            .send(Msg::ToolFinished {
591                                turn,
592                                call_id,
593                                outcome: crate::domain::ToolOutcome::error(
594                                    "internal error: the tool execution task panicked".to_string(),
595                                    0.0,
596                                ),
597                            })
598                            .await;
599                    }
600                });
601            },
602            Cmd::ResolveApproval { call_id, decision } => {
603                // Deliver the user's inline decision to the parked tool task.
604                // Not turn-scoped — fire-and-forget to the broker.
605                if let Some(broker) = &self.approval {
606                    broker.resolve(call_id, decision.into());
607                }
608            },
609            Cmd::CancelScope(turn) => {
610                self.drop_scope(turn);
611            },
612            Cmd::BackgroundScope(turn) => {
613                // Fire the scope's background token (don't drop the scope):
614                // detachable tools move their child to a background process and
615                // return a normal outcome, so the turn finishes naturally.
616                self.scope_mut(turn).background();
617            },
618            Cmd::SaveConversation(history) => {
619                let tx = self.msg_tx.clone();
620                let workdir = self.workdir.clone();
621                self.detached.spawn(async move {
622                    if let Ok(manager) = crate::session::ConversationManager::new(&workdir)
623                        && manager.save_conversation(&history).is_ok()
624                    {
625                        let _ = tx.send(Msg::SessionSaved).await;
626                    } else {
627                        tracing::warn!("SaveConversation: failed to write to disk");
628                    }
629                });
630            },
631            Cmd::SaveCompactionArchive {
632                archive,
633                record,
634                conversation,
635            } => {
636                let tx = self.msg_tx.clone();
637                let workdir = self.workdir.clone();
638                let task_id = self.task_id.clone();
639                self.detached.spawn(async move {
640                    let Ok(manager) = crate::session::ConversationManager::new(&workdir) else {
641                        return;
642                    };
643                    // Archive FIRST — it is the only durable copy of the
644                    // dropped messages. Only overwrite the (stripped)
645                    // conversation once the archive has persisted, so a
646                    // failed archive can never lose messages.
647                    match manager.save_compaction_archive(&archive) {
648                        Ok(path) => {
649                            if let Ok(store) = crate::runtime::RuntimeStore::open_default() {
650                                let compaction_id = record.id.clone();
651                                let conversation_id = archive.conversation_id.clone();
652                                let _ =
653                                    store.compactions().create(crate::runtime::NewCompaction {
654                                        id: Some(record.id),
655                                        task_id: task_id.clone(),
656                                        session_id: Some(archive.conversation_id),
657                                        source_token_estimate: Some(record.before_tokens as i64),
658                                        summary_token_count: Some(record.summary_tokens as i64),
659                                        preserved_turns: Some(record.preserved_message_count as i64),
660                                        archive_path: Some(path.display().to_string()),
661                                        verification_status: Some("verified".to_string()),
662                                    });
663                                fire_plugin_hooks(
664                                    "compaction",
665                                    serde_json::json!({
666                                        "id": compaction_id,
667                                        "task_id": task_id.clone(),
668                                        "session_id": conversation_id,
669                                        "archive_path": path.display().to_string(),
670                                    }),
671                                )
672                                .await;
673                            }
674                            if manager.save_conversation(&conversation).is_ok() {
675                                let _ = tx.send(Msg::SessionSaved).await;
676                            } else {
677                                tracing::warn!(
678                                    "SaveCompactionArchive: archive persisted but conversation save failed"
679                                );
680                            }
681                        },
682                        Err(err) => {
683                            tracing::warn!(
684                                error = %err,
685                                "SaveCompactionArchive: archive write failed; NOT overwriting conversation",
686                            );
687                        },
688                    }
689                });
690            },
691            Cmd::SaveProcess(process) => {
692                let task_id = self.task_id.clone();
693                self.detached.spawn(async move {
694                    let status = match process.status {
695                        crate::domain::ManagedProcessStatus::Running => {
696                            crate::runtime::ProcessStatus::Running
697                        },
698                        crate::domain::ManagedProcessStatus::Exited => {
699                            crate::runtime::ProcessStatus::Exited
700                        },
701                        crate::domain::ManagedProcessStatus::Unknown => {
702                            crate::runtime::ProcessStatus::Unknown
703                        },
704                    };
705                    if let Ok(store) = crate::runtime::RuntimeStore::open_default() {
706                        let _ = store.processes().upsert(crate::runtime::NewProcess {
707                            id: Some(process.id),
708                            task_id,
709                            pid: process.pid,
710                            command: process.command,
711                            cwd: process.cwd,
712                            log_path: Some(process.log_path),
713                            detected_url: process.detected_url,
714                            status,
715                            health: None,
716                        });
717                    }
718                });
719            },
720            Cmd::PersistLastModel(model) => {
721                self.detached.spawn(async move {
722                    let _ = crate::app::persist_last_model(&model);
723                });
724            },
725            Cmd::PersistReasoningFor { model_id, level } => {
726                self.detached.spawn(async move {
727                    let _ = crate::app::persist_reasoning_for_model(&model_id, level);
728                });
729            },
730            Cmd::PersistOllamaNumCtxFor { model_id, num_ctx } => {
731                self.detached.spawn(async move {
732                    let _ = crate::app::persist_ollama_num_ctx_for_model(&model_id, num_ctx);
733                });
734            },
735            Cmd::PersistOllamaOffload(enabled) => {
736                self.detached.spawn(async move {
737                    let _ = crate::app::persist_ollama_allow_ram_offload(enabled);
738                });
739            },
740            Cmd::RefreshInstructions => {
741                let tx = self.msg_tx.clone();
742                let workdir = self.workdir.clone();
743                self.detached.spawn(async move {
744                    let (loaded, _outcome) = crate::app::instructions::refresh(None, &workdir);
745                    let _ = tx.send(Msg::InstructionsChanged(loaded)).await;
746                });
747            },
748            Cmd::RefreshMemory => {
749                let tx = self.msg_tx.clone();
750                let workdir = self.workdir.clone();
751                self.detached.spawn(async move {
752                    let cfg = crate::app::load_config().unwrap_or_default().memory;
753                    let (loaded, _) = crate::app::memory::refresh(None, &workdir, &cfg);
754                    let _ = tx.send(Msg::MemoryChanged(loaded)).await;
755                });
756            },
757            Cmd::ListMemory => {
758                let tx = self.msg_tx.clone();
759                let workdir = self.workdir.clone();
760                self.detached.spawn(async move {
761                    let cfg = crate::app::load_config().unwrap_or_default().memory;
762                    let text = match crate::app::memory::load(&workdir, &cfg) {
763                        Some(mem) => mem.index,
764                        None => "No memories saved yet. Durable facts (yours or mine) show up here — use `/remember <fact>` or just ask me to remember something.".to_string(),
765                    };
766                    let _ = tx.send(Msg::RuntimeText(text)).await;
767                });
768            },
769            Cmd::RememberMemory { text } => {
770                let tx = self.msg_tx.clone();
771                let workdir = self.workdir.clone();
772                self.detached.spawn(async move {
773                    let cfg = crate::app::load_config().unwrap_or_default().memory;
774                    let name = memory_title_from_text(&text);
775                    let (status, kind) = match crate::app::memory::write_memory(
776                        &workdir,
777                        crate::app::memory::MemoryScope::ProjectPrivate,
778                        &name,
779                        &text,
780                        &[],
781                        &text,
782                    ) {
783                        Ok(_) => (
784                            format!("Remembered: {name}"),
785                            crate::domain::StatusKind::Info,
786                        ),
787                        Err(e) => (
788                            format!("Couldn't save memory: {e}"),
789                            crate::domain::StatusKind::Error,
790                        ),
791                    };
792                    let (loaded, _) = crate::app::memory::refresh(None, &workdir, &cfg);
793                    let _ = tx.send(Msg::MemoryChanged(loaded)).await;
794                    let _ = tx
795                        .send(Msg::TransientStatus {
796                            text: status,
797                            kind,
798                            dismiss_ms: 3_000,
799                        })
800                        .await;
801                });
802            },
803            Cmd::ForgetMemory { id } => {
804                let tx = self.msg_tx.clone();
805                let workdir = self.workdir.clone();
806                self.detached.spawn(async move {
807                    let cfg = crate::app::load_config().unwrap_or_default().memory;
808                    let (status, kind) = match crate::app::memory::delete_memory(&workdir, &id) {
809                        Ok(Some(_)) => (format!("Forgot: {id}"), crate::domain::StatusKind::Info),
810                        Ok(None) => (
811                            format!("No memory named '{id}'"),
812                            crate::domain::StatusKind::Info,
813                        ),
814                        Err(e) => (
815                            format!("Couldn't forget memory: {e}"),
816                            crate::domain::StatusKind::Error,
817                        ),
818                    };
819                    let (loaded, _) = crate::app::memory::refresh(None, &workdir, &cfg);
820                    let _ = tx.send(Msg::MemoryChanged(loaded)).await;
821                    let _ = tx
822                        .send(Msg::TransientStatus {
823                            text: status,
824                            kind,
825                            dismiss_ms: 3_000,
826                        })
827                        .await;
828                });
829            },
830            Cmd::ConsolidateMemory { model_id } => {
831                let tx = self.msg_tx.clone();
832                let workdir = self.workdir.clone();
833                let providers = self.providers.clone();
834                self.detached.spawn(async move {
835                    consolidate_memory(tx, providers, workdir, model_id).await;
836                });
837            },
838            Cmd::LoadConversation(id) => {
839                let tx = self.msg_tx.clone();
840                let workdir = self.workdir.clone();
841                self.detached.spawn(async move {
842                    match crate::session::ConversationManager::new(&workdir) {
843                        Ok(mgr) => match mgr.load_conversation(&id) {
844                            Ok(history) => {
845                                let _ = tx.send(Msg::ConversationLoaded(history)).await;
846                            },
847                            Err(e) => {
848                                tracing::warn!(id = %id, error = %e, "LoadConversation failed");
849                            },
850                        },
851                        Err(e) => {
852                            tracing::warn!(error = %e, "ConversationManager init failed");
853                        },
854                    }
855                });
856            },
857            Cmd::ListConversations => {
858                let tx = self.msg_tx.clone();
859                let workdir = self.workdir.clone();
860                self.detached.spawn(async move {
861                    let summaries = match crate::session::ConversationManager::new(&workdir) {
862                        Ok(mgr) => mgr
863                            .list_conversations()
864                            .unwrap_or_default()
865                            .into_iter()
866                            .map(|h| crate::domain::ConversationSummary {
867                                id: h.id.clone(),
868                                title: h.title.clone(),
869                                message_count: h.messages.len(),
870                                updated_at: h.updated_at.to_rfc3339(),
871                            })
872                            .collect(),
873                        Err(_) => Vec::new(),
874                    };
875                    let _ = tx.send(Msg::ConversationsListed(summaries)).await;
876                });
877            },
878            Cmd::ListRuntimeTasks { limit } => {
879                let tx = self.msg_tx.clone();
880                // Synchronous rusqlite read — run on the blocking pool so it
881                // never stalls an async worker thread (#40).
882                self.detached.spawn_blocking(move || {
883                    let tasks = crate::runtime::RuntimeClient::auto()
884                        .list_tasks(limit)
885                        .map(|read| read.value)
886                        .unwrap_or_default();
887                    let _ = tx.blocking_send(Msg::RuntimeTasksListed(tasks));
888                });
889            },
890            Cmd::LoadRuntimeTask { id } => {
891                let tx = self.msg_tx.clone();
892                self.detached.spawn_blocking(move || {
893                    let (task, events) = crate::runtime::RuntimeClient::auto()
894                        .task_detail(&id)
895                        .map(|read| (Some(read.value.task), read.value.events))
896                        .unwrap_or((None, Vec::new()));
897                    let _ = tx.blocking_send(Msg::RuntimeTaskLoaded { task, events });
898                });
899            },
900            Cmd::ListRuntimeProcesses { limit } => {
901                let tx = self.msg_tx.clone();
902                self.detached.spawn_blocking(move || {
903                    let processes = crate::runtime::RuntimeClient::auto()
904                        .list_processes(limit)
905                        .map(|read| read.value)
906                        .unwrap_or_default();
907                    let _ = tx.blocking_send(Msg::RuntimeProcessesListed(processes));
908                });
909            },
910            Cmd::ShowRuntimeProcessLogs { id } => {
911                let tx = self.msg_tx.clone();
912                self.detached.spawn_blocking(move || {
913                    let text = crate::runtime::RuntimeClient::auto()
914                        .process_log(&id, None)
915                        .map(|log| format!("Process log {}\n\n{}", id, log.content))
916                        .unwrap_or_else(|err| format!("Process log error: {}", err));
917                    let _ = tx.blocking_send(Msg::RuntimeText(text));
918                });
919            },
920            Cmd::StopRuntimeProcess { id } => {
921                let tx = self.msg_tx.clone();
922                self.detached.spawn_blocking(move || {
923                    let msg = match crate::runtime::RuntimeClient::auto().stop_process(&id) {
924                        Ok(response) => Msg::TransientStatus {
925                            text: format!("Stopped process {} (pid {})", id, response.item.pid),
926                            kind: crate::domain::StatusKind::Info,
927                            dismiss_ms: 3_000,
928                        },
929                        Err(err) => Msg::TransientStatus {
930                            text: format!("Process stop failed: {}", err),
931                            kind: crate::domain::StatusKind::Warn,
932                            dismiss_ms: 5_000,
933                        },
934                    };
935                    let _ = tx.blocking_send(msg);
936                });
937            },
938            Cmd::RestartRuntimeProcess { id } => {
939                let tx = self.msg_tx.clone();
940                self.detached.spawn_blocking(move || {
941                    let msg = match crate::runtime::RuntimeClient::auto().restart_process(&id) {
942                        Ok(response) => Msg::TransientStatus {
943                            text: format!("Restarted process {} (pid {})", id, response.item.pid),
944                            kind: crate::domain::StatusKind::Info,
945                            dismiss_ms: 3_000,
946                        },
947                        Err(err) => Msg::TransientStatus {
948                            text: format!("Process restart failed: {}", err),
949                            kind: crate::domain::StatusKind::Warn,
950                            dismiss_ms: 5_000,
951                        },
952                    };
953                    let _ = tx.blocking_send(msg);
954                });
955            },
956            Cmd::OpenRuntimeTarget { target } => {
957                self.detached.spawn_blocking(move || {
958                    let resolved = crate::runtime::RuntimeService::open_default()
959                        .and_then(|service| service.resolve_open_target(&target))
960                        .unwrap_or(target);
961                    // #63: the resolved value can be a `detected_url`/`log_path`
962                    // from a `processes` row — validate before the OS opener,
963                    // exactly like `open_process`.
964                    if let Err(err) = crate::runtime::validate_open_target(&resolved) {
965                        tracing::warn!(error = %err, "refusing to open runtime target");
966                        return;
967                    }
968                    crate::utils::open_file(resolved);
969                });
970            },
971            Cmd::ShowRuntimePorts => {
972                let tx = self.msg_tx.clone();
973                self.detached.spawn_blocking(move || {
974                    let text = crate::runtime::RuntimeClient::auto()
975                        .ports()
976                        .map(|ports| format!("Listening TCP ports\n\n{}", ports.ports))
977                        .unwrap_or_else(|err| format!("Port inspection failed: {}", err));
978                    let _ = tx.blocking_send(Msg::RuntimeText(text));
979                });
980            },
981            Cmd::ListRuntimeApprovals => {
982                let tx = self.msg_tx.clone();
983                self.detached.spawn_blocking(move || {
984                    let approvals = crate::runtime::RuntimeClient::auto()
985                        .list_approvals()
986                        .map(|read| read.value)
987                        .unwrap_or_default();
988                    let _ = tx.blocking_send(Msg::RuntimeApprovalsListed(approvals));
989                });
990            },
991            Cmd::DecideRuntimeApproval { id, decision } => {
992                let tx = self.msg_tx.clone();
993                self.detached.spawn_blocking(move || {
994                    let result = if decision == "approved" {
995                        crate::runtime::RuntimeClient::auto().approve(&id)
996                    } else {
997                        crate::runtime::RuntimeClient::auto().deny(&id)
998                    };
999                    let msg = match result {
1000                        Ok(result) => Msg::TransientStatus {
1001                            text: if result.replayed {
1002                                format!("Approval {} {}: {}", id, decision, result.summary)
1003                            } else {
1004                                format!("Approval {} {}", id, decision)
1005                            },
1006                            kind: crate::domain::StatusKind::Info,
1007                            dismiss_ms: 3_000,
1008                        },
1009                        Err(err) => Msg::TransientStatus {
1010                            text: format!("Approval update failed: {}", err),
1011                            kind: crate::domain::StatusKind::Warn,
1012                            dismiss_ms: 5_000,
1013                        },
1014                    };
1015                    let _ = tx.blocking_send(msg);
1016                });
1017            },
1018            Cmd::ListRuntimeCheckpoints { limit } => {
1019                let tx = self.msg_tx.clone();
1020                self.detached.spawn_blocking(move || {
1021                    let checkpoints = crate::runtime::RuntimeClient::auto()
1022                        .list_checkpoints(limit)
1023                        .map(|read| read.value)
1024                        .unwrap_or_default();
1025                    let _ = tx.blocking_send(Msg::RuntimeCheckpointsListed(checkpoints));
1026                });
1027            },
1028            Cmd::ListRuntimePlugins => {
1029                let tx = self.msg_tx.clone();
1030                self.detached.spawn_blocking(move || {
1031                    let plugins = crate::runtime::RuntimeClient::auto()
1032                        .list_plugins()
1033                        .map(|read| read.value)
1034                        .unwrap_or_default();
1035                    let _ = tx.blocking_send(Msg::RuntimePluginsListed(plugins));
1036                });
1037            },
1038            Cmd::UpdateRuntimeTaskStatus {
1039                id,
1040                status,
1041                final_report,
1042            } => {
1043                let tx = self.msg_tx.clone();
1044                self.detached.spawn_blocking(move || {
1045                    let msg = match crate::runtime::RuntimeStore::open_default().and_then(|store| {
1046                        store
1047                            .tasks()
1048                            .update_status(&id, status, final_report.as_deref())
1049                    }) {
1050                        Ok(()) => Msg::TransientStatus {
1051                            text: format!("Task {} -> {}", id, status),
1052                            kind: crate::domain::StatusKind::Info,
1053                            dismiss_ms: 3_000,
1054                        },
1055                        Err(err) => Msg::TransientStatus {
1056                            text: format!("Task update failed: {}", err),
1057                            kind: crate::domain::StatusKind::Warn,
1058                            dismiss_ms: 4_000,
1059                        },
1060                    };
1061                    let _ = tx.blocking_send(msg);
1062                });
1063            },
1064            Cmd::CreateRuntimeCheckpoint { paths } => {
1065                let tx = self.msg_tx.clone();
1066                let workdir = self.workdir.clone();
1067                self.detached.spawn_blocking(move || {
1068                    let pending_action = Some(serde_json::json!({
1069                        "source": "tui",
1070                        "command": "checkpoint",
1071                    }));
1072                    let msg =
1073                        match crate::runtime::create_checkpoint(&workdir, &paths, pending_action) {
1074                            Ok(manifest) => Msg::TransientStatus {
1075                                text: format!(
1076                                    "Checkpoint {} created for {} path(s)",
1077                                    manifest.id,
1078                                    manifest.files.len()
1079                                ),
1080                                kind: crate::domain::StatusKind::Info,
1081                                dismiss_ms: 4_000,
1082                            },
1083                            Err(err) => Msg::TransientStatus {
1084                                text: format!("Checkpoint failed: {}", err),
1085                                kind: crate::domain::StatusKind::Warn,
1086                                dismiss_ms: 5_000,
1087                            },
1088                        };
1089                    let _ = tx.blocking_send(msg);
1090                });
1091            },
1092            Cmd::RestoreRuntimeCheckpoint { id } => {
1093                let tx = self.msg_tx.clone();
1094                self.detached.spawn_blocking(move || {
1095                    let msg = match crate::runtime::RuntimeClient::auto().restore_checkpoint(&id) {
1096                        Ok(result) => Msg::TransientStatus {
1097                            text: format!(
1098                                "Restored checkpoint {} ({} file(s)){}",
1099                                result.checkpoint.id,
1100                                result.checkpoint.files.len(),
1101                                if result.checkpoint.pending_action.is_some() {
1102                                    "; pending action available in checkpoint manifest"
1103                                } else {
1104                                    ""
1105                                }
1106                            ),
1107                            kind: crate::domain::StatusKind::Info,
1108                            dismiss_ms: 4_000,
1109                        },
1110                        Err(err) => Msg::TransientStatus {
1111                            text: format!("Restore failed: {}", err),
1112                            kind: crate::domain::StatusKind::Warn,
1113                            dismiss_ms: 5_000,
1114                        },
1115                    };
1116                    let _ = tx.blocking_send(msg);
1117                });
1118            },
1119            Cmd::ShowRuntimeModelInfo { model } => {
1120                let tx = self.msg_tx.clone();
1121                self.detached.spawn_blocking(move || {
1122                    let text = runtime_model_info_text(&model);
1123                    let _ = tx.blocking_send(Msg::RuntimeText(text));
1124                });
1125            },
1126            Cmd::InitMcpServers(configs) => {
1127                let tx = self.msg_tx.clone();
1128                self.detached.spawn(async move {
1129                    if configs.is_empty() {
1130                        return;
1131                    }
1132                    crate::mcp::manager_ref::mark_init_started();
1133                    let manager =
1134                        std::sync::Arc::new(crate::mcp::McpServerManager::start(&configs).await);
1135                    // Emit a Ready or Errored per server based on
1136                    // what came up. Zero-tool servers are still ready
1137                    // if the manager has an active client for them.
1138                    for (name, _cfg) in configs.iter() {
1139                        let server_tools: Vec<crate::domain::McpToolSpec> = manager
1140                            .get_all_tools()
1141                            .iter()
1142                            .filter(|(server, _)| server == name)
1143                            .map(|(_, def)| crate::domain::McpToolSpec {
1144                                name: def.name.clone(),
1145                                description: def.description.clone(),
1146                                input_schema: def.input_schema.clone(),
1147                            })
1148                            .collect();
1149                        let msg = mcp_startup_msg(name, manager.has_server(name), server_tools);
1150                        let _ = tx.send(msg).await;
1151                    }
1152                    crate::mcp::manager_ref::set_manager(manager);
1153                    crate::mcp::manager_ref::mark_init_complete();
1154                });
1155            },
1156            Cmd::StopMcpServer { name } => {
1157                let tx = self.msg_tx.clone();
1158                self.detached.spawn(async move {
1159                    // Actually kill the child before claiming it's stopped —
1160                    // otherwise the UI says "stopped" while the server runs on.
1161                    if let Some(mgr) = crate::mcp::manager_ref::get() {
1162                        mgr.stop_server(&name).await;
1163                    }
1164                    let _ = tx.send(Msg::McpServerStopped { name }).await;
1165                });
1166            },
1167            Cmd::PullOllamaModel { model } => {
1168                let tx = self.msg_tx.clone();
1169                self.detached.spawn(async move {
1170                    dispatch_pull_ollama_model(tx, model).await;
1171                });
1172            },
1173            Cmd::OpenInSystem(path) => {
1174                self.detached.spawn(async move {
1175                    let _ = tokio::task::spawn_blocking(move || {
1176                        crate::utils::open_file(&path);
1177                    })
1178                    .await;
1179                });
1180            },
1181            Cmd::DismissStatusAfter { ms } => {
1182                let tx = self.msg_tx.clone();
1183                self.detached.spawn(async move {
1184                    tokio::time::sleep(std::time::Duration::from_millis(ms)).await;
1185                    let _ = tx.send(Msg::StatusDismiss).await;
1186                });
1187            },
1188            Cmd::WriteImageToTemp {
1189                path,
1190                bytes,
1191                format: _,
1192            } => {
1193                self.detached.spawn(async move {
1194                    if let Err(e) = tokio::fs::write(&path, &bytes).await {
1195                        tracing::warn!(path = %path.display(), error = %e, "WriteImageToTemp failed");
1196                    }
1197                });
1198            },
1199            Cmd::ReadClipboard => {
1200                let tx = self.msg_tx.clone();
1201                self.detached.spawn(async move {
1202                    dispatch_read_clipboard(tx).await;
1203                });
1204            },
1205            Cmd::CopyToClipboard(text) => {
1206                let tx = self.msg_tx.clone();
1207                self.detached.spawn(async move {
1208                    dispatch_copy_to_clipboard(text, tx).await;
1209                });
1210            },
1211            Cmd::Exit => {
1212                // The main loop observes `state.should_exit` after
1213                // the reducer returns; the runner doesn't need to
1214                // take any special action. Documented here for
1215                // exhaustiveness.
1216            },
1217            Cmd::SetTerminalTitle(title) => {
1218                if !self.terminal_title_enabled {
1219                    return;
1220                }
1221                // Offload the terminal write to the blocking pool: writing to
1222                // stdout can block when the terminal (or a downstream pipe) is
1223                // slow, and an async worker must not block on it (#44). The
1224                // OSC-2 title sequence is out-of-band relative to the renderer's
1225                // frame draws, so it doesn't corrupt them.
1226                self.detached.spawn_blocking(move || {
1227                    use std::io::Write;
1228                    let seq = format!("\x1b]2;{}\x07", title);
1229                    let mut stdout = std::io::stdout();
1230                    let _ = stdout.write_all(seq.as_bytes());
1231                    let _ = stdout.flush();
1232                });
1233            },
1234        }
1235    }
1236
1237    /// Async shutdown: cancel every scope, then wait for all spawned
1238    /// work to drain. Bounded by 5 seconds — a hung task past that
1239    /// gets aborted outright by `JoinSet::drop`.
1240    pub async fn shutdown(mut self) {
1241        for (id, scope) in self.scopes.iter() {
1242            tracing::debug!(turn = %id, "shutdown: cancelling scope");
1243            scope.cancel();
1244        }
1245
1246        // The config watcher (#45) is a perpetual loop in `detached`; abort it
1247        // so the drain below doesn't block on it until the bounded timeout.
1248        if let Some(handle) = self.config_watch.take() {
1249            handle.abort();
1250        }
1251
1252        // Drain with a bounded timeout.
1253        let shutdown_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5);
1254
1255        let drain = async {
1256            // If an MCP init is still in flight, its child processes are already
1257            // spawned but `set_manager` hasn't run yet — `get()` below would
1258            // return `None` and we'd leak those children. Wait (bounded) for init
1259            // to settle so the manager is installed before we reap it (#59).
1260            let _ = tokio::time::timeout(
1261                std::time::Duration::from_secs(2),
1262                crate::mcp::manager_ref::wait_ready(),
1263            )
1264            .await;
1265            // Gracefully shut down MCP server children (the stdin-EOF →
1266            // terminate → kill ladder in `McpServerManager::shutdown`). The
1267            // manager lives in a `'static OnceLock` that never drops, so this
1268            // explicit call on the exit path is the only thing that reaps those
1269            // child processes. No-op when no servers were configured.
1270            if let Some(mgr) = crate::mcp::manager_ref::get() {
1271                mgr.shutdown().await;
1272            }
1273            // F42: bound each per-scope drain so one non-cooperative task can't
1274            // eat the whole shutdown budget and starve the remaining scopes'
1275            // drains (the scopes were all cancelled above, so a well-behaved task
1276            // unwinds well within this). On timeout, dropping `scope` aborts its
1277            // still-running `JoinSet` members via `TurnScope::drop`.
1278            for (id, mut scope) in self.scopes.drain() {
1279                if tokio::time::timeout(CANCEL_DRAIN_TIMEOUT, scope.drain())
1280                    .await
1281                    .is_err()
1282                {
1283                    tracing::warn!(
1284                        turn = %id,
1285                        timeout_ms = CANCEL_DRAIN_TIMEOUT.as_millis(),
1286                        "shutdown: scope drain timed out; aborting its remaining tasks"
1287                    );
1288                }
1289            }
1290            while let Some(result) = self.detached.join_next().await {
1291                if let Err(e) = result
1292                    && !e.is_cancelled()
1293                {
1294                    tracing::warn!(error = %e, "shutdown: detached task panic");
1295                }
1296            }
1297        };
1298
1299        let _ = tokio::time::timeout_at(shutdown_deadline, drain).await;
1300    }
1301
1302    /// Test helper: clone the Msg sender so a test can synthesize a
1303    /// message as if it came from an effect handler.
1304    #[doc(hidden)]
1305    pub fn msg_sender(&self) -> MsgSender {
1306        self.msg_tx.clone()
1307    }
1308}
1309
1310/// Dispatch a `CallModel` command. Resolves the provider (lazy,
1311/// cached) and streams its events onto the Msg channel. Without a
1312/// bound `ProviderFactory` (unit tests), emits a single
1313/// `UpstreamError` so the reducer ends the turn cleanly.
1314/// Await a sibling relay task's handle, logging a panic (but not a normal
1315/// post-cancellation abort). These relays run alongside the provider/tool call
1316/// for streaming backpressure; awaiting their handle keeps a stray panic from
1317/// vanishing the way a bare `let _ = handle.await` would (#58, #60).
1318async fn join_logged(handle: tokio::task::JoinHandle<()>, what: &str) {
1319    if let Err(e) = handle.await
1320        && !e.is_cancelled()
1321    {
1322        tracing::warn!(error = %e, task = what, "effect: sibling relay task panicked");
1323    }
1324}
1325
1326/// Owns a sibling relay task so it cannot outlive its parent future as a detached
1327/// task: if the parent is dropped before it `take()`s the handle for
1328/// `join_logged`, the guard aborts the task on drop (#F39). On the normal path the
1329/// handle is taken and awaited, so the drop is a no-op. The relay is already
1330/// bounded by the turn `CancellationToken`; this makes the "every turn task is
1331/// owned" invariant hold structurally rather than only behaviorally.
1332struct AbortOnDrop(Option<tokio::task::JoinHandle<()>>);
1333
1334impl AbortOnDrop {
1335    fn take(mut self) -> tokio::task::JoinHandle<()> {
1336        self.0.take().expect("AbortOnDrop::take called once")
1337    }
1338}
1339
1340impl Drop for AbortOnDrop {
1341    fn drop(&mut self) {
1342        if let Some(handle) = &self.0 {
1343            handle.abort();
1344        }
1345    }
1346}
1347
1348/// `tokio::spawn` a relay, wrapped in an [`AbortOnDrop`] so the parent owns it.
1349fn spawn_guarded<F>(fut: F) -> AbortOnDrop
1350where
1351    F: std::future::Future<Output = ()> + Send + 'static,
1352{
1353    AbortOnDrop(Some(tokio::spawn(fut)))
1354}
1355
1356async fn dispatch_call_model(
1357    msg_tx: MsgSender,
1358    providers: Option<Arc<ProviderFactory>>,
1359    turn: TurnId,
1360    mut request: crate::domain::ChatRequest,
1361    token: tokio_util::sync::CancellationToken,
1362) {
1363    use crate::models::UserFacingError;
1364
1365    let Some(factory) = providers else {
1366        let error = UserFacingError {
1367            summary: "not wired".to_string(),
1368            message: "EffectRunner has no ProviderFactory bound".to_string(),
1369            suggestion: "construct via EffectRunner::pair_with_bindings".to_string(),
1370            category: crate::models::ErrorCategory::Internal,
1371            recoverable: false,
1372        };
1373        let _ = msg_tx.send(Msg::UpstreamError { turn, error }).await;
1374        return;
1375    };
1376
1377    // Lazily resolve the provider for this model.
1378    let provider = match factory.resolve(&request.model_id).await {
1379        Ok(p) => p,
1380        Err(e) => {
1381            let error = classify_error_for_ui(&e);
1382            let _ = msg_tx.send(Msg::UpstreamError { turn, error }).await;
1383            return;
1384        },
1385    };
1386    {
1387        // Telemetry write — offload the synchronous DB upserts to the blocking
1388        // pool so they never stall this model-call dispatch path, which runs on
1389        // every turn (#39).
1390        let model_id = request.model_id.clone();
1391        let caps = provider.capabilities().clone();
1392        // Own this telemetry write inside the per-turn task (await it) instead of
1393        // a detached `spawn_blocking` whose handle was dropped — so a panic in the
1394        // upsert surfaces and shutdown isn't racing an untracked DB write (#F41).
1395        // It is a few-ms SQLite upsert before a multi-second model call, so
1396        // awaiting it here does not meaningfully stall the turn (the "never stall
1397        // dispatch" rule is about the synchronous reducer path, not this task).
1398        if let Err(e) =
1399            tokio::task::spawn_blocking(move || record_provider_capabilities(&model_id, &caps))
1400                .await
1401        {
1402            tracing::error!(error = %e, "effect: provider-capability telemetry write failed");
1403        }
1404    }
1405    if !request.tools.is_empty() && !provider.capabilities().supports_tools {
1406        let _ = msg_tx
1407            .send(Msg::TransientStatus {
1408                text: format!(
1409                    "{} does not advertise tool support; Mermaid will send the turn without tools",
1410                    request.model_id
1411                ),
1412                kind: crate::domain::StatusKind::Warn,
1413                dismiss_ms: 6_000,
1414            })
1415            .await;
1416        request.tools.clear();
1417    }
1418
1419    // Resolve the *effective* context window. For Ollama this probes the model's
1420    // real window and auto-fits num_ctx to memory (cache-first, off the UI
1421    // thread); for other providers it's the static advertised window. Using the
1422    // effective value here is what un-skips auto-compaction for Ollama (which had
1423    // `NoKnownContextLimit`) and gives the status bar real numbers.
1424    let sizing = provider.resolve_context_window(&request).await;
1425    let max_context_tokens = sizing.effective.or_else(|| {
1426        crate::domain::runtime::infer_static_context_window_for_model_id(&request.model_id)
1427    });
1428    // Report the resolved window to the reducer for the `/context` display +
1429    // truncation quick-fix. Harmless for non-Ollama (source is None → no extra
1430    // detail shown).
1431    let _ = msg_tx
1432        .send(Msg::ProviderContextResolved {
1433            model_id: request.model_id.clone(),
1434            model_max: sizing.model_max,
1435            effective: sizing.effective,
1436            source: sizing.source,
1437        })
1438        .await;
1439    let context_snapshot =
1440        crate::domain::estimate_context_usage_for_request(&request, max_context_tokens);
1441    let _ = msg_tx
1442        .send(Msg::ContextUsageEstimated {
1443            turn,
1444            snapshot: context_snapshot.clone(),
1445        })
1446        .await;
1447
1448    let policy = CompactionPolicy::default();
1449    let mut compacted_before_stream = false;
1450    if crate::domain::should_auto_compact(&context_snapshot, &request, policy).is_ok() {
1451        let compaction = CompactionRequest::auto(request.clone(), CompactionTrigger::AutoThreshold);
1452        // Best-effort preflight: if there's nothing to compact, proceed
1453        // un-compacted (the provider's own context limit is the real gate).
1454        if let Ok(prepared) = crate::domain::prepare_compaction(&compaction, max_context_tokens) {
1455            match run_compaction(
1456                Arc::clone(&provider),
1457                turn,
1458                compaction,
1459                prepared,
1460                context_snapshot.clone(),
1461                max_context_tokens,
1462                token.clone(),
1463            )
1464            .await
1465            {
1466                Ok(result) => {
1467                    request.messages = result.replacement_messages.clone();
1468                    compacted_before_stream = true;
1469                    let _ = msg_tx.send(Msg::CompactionFinished { turn, result }).await;
1470                },
1471                Err(err) => {
1472                    // Auto-compaction is best-effort. If it can't reduce the
1473                    // context — the estimate is roughest exactly at the limit, so
1474                    // a large preserved tail can read `after >= before` — don't
1475                    // kill the turn. Log it, surface a soft warning, and proceed
1476                    // with the original request; the provider's own context limit
1477                    // is the real gate. (Manual `/compact` keeps its hard error
1478                    // via `run_compaction`'s reduction guard.)
1479                    if token.is_cancelled() {
1480                        return;
1481                    }
1482                    tracing::warn!(
1483                        turn = %turn,
1484                        error = %err,
1485                        "auto-compaction failed; proceeding with the un-compacted request",
1486                    );
1487                    let _ = msg_tx
1488                        .send(Msg::CompactionFailed {
1489                            turn,
1490                            trigger: CompactionTrigger::AutoThreshold,
1491                            message: err.to_string(),
1492                            kind: crate::domain::StatusKind::Warn,
1493                        })
1494                        .await;
1495                },
1496            }
1497        }
1498    }
1499
1500    // Build a StreamContext — provider writes typed events into the
1501    // internal sink; we relay each to the reducer as a Msg.
1502    let (stream_tx, mut stream_rx) = mpsc::channel::<StreamEvent>(256);
1503    let ctx = StreamContext::new(token.clone(), stream_tx, turn);
1504
1505    // Drain stream events into Msgs on a sibling task. Ends when the sink
1506    // closes (provider's final `Done` or completion) OR the turn token is
1507    // cancelled — `select!`ing on the token ties this relay to the turn's
1508    // structured cancellation so a cancel drops it within a tick instead of
1509    // waiting on the next event. (A separate task is required: the relay must
1510    // run concurrently with `provider.chat` for streaming backpressure.)
1511    let relay_tx = msg_tx.clone();
1512    let relay_token = token.clone();
1513    let relay = spawn_guarded(async move {
1514        loop {
1515            let event = tokio::select! {
1516                biased;
1517                _ = relay_token.cancelled() => {
1518                    // #F40: a cancel landing right after the provider finished must
1519                    // not discard the terminal Done it already enqueued. Drain the
1520                    // buffered events and relay only a terminal Done — so the
1521                    // just-completed turn's usage is still recorded — while NOT
1522                    // painting buffered intermediate text (the turn is cancelled).
1523                    // `try_recv` drains the buffer without awaiting more.
1524                    while let Ok(buffered) = stream_rx.try_recv() {
1525                        if let StreamEvent::Done {
1526                            usage,
1527                            thinking_signature,
1528                            stop_reason,
1529                        } = buffered
1530                        {
1531                            let _ = relay_tx
1532                                .send(Msg::StreamDone {
1533                                    turn,
1534                                    usage,
1535                                    thinking_signature,
1536                                    stop_reason,
1537                                })
1538                                .await;
1539                        }
1540                    }
1541                    break;
1542                },
1543                ev = stream_rx.recv() => match ev {
1544                    Some(ev) => ev,
1545                    None => break,
1546                },
1547            };
1548            let msg = match event {
1549                StreamEvent::Text(chunk) => Msg::StreamText { turn, chunk },
1550                StreamEvent::Reasoning(chunk) => Msg::StreamReasoning { turn, chunk },
1551                StreamEvent::ToolCall(call) => Msg::StreamToolCall { turn, call },
1552                StreamEvent::ThinkingSignature(_) => continue, // folded into Done below
1553                StreamEvent::Done {
1554                    usage,
1555                    thinking_signature,
1556                    stop_reason,
1557                } => Msg::StreamDone {
1558                    turn,
1559                    usage,
1560                    thinking_signature,
1561                    stop_reason,
1562                },
1563            };
1564            if relay_tx.send(msg).await.is_err() {
1565                break;
1566            }
1567        }
1568    });
1569
1570    // Run the actual provider. On error, the relay will have
1571    // already emitted partial events; we follow with a single
1572    // UpstreamError to terminate the turn cleanly.
1573    //
1574    // `ModelError::Cancelled` is swallowed — the terminal
1575    // `Msg::TurnCancelled` is emitted from `drop_scope` after the
1576    // turn's `TurnScope` drains. Emitting `UpstreamError` here would
1577    // commit a "cancelled" message the user didn't ask to see.
1578    let mut completed_ok = false;
1579    match provider.chat(request.clone(), ctx).await {
1580        Ok(_final_response) => {
1581            // Success — the final `Done` flowed through the sink.
1582            completed_ok = true;
1583        },
1584        Err(crate::models::ModelError::Cancelled) => {
1585            // Silent: `drop_scope` will emit `Msg::TurnCancelled`.
1586        },
1587        Err(e) => {
1588            let retry_context_limit = !compacted_before_stream && is_context_limit_error(&e);
1589            if retry_context_limit {
1590                let latest_snapshot =
1591                    crate::domain::estimate_context_usage_for_request(&request, max_context_tokens);
1592                let compaction =
1593                    CompactionRequest::auto(request.clone(), CompactionTrigger::ContextLimitRetry);
1594                // Only retry if there's something to compact; otherwise fall
1595                // through to surface the original context-limit error.
1596                if let Ok(prepared) =
1597                    crate::domain::prepare_compaction(&compaction, max_context_tokens)
1598                {
1599                    match run_compaction(
1600                        Arc::clone(&provider),
1601                        turn,
1602                        compaction,
1603                        prepared,
1604                        latest_snapshot,
1605                        max_context_tokens,
1606                        token.clone(),
1607                    )
1608                    .await
1609                    {
1610                        Ok(result) => {
1611                            let mut retry_request = request;
1612                            retry_request.messages = result.replacement_messages.clone();
1613                            let _ = msg_tx.send(Msg::CompactionFinished { turn, result }).await;
1614                            join_logged(relay.take(), "stream_relay").await;
1615                            dispatch_provider_stream(msg_tx, provider, turn, retry_request, token)
1616                                .await;
1617                            return;
1618                        },
1619                        Err(compact_err) => {
1620                            let _ = msg_tx
1621                                .send(Msg::CompactionFailed {
1622                                    turn,
1623                                    trigger: CompactionTrigger::ContextLimitRetry,
1624                                    message: compact_err.to_string(),
1625                                    kind: crate::domain::StatusKind::Error,
1626                                })
1627                                .await;
1628                        },
1629                    }
1630                }
1631            }
1632            let error = classify_error_for_ui(&e);
1633            run_provider_error_hook(&request.model_id, &error).await;
1634            let _ = msg_tx.send(Msg::UpstreamError { turn, error }).await;
1635        },
1636    }
1637
1638    join_logged(relay.take(), "stream_relay").await;
1639
1640    // Post-turn (success only): verify the model actually fit VRAM. Skipped when
1641    // the user allowed RAM offload (no warning possible) and a no-op for
1642    // non-Ollama providers (verify_placement returns None). Off the critical path
1643    // — StreamDone is already enqueued, so any warning renders after the answer.
1644    if completed_ok
1645        && request.ollama_allow_ram_offload != Some(true)
1646        && let Some(p) = provider.verify_placement(sizing.effective).await
1647    {
1648        tracing::debug!(
1649            size_vram_bytes = p.size_vram_bytes,
1650            total_bytes = p.total_bytes,
1651            offloaded = p.size_vram_bytes < p.total_bytes,
1652            suggested_num_ctx = ?p.suggested_num_ctx,
1653            "Ollama placement"
1654        );
1655        let _ = msg_tx
1656            .send(Msg::OllamaPlacementResolved {
1657                model_id: request.model_id.clone(),
1658                size_vram_bytes: p.size_vram_bytes,
1659                total_bytes: p.total_bytes,
1660                suggested_num_ctx: p.suggested_num_ctx,
1661            })
1662            .await;
1663    }
1664}
1665
1666async fn dispatch_provider_stream(
1667    msg_tx: MsgSender,
1668    provider: Arc<dyn ModelProvider>,
1669    turn: TurnId,
1670    request: crate::domain::ChatRequest,
1671    token: tokio_util::sync::CancellationToken,
1672) {
1673    let (stream_tx, mut stream_rx) = mpsc::channel::<StreamEvent>(256);
1674    let ctx = StreamContext::new(token.clone(), stream_tx, turn);
1675    let relay_tx = msg_tx.clone();
1676    let relay_token = token.clone();
1677    let relay = spawn_guarded(async move {
1678        loop {
1679            let event = tokio::select! {
1680                biased;
1681                _ = relay_token.cancelled() => {
1682                    // #F40: a cancel landing right after the provider finished must
1683                    // not discard the terminal Done it already enqueued. Drain the
1684                    // buffered events and relay only a terminal Done — so the
1685                    // just-completed turn's usage is still recorded — while NOT
1686                    // painting buffered intermediate text (the turn is cancelled).
1687                    // `try_recv` drains the buffer without awaiting more.
1688                    while let Ok(buffered) = stream_rx.try_recv() {
1689                        if let StreamEvent::Done {
1690                            usage,
1691                            thinking_signature,
1692                            stop_reason,
1693                        } = buffered
1694                        {
1695                            let _ = relay_tx
1696                                .send(Msg::StreamDone {
1697                                    turn,
1698                                    usage,
1699                                    thinking_signature,
1700                                    stop_reason,
1701                                })
1702                                .await;
1703                        }
1704                    }
1705                    break;
1706                },
1707                ev = stream_rx.recv() => match ev {
1708                    Some(ev) => ev,
1709                    None => break,
1710                },
1711            };
1712            let msg = match event {
1713                StreamEvent::Text(chunk) => Msg::StreamText { turn, chunk },
1714                StreamEvent::Reasoning(chunk) => Msg::StreamReasoning { turn, chunk },
1715                StreamEvent::ToolCall(call) => Msg::StreamToolCall { turn, call },
1716                StreamEvent::ThinkingSignature(_) => continue,
1717                StreamEvent::Done {
1718                    usage,
1719                    thinking_signature,
1720                    stop_reason,
1721                } => Msg::StreamDone {
1722                    turn,
1723                    usage,
1724                    thinking_signature,
1725                    stop_reason,
1726                },
1727            };
1728            if relay_tx.send(msg).await.is_err() {
1729                break;
1730            }
1731        }
1732    });
1733
1734    let model_id = request.model_id.clone();
1735    match provider.chat(request, ctx).await {
1736        Ok(_) | Err(ModelError::Cancelled) => {},
1737        Err(e) => {
1738            let error = classify_error_for_ui(&e);
1739            run_provider_error_hook(&model_id, &error).await;
1740            let _ = msg_tx.send(Msg::UpstreamError { turn, error }).await;
1741        },
1742    }
1743
1744    join_logged(relay.take(), "stream_relay").await;
1745}
1746
1747/// Run plugin hooks OFF the async executor. `run_plugin_hooks` is synchronous —
1748/// it spawns hook children and bounded-waits on them — so calling it inline
1749/// would block a tokio worker, or (on the `dispatch` path) the whole event loop.
1750/// `spawn_blocking` moves it to the blocking pool. Hooks are fire-and-forget
1751/// observers, so the result is dropped.
1752async fn fire_plugin_hooks(event: &'static str, payload: serde_json::Value) {
1753    let _ = tokio::task::spawn_blocking(move || crate::runtime::run_plugin_hooks(event, &payload))
1754        .await;
1755}
1756
1757async fn run_provider_error_hook(model_id: &str, error: &crate::models::UserFacingError) {
1758    fire_plugin_hooks(
1759        "provider_error",
1760        serde_json::json!({
1761            "model_id": model_id,
1762            "summary": &error.summary,
1763            "message": &error.message,
1764            "category": format!("{:?}", error.category),
1765            "recoverable": error.recoverable,
1766        }),
1767    )
1768    .await;
1769}
1770
1771/// Derive a short title for a `/remember` memory from free-text input: the
1772/// first non-empty line, capped to ~8 words / 60 chars. `write_memory`
1773/// slugifies it into the filename.
1774fn memory_title_from_text(text: &str) -> String {
1775    let first = text
1776        .lines()
1777        .find(|l| !l.trim().is_empty())
1778        .unwrap_or("memory")
1779        .trim();
1780    let title: String = first
1781        .split_whitespace()
1782        .take(8)
1783        .collect::<Vec<_>>()
1784        .join(" ")
1785        .chars()
1786        .take(60)
1787        .collect();
1788    if title.trim().is_empty() {
1789        "memory".to_string()
1790    } else {
1791        title
1792    }
1793}
1794
1795const CONSOLIDATE_SYSTEM_PROMPT: &str = "You maintain a coding agent's durable memory: a set of atomic facts. Your only job is to find facts that are EXACT DUPLICATES or CLEARLY OBSOLETE/SUPERSEDED by another fact, and list their ids for pruning. Never prune facts that are merely related or similar but carry distinct information. Never rewrite or merge facts. When in doubt, keep. Reply with ONLY a JSON object: {\"prune\": [\"id1\", \"id2\"], \"reason\": \"one short sentence\"}. If nothing should be pruned, return an empty prune list.";
1796
1797#[derive(Debug)]
1798struct PrunePlan {
1799    prune: Vec<String>,
1800    reason: String,
1801}
1802
1803/// Extract a `{prune:[...], reason:""}` plan from a model response, tolerating
1804/// prose or code fences around the JSON object.
1805fn parse_prune_plan(text: &str) -> Option<PrunePlan> {
1806    let start = text.find('{')?;
1807    let end = text.rfind('}')?;
1808    if end < start {
1809        return None;
1810    }
1811    let json: serde_json::Value = serde_json::from_str(&text[start..=end]).ok()?;
1812    let prune = json
1813        .get("prune")?
1814        .as_array()?
1815        .iter()
1816        .filter_map(|v| v.as_str().map(str::to_string))
1817        .collect();
1818    let reason = json
1819        .get("reason")
1820        .and_then(|v| v.as_str())
1821        .unwrap_or("")
1822        .to_string();
1823    Some(PrunePlan { prune, reason })
1824}
1825
1826/// `/consolidate-memory`: a one-shot model pass that names duplicate/obsolete
1827/// facts to prune (never rewrites — that's the anti-drift rule). The pruned
1828/// files are snapshotted into a checkpoint first, so the prune is reversible.
1829async fn consolidate_memory(
1830    tx: MsgSender,
1831    providers: Option<Arc<ProviderFactory>>,
1832    workdir: std::path::PathBuf,
1833    model_id: String,
1834) {
1835    let items = crate::app::memory::entries_with_bodies(&workdir);
1836    if items.len() < 2 {
1837        let _ = tx
1838            .send(Msg::RuntimeText(format!(
1839                "Nothing to consolidate — {} memor{} saved.",
1840                items.len(),
1841                if items.len() == 1 { "y" } else { "ies" }
1842            )))
1843            .await;
1844        return;
1845    }
1846    let Some(factory) = providers else {
1847        let _ = tx
1848            .send(Msg::RuntimeText(
1849                "Memory consolidation needs a model provider, which isn't bound in this session."
1850                    .to_string(),
1851            ))
1852            .await;
1853        return;
1854    };
1855
1856    let mut listing = String::new();
1857    for (entry, body) in &items {
1858        let id = entry
1859            .path
1860            .file_stem()
1861            .and_then(|s| s.to_str())
1862            .unwrap_or(entry.name.as_str());
1863        listing.push_str(&format!(
1864            "- id: {id}\n  scope: {}\n  description: {}\n  body: {}\n",
1865            entry.scope.as_str(),
1866            entry.description,
1867            body.replace('\n', " ").trim(),
1868        ));
1869    }
1870    let user = format!(
1871        "Here are {} durable memory facts. Identify exact duplicates and clearly obsolete or superseded facts to prune.\n\n{}",
1872        items.len(),
1873        listing
1874    );
1875    let request = crate::domain::ChatRequest {
1876        model_id: model_id.clone(),
1877        messages: vec![crate::models::ChatMessage::user(user)],
1878        system_prompt: CONSOLIDATE_SYSTEM_PROMPT.to_string(),
1879        instructions: None,
1880        reasoning: crate::models::ReasoningLevel::None,
1881        temperature: 0.0,
1882        max_tokens: 1024,
1883        tools: Vec::new(),
1884        ollama_num_ctx: None,
1885        ollama_allow_ram_offload: None,
1886    };
1887
1888    let provider = match factory.resolve(&model_id).await {
1889        Ok(p) => p,
1890        Err(e) => {
1891            let _ = tx
1892                .send(Msg::RuntimeText(format!(
1893                    "Memory consolidation failed: {e}"
1894                )))
1895                .await;
1896            return;
1897        },
1898    };
1899    let token = tokio_util::sync::CancellationToken::new();
1900    let text =
1901        match crate::providers::model::collect_text(provider, TurnId(0), request, token).await {
1902            Ok((t, _)) => t,
1903            Err(e) => {
1904                let _ = tx
1905                    .send(Msg::RuntimeText(format!(
1906                        "Memory consolidation failed: {e}"
1907                    )))
1908                    .await;
1909                return;
1910            },
1911        };
1912
1913    let Some(plan) = parse_prune_plan(&text) else {
1914        let _ = tx
1915            .send(Msg::RuntimeText(
1916                "Memory consolidation: couldn't parse the model's plan; nothing changed."
1917                    .to_string(),
1918            ))
1919            .await;
1920        return;
1921    };
1922    if plan.prune.is_empty() {
1923        let reason = if plan.reason.is_empty() {
1924            String::new()
1925        } else {
1926            format!(" {}", plan.reason)
1927        };
1928        let _ = tx
1929            .send(Msg::RuntimeText(format!(
1930                "Memory consolidation: nothing to prune.{reason}"
1931            )))
1932            .await;
1933        return;
1934    }
1935
1936    // Snapshot the to-be-pruned files first so the prune is reversible. The
1937    // delete below is irreversible, so a failed checkpoint must NOT proceed —
1938    // otherwise the report would advertise "Recoverable from the latest
1939    // checkpoint" for a prune with no checkpoint behind it (#F69). Abort instead;
1940    // nothing has been deleted yet, so no memory is lost.
1941    let paths: Vec<std::path::PathBuf> = plan
1942        .prune
1943        .iter()
1944        .filter_map(|id| crate::app::memory::find(&workdir, id).map(|e| e.path))
1945        .collect();
1946    if !paths.is_empty()
1947        && let Err(e) = crate::runtime::create_checkpoint(
1948            &workdir,
1949            &paths,
1950            Some(serde_json::json!({ "tool": "consolidate_memory", "reason": plan.reason })),
1951        )
1952    {
1953        let _ = tx
1954            .send(Msg::RuntimeText(format!(
1955                "Memory consolidation aborted: couldn't checkpoint the {} file{} marked for pruning, so nothing was deleted (no memory lost). Error: {e}",
1956                paths.len(),
1957                if paths.len() == 1 { "" } else { "s" },
1958            )))
1959            .await;
1960        return;
1961    }
1962
1963    let mut pruned = Vec::new();
1964    for id in &plan.prune {
1965        if let Ok(Some(_)) = crate::app::memory::delete_memory(&workdir, id) {
1966            pruned.push(id.clone());
1967        }
1968    }
1969
1970    let cfg = crate::app::load_config().unwrap_or_default().memory;
1971    let (loaded, _) = crate::app::memory::refresh(None, &workdir, &cfg);
1972    let _ = tx.send(Msg::MemoryChanged(loaded)).await;
1973
1974    let report = if pruned.is_empty() {
1975        "Memory consolidation: the model named facts to prune, but none matched existing memories."
1976            .to_string()
1977    } else {
1978        format!(
1979            "Consolidated memory — pruned {} fact{}: {}.{} Recoverable from the latest checkpoint (/checkpoints, /restore).",
1980            pruned.len(),
1981            if pruned.len() == 1 { "" } else { "s" },
1982            pruned.join(", "),
1983            if plan.reason.is_empty() {
1984                String::new()
1985            } else {
1986                format!(" Reason: {}.", plan.reason)
1987            },
1988        )
1989    };
1990    let _ = tx.send(Msg::RuntimeText(report)).await;
1991}
1992
1993async fn dispatch_compact_conversation(
1994    msg_tx: MsgSender,
1995    providers: Option<Arc<ProviderFactory>>,
1996    turn: TurnId,
1997    request: CompactionRequest,
1998    token: tokio_util::sync::CancellationToken,
1999) {
2000    let Some(factory) = providers else {
2001        let _ = msg_tx
2002            .send(Msg::CompactionFailed {
2003                turn,
2004                trigger: request.trigger,
2005                message: "EffectRunner has no ProviderFactory bound".to_string(),
2006                kind: crate::domain::StatusKind::Error,
2007            })
2008            .await;
2009        return;
2010    };
2011
2012    let provider = match factory.resolve(&request.chat.model_id).await {
2013        Ok(provider) => provider,
2014        Err(err) => {
2015            let _ = msg_tx
2016                .send(Msg::CompactionFailed {
2017                    turn,
2018                    trigger: request.trigger,
2019                    message: err.to_string(),
2020                    kind: crate::domain::StatusKind::Error,
2021                })
2022                .await;
2023            return;
2024        },
2025    };
2026
2027    let max_context_tokens = provider.capabilities().max_context_tokens.or_else(|| {
2028        crate::domain::runtime::infer_static_context_window_for_model_id(&request.chat.model_id)
2029    });
2030    let before_snapshot =
2031        crate::domain::estimate_context_usage_for_request(&request.chat, max_context_tokens);
2032
2033    let trigger = request.trigger;
2034    // A benign precondition (e.g. too little history to summarize) is a no-op, not
2035    // a failure — surface it as `Info` so the reducer shows a calm note instead of
2036    // a "Compaction failed: Invalid request" error. Real failures (model errors,
2037    // an empty/non-reducing summary) still flow through `run_compaction` as errors.
2038    let prepared = match crate::domain::prepare_compaction(&request, max_context_tokens) {
2039        Ok(prepared) => prepared,
2040        Err(skip) => {
2041            let _ = msg_tx
2042                .send(Msg::CompactionFailed {
2043                    turn,
2044                    trigger,
2045                    message: skip.to_string(),
2046                    kind: crate::domain::StatusKind::Info,
2047                })
2048                .await;
2049            return;
2050        },
2051    };
2052    match run_compaction(
2053        provider,
2054        turn,
2055        request,
2056        prepared,
2057        before_snapshot,
2058        max_context_tokens,
2059        token,
2060    )
2061    .await
2062    {
2063        Ok(result) => {
2064            let _ = msg_tx.send(Msg::CompactionFinished { turn, result }).await;
2065        },
2066        Err(err) => {
2067            let _ = msg_tx
2068                .send(Msg::CompactionFailed {
2069                    turn,
2070                    trigger,
2071                    message: err.to_string(),
2072                    kind: crate::domain::StatusKind::Error,
2073                })
2074                .await;
2075        },
2076    }
2077}
2078
2079async fn run_compaction(
2080    provider: Arc<dyn ModelProvider>,
2081    turn: TurnId,
2082    request: CompactionRequest,
2083    prepared: crate::domain::PreparedCompaction,
2084    before_snapshot: crate::domain::ContextUsageSnapshot,
2085    max_context_tokens: Option<usize>,
2086    token: tokio_util::sync::CancellationToken,
2087) -> Result<CompactionResult, ModelError> {
2088    let started = Instant::now();
2089
2090    let summary_request = crate::domain::build_summary_request(
2091        &request.chat,
2092        &prepared,
2093        request.instructions.as_deref(),
2094        request.policy,
2095    );
2096    let (draft, draft_usage) =
2097        collect_compaction_text(Arc::clone(&provider), turn, summary_request, token.clone())
2098            .await?;
2099    let draft_summary = crate::domain::normalize_summary(&draft);
2100    if draft_summary.trim().is_empty() {
2101        return Err(ModelError::InvalidRequest(
2102            "compaction produced an empty summary".to_string(),
2103        ));
2104    }
2105
2106    let verify_request = crate::domain::build_verification_request(
2107        &request.chat,
2108        &prepared,
2109        &draft_summary,
2110        request.instructions.as_deref(),
2111        request.policy,
2112    );
2113    let (final_summary, verify_usage, verified, verification_error) =
2114        match collect_compaction_text(Arc::clone(&provider), turn, verify_request, token).await {
2115            Ok((verified_text, verify_usage)) => {
2116                let verified_summary = crate::domain::normalize_summary(&verified_text);
2117                if verified_summary.trim().is_empty() {
2118                    (
2119                        draft_summary,
2120                        verify_usage,
2121                        false,
2122                        Some("verification returned an empty summary".to_string()),
2123                    )
2124                } else {
2125                    (verified_summary, verify_usage, true, None)
2126                }
2127            },
2128            Err(ModelError::Cancelled) => return Err(ModelError::Cancelled),
2129            Err(err) => (draft_summary, None, false, Some(err.to_string())),
2130        };
2131
2132    let id = format!(
2133        "compact_{}",
2134        chrono::Local::now().format("%Y%m%d_%H%M%S_%3f")
2135    );
2136    let mut record = crate::domain::CompactionRecord {
2137        id,
2138        trigger: request.trigger,
2139        created_at: chrono::Local::now(),
2140        before_tokens: before_snapshot.used_tokens,
2141        after_tokens: 0,
2142        archived_message_count: prepared.archived_messages.len(),
2143        preserved_message_count: prepared.preserved_messages.len(),
2144        summary_tokens: final_summary.len().div_ceil(4),
2145        duration_secs: started.elapsed().as_secs_f64(),
2146        verified,
2147        verification_error,
2148        focus: request.instructions.clone(),
2149        archive_path: None,
2150    };
2151
2152    let mut replacement =
2153        crate::domain::build_replacement_messages(&final_summary, &prepared, &record);
2154    let mut compacted_request = request.chat.clone();
2155    compacted_request.messages = replacement.clone();
2156    let mut after_snapshot =
2157        crate::domain::estimate_context_usage_for_request(&compacted_request, max_context_tokens);
2158    record.after_tokens = after_snapshot.used_tokens;
2159    record.duration_secs = started.elapsed().as_secs_f64();
2160    replacement = crate::domain::build_replacement_messages(&final_summary, &prepared, &record);
2161    compacted_request.messages = replacement.clone();
2162    after_snapshot =
2163        crate::domain::estimate_context_usage_for_request(&compacted_request, max_context_tokens);
2164    record.after_tokens = after_snapshot.used_tokens;
2165
2166    if after_snapshot.used_tokens >= before_snapshot.used_tokens {
2167        return Err(ModelError::InvalidRequest(format!(
2168            "compaction did not reduce context ({} -> {} tokens)",
2169            before_snapshot.used_tokens, after_snapshot.used_tokens
2170        )));
2171    }
2172
2173    if crate::domain::context_exceeds_hard_limit(
2174        &after_snapshot,
2175        &compacted_request,
2176        request.policy,
2177    ) {
2178        return Err(ModelError::InvalidRequest(format!(
2179            "compacted context still exceeds response reserve ({} tokens used)",
2180            after_snapshot.used_tokens
2181        )));
2182    }
2183
2184    Ok(CompactionResult {
2185        record,
2186        replacement_messages: replacement,
2187        archived_messages: prepared.archived_messages,
2188        before_snapshot,
2189        after_snapshot,
2190        usage: crate::domain::combine_usage(draft_usage, verify_usage),
2191    })
2192}
2193
2194async fn collect_compaction_text(
2195    provider: Arc<dyn ModelProvider>,
2196    turn: TurnId,
2197    request: crate::domain::ChatRequest,
2198    token: tokio_util::sync::CancellationToken,
2199) -> Result<(String, Option<TokenUsage>), ModelError> {
2200    // Shared with the Auto-mode safety classifier — see
2201    // `crate::providers::model::collect_text`.
2202    crate::providers::model::collect_text(provider, turn, request, token).await
2203}
2204
2205fn record_provider_capabilities(
2206    model_id: &str,
2207    caps: &crate::providers::capabilities::Capabilities,
2208) {
2209    let (provider, model) = split_model_id(model_id);
2210    if let Ok(store) = crate::runtime::RuntimeStore::open_default() {
2211        for (key, value) in [
2212            ("tools_support", caps.supports_tools.to_string()),
2213            ("vision_support", caps.supports_vision.to_string()),
2214            (
2215                "context_limit",
2216                caps.max_context_tokens
2217                    .map(|v| v.to_string())
2218                    .unwrap_or_else(|| "unknown".to_string()),
2219            ),
2220            (
2221                "reasoning_parameter_shape",
2222                format!("{:?}", caps.supports_reasoning),
2223            ),
2224            (
2225                "streaming_usage_available",
2226                "provider_dependent".to_string(),
2227            ),
2228            ("token_usage_field_shape", "normalized".to_string()),
2229        ] {
2230            let _ = store
2231                .provider_probes()
2232                .upsert(crate::runtime::NewProviderProbe {
2233                    provider: provider.clone(),
2234                    model_id: model.clone(),
2235                    capability_key: key.to_string(),
2236                    capability_value: value,
2237                    confidence: "verified".to_string(),
2238                    error: None,
2239                });
2240        }
2241    }
2242}
2243
2244fn split_model_id(model_id: &str) -> (String, String) {
2245    match model_id.split_once('/') {
2246        Some((provider, model)) if !provider.is_empty() && !model.is_empty() => {
2247            (provider.to_ascii_lowercase(), model.to_string())
2248        },
2249        _ => ("ollama".to_string(), model_id.to_string()),
2250    }
2251}
2252
2253fn is_context_limit_error(error: &ModelError) -> bool {
2254    let text = error.to_string().to_lowercase();
2255    text.contains("context")
2256        && (text.contains("too large")
2257            || text.contains("exceed")
2258            || text.contains("maximum")
2259            || text.contains("token"))
2260}
2261
2262/// Dispatch an `ExecuteTool` command.
2263#[allow(clippy::too_many_arguments)]
2264async fn dispatch_execute_tool(
2265    msg_tx: MsgSender,
2266    tools: Option<Arc<ToolRegistry>>,
2267    workdir: PathBuf,
2268    turn: TurnId,
2269    call_id: crate::domain::ToolCallId,
2270    source: crate::models::tool_call::ToolCall,
2271    token: tokio_util::sync::CancellationToken,
2272    background: tokio_util::sync::CancellationToken,
2273    config: Arc<crate::app::Config>,
2274    model_id: String,
2275    task_id: Option<String>,
2276    safety_mode: crate::runtime::SafetyMode,
2277    intent: Option<String>,
2278    classifier: Option<Arc<dyn crate::providers::AutoClassifier>>,
2279    approval: Option<crate::providers::ApprovalBroker>,
2280) {
2281    let _ = msg_tx.send(Msg::ToolStarted { turn, call_id }).await;
2282
2283    let Some(registry) = tools else {
2284        let _ = msg_tx
2285            .send(Msg::ToolFinished {
2286                turn,
2287                call_id,
2288                outcome: crate::domain::ToolOutcome::error(
2289                    "EffectRunner has no ToolRegistry bound",
2290                    0.0,
2291                ),
2292            })
2293            .await;
2294        return;
2295    };
2296
2297    // Route MCP-prefixed calls to the mcp proxy, which takes
2298    // {server_name, tool_name, arguments}. The raw model call has
2299    // those embedded in the function name and arguments respectively.
2300    let (tool_key, args) = if source.function.name.starts_with("mcp__") {
2301        let rest = &source.function.name[5..];
2302        if let Some((server, tool)) = rest.split_once("__") {
2303            (
2304                "mcp_proxy",
2305                serde_json::json!({
2306                    "server_name": server,
2307                    "tool_name": tool,
2308                    "arguments": source.function.arguments.clone(),
2309                }),
2310            )
2311        } else {
2312            let _ = msg_tx
2313                .send(Msg::ToolFinished {
2314                    turn,
2315                    call_id,
2316                    outcome: crate::domain::ToolOutcome::error(
2317                        format!("invalid MCP tool name: {}", source.function.name),
2318                        0.0,
2319                    ),
2320                })
2321                .await;
2322            return;
2323        }
2324    } else {
2325        (
2326            source.function.name.as_str(),
2327            source.function.arguments.clone(),
2328        )
2329    };
2330    let tool_run_id =
2331        start_runtime_tool_run(task_id.as_deref(), turn, call_id, tool_key, &args).await;
2332
2333    let Some(tool) = registry.get(tool_key) else {
2334        let outcome = crate::domain::ToolOutcome::error(format!("unknown tool: {}", tool_key), 0.0);
2335        finish_runtime_tool_run(tool_run_id.as_deref(), &outcome);
2336        let _ = msg_tx
2337            .send(Msg::ToolFinished {
2338                turn,
2339                call_id,
2340                outcome,
2341            })
2342            .await;
2343        return;
2344    };
2345
2346    // Bridge the tool's progress channel to `Msg::ToolProgress`.
2347    // A sibling task drains progress events while the tool runs.
2348    // The channel closes when `progress_tx` drops (when `ctx`
2349    // drops at the end of `tool.execute`), which terminates the
2350    // relay loop cleanly.
2351    let (progress_tx, mut progress_rx) = mpsc::channel(16);
2352    let relay_tx = msg_tx.clone();
2353    let relay_token = token.clone();
2354    let progress_relay = spawn_guarded(async move {
2355        loop {
2356            let event = tokio::select! {
2357                biased;
2358                _ = relay_token.cancelled() => break,
2359                ev = progress_rx.recv() => match ev {
2360                    Some(ev) => ev,
2361                    None => break,
2362                },
2363            };
2364            if relay_tx
2365                .send(Msg::ToolProgress {
2366                    turn,
2367                    call_id,
2368                    event,
2369                })
2370                .await
2371                .is_err()
2372            {
2373                break;
2374            }
2375        }
2376    });
2377
2378    let mut ctx = ExecContext::new(
2379        token,
2380        progress_tx,
2381        call_id,
2382        turn,
2383        workdir,
2384        config,
2385        model_id,
2386        task_id,
2387        safety_mode,
2388        intent,
2389        classifier,
2390        approval,
2391    );
2392    ctx.background = background;
2393    let before_payload = serde_json::json!({
2394        "turn_id": turn.0,
2395        "call_id": call_id.0,
2396        "tool": tool_key,
2397    });
2398    fire_plugin_hooks("before_tool_use", before_payload).await;
2399    let outcome = tool.execute(args, ctx).await;
2400    let after_payload = serde_json::json!({
2401        "turn_id": turn.0,
2402        "call_id": call_id.0,
2403        "tool": tool_key,
2404        "status": tool_status_label(outcome.status),
2405        "summary": &outcome.summary,
2406    });
2407    fire_plugin_hooks("after_tool_use", after_payload).await;
2408    finish_runtime_tool_run(tool_run_id.as_deref(), &outcome);
2409    join_logged(progress_relay.take(), "tool_progress_relay").await;
2410    let _ = msg_tx
2411        .send(Msg::ToolFinished {
2412            turn,
2413            call_id,
2414            outcome,
2415        })
2416        .await;
2417}
2418
2419async fn start_runtime_tool_run(
2420    task_id: Option<&str>,
2421    turn: TurnId,
2422    call_id: crate::domain::ToolCallId,
2423    tool_name: &str,
2424    args: &serde_json::Value,
2425) -> Option<String> {
2426    // Synchronous rusqlite write on the hot tool-execution path — offload it to
2427    // the blocking pool. The id is needed by `finish`, so we await the result
2428    // (unlike `finish`, which is fire-and-forget) (#39).
2429    let task_id = task_id.map(str::to_string);
2430    let tool_name = tool_name.to_string();
2431    let args_json = serde_json::to_string(args).ok();
2432    tokio::task::spawn_blocking(move || {
2433        crate::runtime::RuntimeStore::open_default()
2434            .and_then(|store| {
2435                store.tool_runs().start(crate::runtime::NewToolRun {
2436                    id: None,
2437                    task_id,
2438                    turn_id: Some(turn.0.to_string()),
2439                    call_id: Some(call_id.0.to_string()),
2440                    tool_name,
2441                    args_json,
2442                })
2443            })
2444            .map(|record| record.id)
2445            .ok()
2446    })
2447    .await
2448    .ok()
2449    .flatten()
2450}
2451
2452fn finish_runtime_tool_run(tool_run_id: Option<&str>, outcome: &crate::domain::ToolOutcome) {
2453    let Some(tool_run_id) = tool_run_id else {
2454        return;
2455    };
2456    let tool_run_id = tool_run_id.to_string();
2457    let status = tool_status_label(outcome.status).to_string();
2458    let output_json = serde_json::to_string(&serde_json::json!({
2459        "status": tool_status_label(outcome.status),
2460        "summary": &outcome.summary,
2461        "model_content": &outcome.model_content,
2462        "error": &outcome.error,
2463        "metadata": &outcome.metadata,
2464        "artifacts": &outcome.artifacts,
2465        "duration_secs": outcome.duration_secs,
2466    }))
2467    .ok();
2468    // Fire-and-forget telemetry write on the blocking pool — don't stall the
2469    // tool-finish path waiting on rusqlite (#39).
2470    tokio::task::spawn_blocking(move || {
2471        if let Ok(store) = crate::runtime::RuntimeStore::open_default() {
2472            let _ = store
2473                .tool_runs()
2474                .finish(&tool_run_id, &status, output_json.as_deref());
2475        }
2476    });
2477}
2478
2479fn tool_status_label(status: crate::domain::ToolStatus) -> &'static str {
2480    match status {
2481        crate::domain::ToolStatus::Success => "success",
2482        crate::domain::ToolStatus::Error => "error",
2483        crate::domain::ToolStatus::Cancelled => "cancelled",
2484    }
2485}
2486
2487fn runtime_model_info_text(model: &str) -> String {
2488    let snapshot = crate::domain::runtime::ProviderCapabilitySnapshot::from_model_id(model);
2489    let mut lines = vec![
2490        format!("Model info: {}", model),
2491        format!("- provider: {}", snapshot.provider),
2492        format!("- model: {}", snapshot.model),
2493        format!("- supports tools: {}", snapshot.supports_tools),
2494        format!("- supports vision: {}", snapshot.supports_vision),
2495        format!("- reasoning: {}", snapshot.reasoning),
2496        format!(
2497            "- context limit: {}",
2498            snapshot
2499                .max_context_tokens
2500                .map(|value: usize| value.to_string())
2501                .unwrap_or_else(|| "unknown".to_string())
2502        ),
2503    ];
2504    if let Ok(store) = crate::runtime::RuntimeStore::open_default()
2505        && let Ok(probes) = store
2506            .provider_probes()
2507            .list(Some(&snapshot.provider), Some(&snapshot.model))
2508        && !probes.is_empty()
2509    {
2510        lines.push(String::new());
2511        lines.push("Cached provider reality records:".to_string());
2512        for probe in probes {
2513            lines.push(format!(
2514                "- {} = {} ({})",
2515                probe.capability_key, probe.capability_value, probe.confidence
2516            ));
2517        }
2518    }
2519    lines.join("\n")
2520}
2521
2522/// Spawn `ollama pull <model>` and stream its stdout lines as
2523/// `Msg::ModelPullProgress` status updates. Emits a final
2524/// `Msg::ModelPullFinished` on successful exit; on failure, emits a
2525/// single `ModelPullProgress` with the error text.
2526async fn dispatch_pull_ollama_model(tx: MsgSender, model: String) {
2527    use tokio::io::{AsyncBufReadExt, BufReader};
2528    use tokio::process::Command;
2529
2530    let mut cmd = Command::new("ollama");
2531    cmd.arg("pull")
2532        .arg(&model)
2533        .stdin(std::process::Stdio::null())
2534        .stdout(std::process::Stdio::piped())
2535        .stderr(std::process::Stdio::piped())
2536        .kill_on_drop(true);
2537
2538    let mut child = match cmd.spawn() {
2539        Ok(c) => c,
2540        Err(e) => {
2541            let _ = tx
2542                .send(Msg::ModelPullProgress(format!(
2543                    "ollama pull failed to start: {}",
2544                    e
2545                )))
2546                .await;
2547            return;
2548        },
2549    };
2550
2551    // Capture the reader's handle instead of orphaning it: the child's stdout
2552    // closes when it exits, so this task finishes right after `child.wait`
2553    // below — we join it there so a panic is logged, not silently lost (#60).
2554    let reader_handle = child.stdout.take().map(|stdout| {
2555        let tx_inner = tx.clone();
2556        tokio::spawn(async move {
2557            let mut reader = BufReader::new(stdout).lines();
2558            while let Ok(Some(line)) = reader.next_line().await {
2559                let _ = tx_inner.send(Msg::ModelPullProgress(line)).await;
2560            }
2561        })
2562    });
2563
2564    match child.wait().await {
2565        Ok(status) if status.success() => {
2566            let _ = tx.send(Msg::ModelPullFinished { model }).await;
2567        },
2568        Ok(status) => {
2569            let _ = tx
2570                .send(Msg::ModelPullProgress(format!(
2571                    "ollama pull exited with status {}",
2572                    status.code().unwrap_or(-1)
2573                )))
2574                .await;
2575        },
2576        Err(e) => {
2577            let _ = tx
2578                .send(Msg::ModelPullProgress(format!(
2579                    "ollama pull wait error: {}",
2580                    e
2581                )))
2582                .await;
2583        },
2584    }
2585
2586    // The child has exited; its stdout is closed, so the reader is finishing.
2587    // Join it (logging a panic) so it isn't left orphaned (#60).
2588    if let Some(handle) = reader_handle {
2589        join_logged(handle, "ollama_pull_reader").await;
2590    }
2591}
2592
2593fn mcp_startup_msg(name: &str, started: bool, tools: Vec<crate::domain::McpToolSpec>) -> Msg {
2594    if started {
2595        Msg::McpServerReady {
2596            name: name.to_string(),
2597            tools,
2598        }
2599    } else {
2600        Msg::McpServerErrored {
2601            name: name.to_string(),
2602            reason: "server failed to start or initialize".to_string(),
2603        }
2604    }
2605}
2606
2607/// Read the system clipboard on a blocking thread and emit a `Msg`
2608/// back into the main loop. Image content wins when present; falls
2609/// back to text; empty or error surface as `Msg::TransientStatus` so
2610/// the user gets visible feedback (a silent no-op on Ctrl+V would be
2611/// confusing, especially on macOS where `osascript` can take ~300ms).
2612///
2613/// `tokio::task::spawn_blocking` is the right primitive: `clipboard::
2614/// has_image` / `read_image_bytes` / `read_text` shell out to xclip /
2615/// wl-paste / pngpaste / PowerShell, all of which block synchronously.
2616async fn dispatch_read_clipboard(tx: MsgSender) {
2617    use crate::domain::{Paste, StatusKind};
2618
2619    enum Outcome {
2620        Image { bytes: Vec<u8>, format: String },
2621        Text(String),
2622        Empty,
2623        Error(String),
2624    }
2625
2626    let outcome = tokio::task::spawn_blocking(|| {
2627        if crate::clipboard::has_image() {
2628            match crate::clipboard::read_image_bytes() {
2629                Ok((bytes, format)) => Outcome::Image { bytes, format },
2630                Err(e) => Outcome::Error(format!("Clipboard image read failed: {}", e)),
2631            }
2632        } else {
2633            match crate::clipboard::read_text() {
2634                Ok(t) if !t.is_empty() => Outcome::Text(t),
2635                Ok(_) => Outcome::Empty,
2636                Err(e) => Outcome::Error(format!("Clipboard empty / read failed: {}", e)),
2637            }
2638        }
2639    })
2640    .await
2641    .unwrap_or_else(|e| Outcome::Error(format!("clipboard spawn_blocking: {}", e)));
2642
2643    let msg = match outcome {
2644        Outcome::Image { bytes, format } => Msg::Paste(Paste::Image { bytes, format }),
2645        Outcome::Text(text) => Msg::Paste(Paste::Text(text)),
2646        Outcome::Empty => Msg::TransientStatus {
2647            text: "Clipboard is empty".to_string(),
2648            kind: StatusKind::Info,
2649            dismiss_ms: 2_000,
2650        },
2651        Outcome::Error(text) => Msg::TransientStatus {
2652            text,
2653            kind: StatusKind::Warn,
2654            dismiss_ms: 4_000,
2655        },
2656    };
2657    let _ = tx.send(msg).await;
2658}
2659
2660/// Write text to the system clipboard on a blocking thread (the platform
2661/// tools shell out and block), then report the result via a transient status.
2662async fn dispatch_copy_to_clipboard(text: String, tx: MsgSender) {
2663    use crate::domain::StatusKind;
2664
2665    let char_count = text.chars().count();
2666    let result = tokio::task::spawn_blocking(move || crate::clipboard::write_text(&text))
2667        .await
2668        .unwrap_or_else(|e| Err(anyhow::anyhow!("clipboard spawn_blocking: {e}")));
2669
2670    let msg = match result {
2671        Ok(()) => Msg::TransientStatus {
2672            text: format!("Copied {char_count} chars to clipboard"),
2673            kind: StatusKind::Info,
2674            dismiss_ms: 2_000,
2675        },
2676        Err(e) => Msg::TransientStatus {
2677            text: format!("Copy failed: {e}"),
2678            kind: StatusKind::Warn,
2679            dismiss_ms: 4_000,
2680        },
2681    };
2682    let _ = tx.send(msg).await;
2683}
2684
2685fn classify_error_for_ui(e: &crate::models::ModelError) -> crate::models::UserFacingError {
2686    use crate::models::{ErrorCategory, ModelError, UserFacingError};
2687    match e {
2688        ModelError::Backend(b) => UserFacingError {
2689            summary: "Backend error".to_string(),
2690            message: b.to_string(),
2691            suggestion: "Check the provider endpoint / API key.".to_string(),
2692            category: ErrorCategory::Connection,
2693            recoverable: true,
2694        },
2695        ModelError::Authentication(msg) => UserFacingError {
2696            summary: "Auth error".to_string(),
2697            message: msg.clone(),
2698            suggestion: "Set the env var the provider expects.".to_string(),
2699            category: ErrorCategory::Auth,
2700            recoverable: false,
2701        },
2702        ModelError::RateLimit { retry_after } => UserFacingError {
2703            summary: "Rate limit".to_string(),
2704            message: format!("retry after {:?}", retry_after),
2705            suggestion: "Wait and try again.".to_string(),
2706            category: ErrorCategory::Temporary,
2707            recoverable: true,
2708        },
2709        ModelError::StreamError(msg) => UserFacingError {
2710            summary: "Stream error".to_string(),
2711            message: msg.clone(),
2712            suggestion: "Retry the request.".to_string(),
2713            category: ErrorCategory::Connection,
2714            recoverable: true,
2715        },
2716        other => UserFacingError {
2717            summary: "Model error".to_string(),
2718            message: other.to_string(),
2719            suggestion: String::new(),
2720            category: ErrorCategory::Internal,
2721            recoverable: false,
2722        },
2723    }
2724}
2725
2726#[cfg(test)]
2727mod tests {
2728    use super::*;
2729    use crate::domain::ToolCallId;
2730    use std::time::Duration;
2731
2732    fn runner() -> (EffectRunner, mpsc::Receiver<Msg>) {
2733        EffectRunner::pair(PathBuf::from("/tmp"))
2734    }
2735
2736    #[test]
2737    fn new_child_suppresses_terminal_title() {
2738        // A subagent's child runner must not emit OSC 2 terminal titles —
2739        // otherwise they leak into a headless parent's stdout and corrupt
2740        // `--format json`/`text` output (caught during live headless testing).
2741        let (tx, _rx) = mpsc::channel::<Msg>(MSG_CHANNEL_CAPACITY);
2742        let providers = Arc::new(ProviderFactory::new(crate::app::Config::default()));
2743        let tools = Arc::new(ToolRegistry::new());
2744        let child = EffectRunner::new_child(tx, PathBuf::from("/tmp"), providers, tools);
2745        assert!(
2746            !child.terminal_title_enabled,
2747            "subagent child runner must suppress terminal-title escapes"
2748        );
2749    }
2750
2751    #[test]
2752    fn parse_prune_plan_extracts_json_amid_prose() {
2753        let plan = parse_prune_plan(
2754            "Sure, here's the plan:\n```json\n{\"prune\": [\"a\", \"b\"], \"reason\": \"dupes\"}\n```\nDone.",
2755        )
2756        .expect("should parse");
2757        assert_eq!(plan.prune, vec!["a".to_string(), "b".to_string()]);
2758        assert_eq!(plan.reason, "dupes");
2759    }
2760
2761    #[test]
2762    fn parse_prune_plan_handles_empty_and_garbage() {
2763        let empty = parse_prune_plan("{\"prune\": [], \"reason\": \"all distinct\"}")
2764            .expect("empty plan parses");
2765        assert!(empty.prune.is_empty());
2766        assert!(parse_prune_plan("no json here").is_none());
2767    }
2768
2769    #[test]
2770    fn memory_title_from_text_is_short_and_nonempty() {
2771        assert_eq!(
2772            memory_title_from_text("prefer ripgrep over grep"),
2773            "prefer ripgrep over grep"
2774        );
2775        assert_eq!(memory_title_from_text("   "), "memory");
2776        let long = memory_title_from_text("one two three four five six seven eight nine ten");
2777        assert!(long.split_whitespace().count() <= 8);
2778    }
2779
2780    #[tokio::test]
2781    async fn dispatch_exit_is_noop_on_runner_state() {
2782        let (mut r, _rx) = runner();
2783        r.dispatch(Cmd::Exit);
2784        assert_eq!(r.scope_count(), 0);
2785    }
2786
2787    #[tokio::test]
2788    async fn dispatch_save_emits_session_saved() {
2789        let (mut r, mut rx) = runner();
2790        r.dispatch(Cmd::SaveConversation(
2791            crate::session::ConversationHistory::new("/p".to_string(), "m".to_string()),
2792        ));
2793        let msg = tokio::time::timeout(Duration::from_millis(200), rx.recv())
2794            .await
2795            .expect("sender emits")
2796            .expect("channel alive");
2797        assert!(matches!(msg, Msg::SessionSaved));
2798    }
2799
2800    #[tokio::test]
2801    async fn dispatch_dismiss_after_delay_emits_status_dismiss() {
2802        let (mut r, mut rx) = runner();
2803        let t0 = std::time::Instant::now();
2804        r.dispatch(Cmd::DismissStatusAfter { ms: 30 });
2805        let msg = tokio::time::timeout(Duration::from_millis(300), rx.recv())
2806            .await
2807            .expect("sender emits")
2808            .expect("channel alive");
2809        assert!(matches!(msg, Msg::StatusDismiss));
2810        assert!(t0.elapsed() >= Duration::from_millis(25));
2811    }
2812
2813    #[test]
2814    fn mcp_startup_msg_treats_zero_tool_started_server_as_ready() {
2815        let msg = mcp_startup_msg("empty", true, Vec::new());
2816        assert!(matches!(
2817            msg,
2818            Msg::McpServerReady { name, tools } if name == "empty" && tools.is_empty()
2819        ));
2820    }
2821
2822    #[test]
2823    fn mcp_startup_msg_reports_unstarted_server_as_error() {
2824        let msg = mcp_startup_msg("bad", false, Vec::new());
2825        assert!(matches!(
2826            msg,
2827            Msg::McpServerErrored { name, reason }
2828                if name == "bad" && reason.contains("failed to start")
2829        ));
2830    }
2831
2832    #[tokio::test]
2833    async fn cancel_scope_emits_turn_cancelled_after_bounded_timeout() {
2834        let (mut r, mut rx) = runner();
2835        let turn = TurnId(77);
2836        {
2837            let scope = r.scope_mut(turn);
2838            scope.spawn(async {
2839                std::future::pending::<()>().await;
2840            });
2841        }
2842        assert_eq!(r.scope_count(), 1);
2843
2844        let start = std::time::Instant::now();
2845        r.dispatch(Cmd::CancelScope(turn));
2846        assert_eq!(r.scope_count(), 0);
2847        let msg = tokio::time::timeout(Duration::from_millis(500), rx.recv())
2848            .await
2849            .expect("bounded cancel should emit terminal message")
2850            .expect("channel alive");
2851        assert!(matches!(msg, Msg::TurnCancelled(t) if t == turn));
2852        assert!(
2853            start.elapsed() < Duration::from_millis(500),
2854            "cancel terminal message took {:?}",
2855            start.elapsed()
2856        );
2857    }
2858
2859    #[tokio::test]
2860    async fn cancel_scope_emits_turn_cancelled_even_after_reaping() {
2861        // Regression (Axis 1 #9): if a turn's tasks complete and
2862        // `reap_empty_scopes` removes the now-empty scope before the user's
2863        // cancel lands, `drop_scope` used to be a silent no-op and the reducer
2864        // stuck forever in `Cancelling`. The terminal `TurnCancelled` must fire
2865        // even when the scope is already gone.
2866        let (mut r, mut rx) = runner();
2867        let turn = TurnId(88);
2868        {
2869            let scope = r.scope_mut(turn);
2870            scope.spawn(async {}); // completes immediately
2871        }
2872        assert_eq!(r.scope_count(), 1);
2873
2874        // Let the task finish, then any dispatch reaps the now-empty scope.
2875        tokio::time::sleep(Duration::from_millis(20)).await;
2876        r.dispatch(Cmd::Exit);
2877        assert_eq!(r.scope_count(), 0, "completed scope should be reaped");
2878
2879        // The scope is gone, but the reducer is still `Cancelling`: cancel must
2880        // still produce a terminal message.
2881        r.dispatch(Cmd::CancelScope(turn));
2882        let msg = tokio::time::timeout(Duration::from_millis(500), rx.recv())
2883            .await
2884            .expect("cancel on a reaped scope must still emit a terminal message")
2885            .expect("channel alive");
2886        assert!(matches!(msg, Msg::TurnCancelled(t) if t == turn));
2887    }
2888
2889    #[tokio::test]
2890    async fn dispatch_call_model_creates_scope() {
2891        let (mut r, _rx) = runner();
2892        let turn = TurnId(7);
2893        let request = crate::domain::ChatRequest {
2894            model_id: "test/m".to_string(),
2895            messages: vec![],
2896            system_prompt: String::new(),
2897            instructions: None,
2898            reasoning: crate::models::ReasoningLevel::Medium,
2899            temperature: 0.7,
2900            max_tokens: 4096,
2901            tools: vec![],
2902
2903            ollama_num_ctx: None,
2904            ollama_allow_ram_offload: None,
2905        };
2906        r.dispatch(Cmd::CallModel { turn, request });
2907        assert_eq!(r.scope_count(), 1);
2908    }
2909
2910    /// F12: after a spawned task completes (here via the
2911    /// no-ProviderFactory error path), the next `dispatch` call reaps
2912    /// the empty scope instead of leaving an orphan entry in the map.
2913    #[tokio::test]
2914    async fn empty_scopes_are_reaped_on_next_dispatch() {
2915        let (mut r, mut rx) = runner();
2916        let turn = TurnId(42);
2917        let request = crate::domain::ChatRequest {
2918            model_id: "test/m".to_string(),
2919            messages: vec![],
2920            system_prompt: String::new(),
2921            instructions: None,
2922            reasoning: crate::models::ReasoningLevel::Medium,
2923            temperature: 0.7,
2924            max_tokens: 4096,
2925            tools: vec![],
2926
2927            ollama_num_ctx: None,
2928            ollama_allow_ram_offload: None,
2929        };
2930        r.dispatch(Cmd::CallModel { turn, request });
2931        assert_eq!(r.scope_count(), 1);
2932
2933        // Runner has no provider bindings → dispatch_call_model hits
2934        // the "not wired" error path and emits UpstreamError, then the
2935        // spawned task returns. Drain that message so we know the task
2936        // ran to completion.
2937        let msg = tokio::time::timeout(Duration::from_millis(200), rx.recv())
2938            .await
2939            .expect("upstream error arrived")
2940            .expect("channel alive");
2941        assert!(matches!(msg, Msg::UpstreamError { .. }));
2942
2943        // Give the JoinSet a tick to notice the task finished.
2944        tokio::task::yield_now().await;
2945
2946        // Any subsequent dispatch reaps the now-empty scope.
2947        r.dispatch(Cmd::DismissStatusAfter { ms: 10 });
2948        assert_eq!(
2949            r.scope_count(),
2950            0,
2951            "completed scope must be reaped on next dispatch"
2952        );
2953    }
2954
2955    #[tokio::test]
2956    async fn dispatch_execute_tool_under_turn_emits_tool_started() {
2957        let (mut r, mut rx) = runner();
2958        let turn = TurnId(7);
2959        let call_id = ToolCallId(1);
2960        let source = crate::models::tool_call::ToolCall {
2961            id: Some("c1".to_string()),
2962            function: crate::models::tool_call::FunctionCall {
2963                name: "read_file".to_string(),
2964                arguments: serde_json::json!({"path": "x"}),
2965            },
2966        };
2967        r.dispatch(Cmd::ExecuteTool {
2968            turn,
2969            call_id,
2970            source,
2971            model_id: "ollama/test".to_string(),
2972            safety_mode: crate::runtime::SafetyMode::Ask,
2973            intent: None,
2974        });
2975        let first = tokio::time::timeout(Duration::from_millis(200), rx.recv())
2976            .await
2977            .expect("some msg")
2978            .expect("channel alive");
2979        assert!(matches!(
2980            first,
2981            Msg::ToolStarted {
2982                turn: t,
2983                call_id: c,
2984            } if t == turn && c == call_id
2985        ));
2986    }
2987
2988    #[tokio::test]
2989    async fn cancel_scope_before_execute_tool_drops_pending_work() {
2990        let (mut r, _rx) = runner();
2991        let turn = TurnId(9);
2992        r.dispatch(Cmd::CallModel {
2993            turn,
2994            request: crate::domain::ChatRequest {
2995                model_id: "m".to_string(),
2996                messages: vec![],
2997                system_prompt: String::new(),
2998                instructions: None,
2999                reasoning: crate::models::ReasoningLevel::Medium,
3000                temperature: 0.7,
3001                max_tokens: 4096,
3002                tools: vec![],
3003
3004                ollama_num_ctx: None,
3005                ollama_allow_ram_offload: None,
3006            },
3007        });
3008        assert_eq!(r.scope_count(), 1);
3009
3010        r.dispatch(Cmd::CancelScope(turn));
3011        assert_eq!(r.scope_count(), 0);
3012    }
3013
3014    #[tokio::test]
3015    async fn tombstoned_turn_is_not_resurrected_by_late_scoped_cmd() {
3016        // F38: once a turn's scope has been cancelled (dropped + tombstoned), a
3017        // stray turn-scoped Cmd bearing the same TurnId must be dropped — not
3018        // used to spin up a fresh, un-cancelled scope via `scope_mut`'s
3019        // `or_insert_with`. Turn ids are monotonic and never reused, so such a
3020        // Cmd can only be a post-cancel straggler.
3021        let (mut r, _rx) = runner();
3022        let req = || crate::domain::ChatRequest {
3023            model_id: "test/m".to_string(),
3024            messages: vec![],
3025            system_prompt: String::new(),
3026            instructions: None,
3027            reasoning: crate::models::ReasoningLevel::Medium,
3028            temperature: 0.7,
3029            max_tokens: 4096,
3030            tools: vec![],
3031            ollama_num_ctx: None,
3032            ollama_allow_ram_offload: None,
3033        };
3034        let turn = TurnId(123);
3035
3036        r.dispatch(Cmd::CallModel {
3037            turn,
3038            request: req(),
3039        });
3040        assert_eq!(r.scope_count(), 1);
3041
3042        // Cancel: drops the scope and tombstones the turn.
3043        r.dispatch(Cmd::CancelScope(turn));
3044        assert_eq!(r.scope_count(), 0);
3045
3046        // A late scoped Cmd for the now-tombstoned turn must be dropped.
3047        r.dispatch(Cmd::CallModel {
3048            turn,
3049            request: req(),
3050        });
3051        assert_eq!(
3052            r.scope_count(),
3053            0,
3054            "a cancelled turn must not be resurrected by a late scoped Cmd"
3055        );
3056
3057        // A fresh, higher turn id is unaffected by the tombstone.
3058        r.dispatch(Cmd::CallModel {
3059            turn: TurnId(124),
3060            request: req(),
3061        });
3062        assert_eq!(
3063            r.scope_count(),
3064            1,
3065            "a fresh turn must still create its scope normally"
3066        );
3067    }
3068
3069    #[tokio::test]
3070    async fn shutdown_drains_pending_saves() {
3071        let (mut r, _rx) = runner();
3072        for _ in 0..5 {
3073            r.dispatch(Cmd::SaveConversation(
3074                crate::session::ConversationHistory::new("/p".to_string(), "m".to_string()),
3075            ));
3076        }
3077        // Shutdown waits for all five to complete (should be instant).
3078        let start = std::time::Instant::now();
3079        r.shutdown().await;
3080        assert!(start.elapsed() < Duration::from_secs(2));
3081    }
3082}