Skip to main content

lattice_plugin_loader/
lib.rs

1//! `lattice-plugin-loader` — the editor-side plugin loader (Phase 8).
2//!
3//! Phase 7 shipped the plugin *runtime* ([`lattice_plugin_host`]): the wasmtime
4//! engine, the WIT API package, the capability/fuel/crash model, and every
5//! extension seam, each exercised end-to-end by guest fixtures. That crate is
6//! deliberately **substrate-neutral** — it owns "engine + seams" and knows
7//! nothing about the editor: no XDG discovery, no ex-commands, no native
8//! registries. A headless test harness or a future non-editor host can drive it
9//! unchanged.
10//!
11//! This crate is the **subsystem that composes the runtime with the editor's
12//! native registries**. It discovers plugins on disk ([`discovery`]), loads
13//! them (`compile → spawn each declared seam → drain the contribution into its
14//! native registry`), owns the loaded-plugin state past boot as a service, and
15//! (PL8.C) will expose the user-facing load/unload/reload surface.
16//!
17//! # Where this sits (the loader-home decision)
18//!
19//! Three homes were weighed (slice plan, "where the loader lives"): inline in
20//! `lattice-host`, folded into `lattice-plugin-host`, or a dedicated crate. This
21//! is the dedicated crate — the genuinely-better long-term fit (heuristic #1):
22//! the runtime crate stays "engine + seams"; inlining in the host would grow
23//! `Editor::` methods + a host dispatch arm (the half-migration the
24//! mode-ownership acid test forbids). The loader reaches the native registries
25//! through the same [`SubsystemBoot`](lattice_mode::SubsystemBoot) seam every
26//! other subsystem installs through, so wiring it into the editor is one line
27//! ([`install`]) and zero host internals.
28//!
29//! # Status (PL8.B — picker / config / events / grammar / modes / completion)
30//!
31//! On-disk discovery + six seam→registry drains are live: a plugin dropped in
32//! `<data>/lattice/plugins/` loads at boot and its contribution is reachable.
33//! - **picker** RCU-registers its source into the
34//!   [`PickerRegistryHandle`](lattice_picker::PickerRegistryHandle);
35//! - **config** registers its typed options into the live
36//!   [`ConfigRegistry`](lattice_config::ConfigRegistry);
37//! - **events** subscribes its handlers on the [`EventBus`];
38//! - **grammar** registers its motions / operators / text-objects / ex-commands
39//!   into the runtime-mutable
40//!   [`CommandRegistryHandle`](lattice_grammar::CommandRegistryHandle) (B3a/B3b)
41//!   — the sync-trampoline seam, so the dispatcher fires it on keystroke off a
42//!   wait-free `.load()` snapshot with no actor task;
43//! - **modes** registers its minor modes into the runtime-mutable
44//!   [`ModeRegistryHandle`](lattice_mode::ModeRegistryHandle) (B2), each mode's
45//!   keymap binding landing in its own gated `MinorMode` layer on the
46//!   [`KeymapHandle`] — declarative data, so the guest `Store` drops after
47//!   registration (no task, nothing to keep alive);
48//! - **completion** wraps its `WasmCompletionSource` as a native async
49//!   `CompletionSourceContribution` carried by a loader-owned universal
50//!   [`PluginCompletionMode`], so the aggregator reads it through
51//!   `Mode::completion_sources()` like any LSP / snippet source (option A —
52//!   completion is mode-attached; the async `generate` runs on a spawned actor,
53//!   off the keystroke path).
54//!
55//! Each records provenance for `:list-plugins` via the [`PluginMetaSink`] seam.
56//! That closes the PL8.B seam drains. **PL8.C** adds the user-facing lifecycle:
57//! the loader self-registers `:plugin-load` / `:plugin-unload` / `:plugin-reload`
58//! into the runtime-mutable command registry ([`register_ex_commands`](PluginLoader::register_ex_commands),
59//! option A — zero host code), and [`unload`](PluginLoader::unload) reverses every
60//! registry contribution via [`PluginTeardown`]. Decoration caching is the
61//! separate hot-path slice PL8.E; `init.rs`-as-WASM is PL8.D.
62//!
63//! Design: `docs/dev/architecture/plugin-host.md`,
64//! `docs/dev/architecture/boot-composition.md`. Slice plan:
65//! `docs/dev/operations/slice-plans/plugin-loader.md`.
66
67pub mod build;
68pub mod discovery;
69pub mod events;
70mod ex_commands;
71pub mod install;
72pub mod pipeline;
73pub mod resolve;
74pub mod source_record;
75pub mod watch;
76
77pub use build::{
78    BuildOutcome, CargoComponentBuilder, ComponentBuilder, Stamp, artifact_path, build_plugin,
79    source_stamp,
80};
81pub use discovery::{
82    DiscoveredPlugin, default_core_plugins_dir, default_init_dir, default_plugins_dir,
83    default_source_cache_dir, discover, discover_one,
84};
85pub use events::LanguagesRegistered;
86pub use install::{
87    autoload_enabled, disable_autoload, enable_autoload, flush_plugin_stores, install,
88};
89pub use pipeline::{Install, RequiredSpec, install_all, install_required, to_required_spec};
90pub use resolve::{
91    Fetcher, GitRunner, HttpFetcher, PluginSource, Resolved, SystemGit, git_cache_dir, resolve,
92};
93pub use source_record::SourceRecord;
94
95use std::sync::{Arc, Mutex};
96
97use lattice_completion::{CompletionSourceContribution, CompletionSourceKind, SourceId};
98use lattice_config::ConfigRegistry;
99use lattice_grammar::CommandRegistryHandle;
100use lattice_keymap::KeymapHandle;
101use lattice_mode::{
102    ActivationPolicy, AsyncContextSource, AsyncGutterDecorationSource, CapabilitySet,
103    ContextSourceRegistryHandle, GutterDecorationSourceRegistryHandle, LifecycleFuture, Mode,
104    ModeContext, ModeId, ModeKind, ModeRegistryHandle, PluginMetaSinkHandle,
105};
106use lattice_picker::{PickerRegistryHandle, PickerSourceGenerator};
107use lattice_plugin_host::{
108    Capability, LoadedPlugin, ManifestError, PluginBudget, PluginHost, PluginHostError, PluginId,
109    PluginManifest, PluginSeam, PluginTeardown, TeardownRegistries, TeardownReport, TrustTier,
110    WasmCompletionSource, WasmContextSource, WasmDecorationSource, WasmPickerSource,
111};
112use lattice_protocol::{Event, EventKind};
113use lattice_runtime::{EventBus, EventFilter, SubscriptionTarget};
114use tokio::runtime::Handle;
115use tokio::task::JoinHandle;
116
117mod status;
118pub use status::{BuildState, FailedLoad, PluginHealth, PluginStatus};
119
120/// An error and every cause behind it, as `outer: inner: innermost`.
121///
122/// **`err.to_string()` is the wrong thing to report and this exists to replace
123/// it.** Every error type here is a `thiserror` enum whose variants carry a
124/// `#[source]`, and `Display` prints only the outermost line. So a plugin that
125/// failed to instantiate reported exactly `failed to instantiate the plugin
126/// component` — the *category*, never the reason — while the wasmtime error
127/// saying which import was missing or which type did not match sat one link
128/// down, discarded.
129///
130/// That is a bad outcome anywhere and a specially bad one here, because the
131/// message is the whole product: `record_failure` exists (its comment says so)
132/// because a missing diagnostic once cost a debugging session, and it was
133/// itself throwing away the half that identifies the fault. The generic line
134/// then sends every reader to the same wrong fix, since the view's footer
135/// suggests `--wit-sync` whether or not the ABI has anything to do with it.
136///
137/// Bounded at eight links: a chain longer than that is a wrapper bug, and an
138/// unbounded walk over a cyclic `source()` would hang the loader rather than
139/// report anything.
140pub fn error_chain(err: &dyn std::error::Error) -> String {
141    let mut out = err.to_string();
142    let mut source = err.source();
143    let mut depth = 0;
144    while let Some(cause) = source {
145        if depth == 8 {
146            out.push_str(": …");
147            break;
148        }
149        out.push_str(": ");
150        out.push_str(&cause.to_string());
151        source = cause.source();
152        depth += 1;
153    }
154    out
155}
156
157/// The service handle other layers reach the loader through — the ex-command
158/// surface (PL8.C), the plugin-manager view (PL8.H). Per the `ServiceRegistry`
159/// Arc/TypeId rule, register **and** look up with this exact alias
160/// (`Arc<PluginLoader>`), never a bare `PluginLoader`.
161pub type PluginLoaderHandle = Arc<PluginLoader>;
162
163/// A live loaded plugin: its host-issued [`PluginId`], its manifest id (the
164/// user-facing name, and the key for `:plugin-unload <name>`), the source dir to
165/// re-load from on `:plugin-reload`, the actor-lifecycle handles, and the
166/// [`PluginTeardown`] bundle that reverses its registry contributions on unload.
167struct LoadedRecord {
168    id: PluginId,
169    name: String,
170    /// The directory this plugin was loaded from — re-loaded on
171    /// `:plugin-reload`. `None` for plugins loaded from bytes in tests (reload
172    /// is then a no-op with a logged reason).
173    source_dir: Option<std::path::PathBuf>,
174    /// Lifecycle-only plugins (base `plugin` world — `init.rs`, no-op) keep
175    /// their instance alive here; dropping it drops the `Store`. Seam plugins
176    /// are driven by their actor task instead, so this is `None` for them.
177    lifecycle: Option<LoadedPlugin>,
178    /// The detached actor tasks driving this plugin's seams (picker / events /
179    /// completion). Aborted on unload — the actor-lifecycle half. Kept so the
180    /// tasks are not cancelled by a dropped `JoinHandle` (tokio detaches on
181    /// drop). [`PluginTeardown`] reverses the *registry* half; these are the
182    /// running actors it does not cover.
183    tasks: Vec<JoinHandle<()>>,
184    /// The registry-contribution reversal bundle — each drain fills its surface's
185    /// tokens (grammar / picker / modes / config options / event subscriptions);
186    /// `PluginTeardown::unload` consumes it against the live registries on
187    /// `:plugin-unload` / reload.
188    teardown: PluginTeardown,
189    /// PL8.H.1: the trust tier this plugin loaded under — reported in the
190    /// manager view's status, and the gate that decides `denied` below.
191    tier: TrustTier,
192    /// PL8.H.1: capabilities the plugin requested AND received under `tier`
193    /// (`requested` minus `denied`). Computed once at load from the manifest.
194    granted: Vec<Capability>,
195    /// PL8.H.1: requested-but-withheld capabilities (tier-gated). Never fatal —
196    /// the plugin loaded degraded; the manager view surfaces this.
197    denied: Vec<Capability>,
198    /// PL8.H.1: live health — `Healthy` at load, flipped to `Quarantined` by the
199    /// `Event::PluginCrashed` subscription ([`PluginLoader::subscribe_health`]).
200    health: PluginHealth,
201    /// PM.3: the mode this plugin enables by default (from its manifest), gated by
202    /// `<id>.enabled`. Kept so the `OptionChanged` subscription
203    /// ([`PluginLoader::subscribe_mode_gates`]) can map a changed `<id>.enabled`
204    /// back to the modes to (de)activate. Empty ⇒ no default mode.
205    default_modes: Vec<String>,
206    /// PM.8a: where the plugin came from (its `.source` marker at load time).
207    source: crate::source_record::SourceRecord,
208}
209
210/// PM.8b: what a build for one plugin is doing right now.
211#[derive(Debug, Clone, PartialEq, Eq)]
212enum BuildActivity {
213    Running,
214    Failed,
215}
216
217/// PM.8a: is this plugin's artifact current with its source?
218///
219/// Recomputed from disk per snapshot rather than cached at load, because the
220/// interesting transition happens *while the editor runs* — a user edits a
221/// local plugin's source and wants the view to say `stale` without a restart.
222/// It is two small file reads per row, on the `:plugins` refresh path, not a
223/// per-frame cost.
224/// The removable directories under `root`, given the names to keep.
225///
226/// Split from [`PluginLoader::removable_plugin_dirs`] so the rules can be
227/// tested against a real directory tree without a loaded editor behind them —
228/// `default_plugins_dir()` is the user's actual config root, which a test must
229/// never read and certainly never delete from.
230///
231/// Name-ordered, like every other list the manager view reads.
232fn removable_under(
233    root: &std::path::Path,
234    keep: &std::collections::HashSet<String>,
235) -> Vec<(String, std::path::PathBuf)> {
236    let Ok(entries) = std::fs::read_dir(root) else {
237        return Vec::new();
238    };
239    let mut out: Vec<(String, std::path::PathBuf)> = entries
240        .flatten()
241        .filter(|e| e.path().is_dir())
242        .filter_map(|e| {
243            let name = e.file_name().to_string_lossy().to_string();
244            if keep.contains(&name) {
245                return None;
246            }
247            // Clause 4: no provenance, no removal. Re-installing needs a
248            // source to re-install FROM; without one the bytes are the only
249            // copy there is.
250            if !e.path().join(".source").is_file() {
251                tracing::debug!(
252                    plugin = %name,
253                    "clean: skipping a directory with no `.source` marker"
254                );
255                return None;
256            }
257            Some((name, e.path()))
258        })
259        .collect();
260    out.sort_by(|a, b| a.0.cmp(&b.0));
261    out
262}
263
264/// Remove the `names` that still appear in `removable`.
265///
266/// The re-check is the point, not a formality: a confirmation the user left
267/// sitting while a plugin loaded must not delete the plugin that just
268/// arrived. A name that has stopped being removable is `Skipped`, never
269/// deleted and never reported as a failure.
270///
271/// Split from [`PluginLoader::clean`] so that rule is testable without the
272/// real config root behind it.
273fn clean_listed(removable: &[(String, std::path::PathBuf)], names: &[String]) -> BulkReport {
274    let by_name: std::collections::HashMap<&str, &std::path::PathBuf> =
275        removable.iter().map(|(n, p)| (n.as_str(), p)).collect();
276    let mut report = BulkReport::default();
277    for name in names {
278        let leg = match by_name.get(name.as_str()) {
279            None => BulkLeg::Skipped("no longer removable".to_string()),
280            Some(path) => match std::fs::remove_dir_all(path) {
281                Ok(()) => {
282                    tracing::info!(plugin = %name, path = %path.display(), "plugin directory removed");
283                    BulkLeg::Done
284                }
285                Err(e) => BulkLeg::Failed(format!("remove {}: {e}", path.display())),
286            },
287        };
288        report.legs.push((name.clone(), leg));
289    }
290    report
291}
292
293/// Which bulk verb [`PluginLoader::spawn_bulk`] should run.
294///
295/// A tag rather than three `spawn_*` methods: the scaffolding around each —
296/// find the runtime, spawn, log the summary and every failure — is identical,
297/// and the only difference is which `async fn` gets awaited in the middle.
298#[derive(Debug, Clone, Copy, PartialEq, Eq)]
299pub enum BulkOp {
300    /// [`PluginLoader::rebuild_all`].
301    Rebuild,
302    /// [`PluginLoader::reload_all`].
303    Reload,
304    /// [`PluginLoader::update_all`].
305    Update,
306}
307
308impl BulkOp {
309    /// The past-tense word its summary counts with.
310    fn past(self) -> &'static str {
311        match self {
312            BulkOp::Rebuild => "rebuilt",
313            BulkOp::Reload => "reloaded",
314            BulkOp::Update => "updated",
315        }
316    }
317}
318
319/// What a bulk run did to one plugin.
320///
321/// `Skipped` is not `Failed`, and keeping them apart is the whole reason this
322/// is an enum rather than a `Result`. "Pinned, so there was nothing to update"
323/// and "the build broke" both leave the plugin exactly as it was, but only one
324/// of them is something the user needs to go and look at. A run that reports
325/// `4 updated, 2 pinned` reads as success; the same run reporting `4 updated,
326/// 2 failed` sends someone hunting for a problem that does not exist.
327#[derive(Debug, Clone, PartialEq, Eq)]
328pub enum BulkLeg {
329    /// The operation ran and succeeded.
330    Done,
331    /// The operation did not apply to this plugin, for the reason given.
332    Skipped(String),
333    /// The operation applied, ran, and failed.
334    Failed(String),
335}
336
337/// The outcome of a bulk operation, one leg per plugin.
338///
339/// Ordered as the plugins were visited, which is name order — the same order
340/// `:plugins` lists them in, so a report can be read against the view.
341#[derive(Debug, Clone, Default, PartialEq, Eq)]
342pub struct BulkReport {
343    pub legs: Vec<(String, BulkLeg)>,
344}
345
346impl BulkReport {
347    fn count(&self, f: impl Fn(&BulkLeg) -> bool) -> usize {
348        self.legs.iter().filter(|(_, leg)| f(leg)).count()
349    }
350
351    /// How many legs succeeded.
352    pub fn done(&self) -> usize {
353        self.count(|l| matches!(l, BulkLeg::Done))
354    }
355
356    /// How many legs did not apply.
357    pub fn skipped(&self) -> usize {
358        self.count(|l| matches!(l, BulkLeg::Skipped(_)))
359    }
360
361    /// How many legs ran and failed.
362    pub fn failed(&self) -> usize {
363        self.count(|l| matches!(l, BulkLeg::Failed(_)))
364    }
365
366    /// Every plugin that failed, with its reason — what the caller logs.
367    pub fn failures(&self) -> Vec<(&str, &str)> {
368        self.legs
369            .iter()
370            .filter_map(|(name, leg)| match leg {
371                BulkLeg::Failed(why) => Some((name.as_str(), why.as_str())),
372                _ => None,
373            })
374            .collect()
375    }
376
377    /// A one-line summary for the echo.
378    ///
379    /// Names only the non-zero parts, so the common all-succeeded run says
380    /// `3 updated` rather than `3 updated, 0 skipped, 0 failed` — a count of
381    /// zero is noise that makes the counts that matter harder to find.
382    pub fn summary(&self, verb_past: &str) -> String {
383        if self.legs.is_empty() {
384            return "no plugins loaded".to_string();
385        }
386        let mut parts = vec![format!("{} {verb_past}", self.done())];
387        if self.skipped() > 0 {
388            parts.push(format!("{} skipped", self.skipped()));
389        }
390        if self.failed() > 0 {
391            parts.push(format!("{} failed", self.failed()));
392        }
393        parts.join(", ")
394    }
395}
396
397/// Why `update` can do nothing for `source` — `None` when it can.
398///
399/// Only the pinned-git arm refuses. `Local` is always current (the directory
400/// IS the source), `Prebuilt` re-downloads on every resolve, and unpinned git
401/// is the case the verb exists for. A pin, though, is already the answer to
402/// "which commit": updating past it would discard the choice the user wrote
403/// in `init.rs`, and updating to it is what boot already did.
404///
405/// Split out from [`PluginLoader::update`] so the table is testable without a
406/// loaded plugin behind it — the refusal is a property of the source alone.
407fn update_refusal(name: &str, source: Option<&resolve::PluginSource>) -> Option<String> {
408    match source {
409        Some(resolve::PluginSource::Git { rev: Some(rev), .. }) => {
410            Some(format!("`{name}` is pinned to {rev}; nothing to update"))
411        }
412        _ => None,
413    }
414}
415
416fn build_state_of(record: &LoadedRecord, activity: Option<&BuildActivity>) -> BuildState {
417    // PM.8b: an in-flight or just-failed build is the more current answer —
418    // the artifact on disk describes the *previous* build, and reporting
419    // `cached` while a rebuild is running would tell the user their `b` did
420    // nothing.
421    match activity {
422        Some(BuildActivity::Running) => return BuildState::Building,
423        Some(BuildActivity::Failed) => return BuildState::Failed,
424        None => {}
425    }
426    let Some(source) = record.source.as_plugin_source() else {
427        return BuildState::NotBuilt;
428    };
429    if matches!(source, crate::resolve::PluginSource::Prebuilt { .. }) {
430        // A prebuilt is downloaded, never built, so it has no staleness.
431        return BuildState::NotBuilt;
432    }
433    let Some(dir) = record.source_dir.as_ref() else {
434        return BuildState::NotBuilt;
435    };
436    // The stamp records what the artifact was built from and (WT.3) against;
437    // the source and this editor's ABI are what they stand at now. PM.5 owns
438    // both halves of that comparison.
439    let stamp_path = dir.join(".build-stamp");
440    let Ok(text) = std::fs::read_to_string(&stamp_path) else {
441        // No stamp: the artifact was placed by hand or by a lattice predating
442        // PM.5. Nothing to compare against, so nothing to claim.
443        return BuildState::NotBuilt;
444    };
445    let Some(stamped) = crate::build::Stamp::parse(&text) else {
446        // WT.3: a stamp from a lattice predating the ABI field. It cannot say
447        // what the artifact was built against, so it cannot support a `cached`
448        // claim — and `cached` is exactly the false reassurance that made the
449        // original failure invisible.
450        return BuildState::NotBuilt;
451    };
452    let build_dir = match &source {
453        crate::resolve::PluginSource::Local(p) => p.clone(),
454        // A git plugin builds out of its source-cache checkout.
455        _ => crate::default_source_cache_dir().join(&record.name),
456    };
457    if !build_dir.is_dir() {
458        return BuildState::NotBuilt;
459    }
460    if stamped == crate::build::Stamp::current(&build_dir) {
461        BuildState::Cached
462    } else {
463        // Either the source moved or the ABI did. Both are answered the same
464        // way — rebuild from source — so `:plugins` does not need to
465        // distinguish them, and the build log says which it was.
466        BuildState::Stale
467    }
468}
469
470/// The editor-side runtime environment the loader drives seams against —
471/// captured once from the boot context in [`install`], or built directly by a
472/// headless harness / test. Every handle is `Option` because a given consumer
473/// wires only the seams it exercises; a seam drain with an absent handle is a
474/// logged skip, never a panic. Grows one field per seam without churning the
475/// [`PluginLoader::with_services`] signature.
476#[derive(Default, Clone)]
477pub struct LoaderServices {
478    /// The shared multi-thread runtime handle (seam actors + discovery run here,
479    /// never the current-thread editor actor).
480    pub runtime: Option<Handle>,
481    /// The typed event bus — seam-actor crash quarantine binds to it, and event
482    /// plugins subscribe through it.
483    pub bus: Option<Arc<EventBus>>,
484    /// The runtime-mutable picker registry (RCU-register loaded picker sources).
485    pub picker_registry: Option<PickerRegistryHandle>,
486    /// The config registry (already interior-mutable) plugin options register into.
487    pub config_registry: Option<Arc<ConfigRegistry>>,
488    /// The runtime-mutable command registry (B3a/B3b) a grammar plugin's
489    /// motions / operators / text-objects / ex-commands register into. The
490    /// dispatch path (`DocumentActor`, host-side ex-command / completion reads)
491    /// snapshots it wait-free with `.load()`, so a plugin registered at runtime
492    /// is live for a buffer on its next keystroke.
493    pub command_registry: Option<CommandRegistryHandle>,
494    /// The runtime-mutable mode registry (B2) a mode plugin's minor modes
495    /// register into. RCU'd like the command registry — an owned snapshot is
496    /// cloned, `spawn_mode_plugin` drains into it, and it is published, so
497    /// keymap-resolution / mode-activation reads stay wait-free.
498    pub mode_registry: Option<ModeRegistryHandle>,
499    /// The interior-mutable keymap handle a mode plugin's per-mode `MinorMode`
500    /// keymap bindings land in (a shared clone — its writes are internally
501    /// mutex+ArcSwap-routed, so `spawn_mode_plugin` mutates it through a `&`).
502    pub keymap: Option<KeymapHandle>,
503    /// The provenance sink — records `PluginId → name/doc` for `:list-plugins`.
504    pub meta_sink: Option<PluginMetaSinkHandle>,
505    /// PL8.E: the runtime-mutable decoration-producer registry (RCU-register a
506    /// loaded decoration plugin's `WasmDecorationSource`). The host's per-tick
507    /// `maybe_refresh_wasm_decorations` reads the same handle wait-free.
508    pub decoration_registry: Option<GutterDecorationSourceRegistryHandle>,
509    /// IM.6b: where a loaded `media` plugin's producer is registered.
510    pub media_registry: Option<lattice_mode::MediaSourceRegistryHandle>,
511    /// OM.A1: where a plugin's agenda-row producer lands. Absent leaves the
512    /// seam `NotWired` — the load fails loudly rather than reporting success
513    /// and contributing nothing to every `:agenda` forever.
514    pub agenda_registry: Option<lattice_mode::ScannedExcerptSourceRegistryHandle>,
515    /// CM.2: binds a plugin operator's declared chord into the universal
516    /// operator-pending layer. The host owns it because the composition needs
517    /// host-resolved builtins; the plugin owns the spec and `apply`. Absent
518    /// leaves the seam `NotWired` for any plugin that declares a chord — an
519    /// operator that registered correctly and has no keys is indistinguishable
520    /// from one that never loaded.
521    pub operator_chords: Option<lattice_mode::OperatorChordWirerHandle>,
522    /// MV.1: the provider-view registry a plugin's declared multibuffer views
523    /// register openers into — the SAME one the agenda and magit's project-diff
524    /// use, so a plugin view opens through `open-provider-view` and refreshes
525    /// through `gr` exactly as a native one does. Absent leaves the seam
526    /// `NotWired`, failing the load loudly rather than reporting success and
527    /// contributing a view that can never be opened.
528    pub provider_view_registry: Option<lattice_mode::ProviderViewRegistryHandle>,
529    /// The multibuffer registry the view's excerpts land in once `build`
530    /// answers. Separate from the provider registry because they are different
531    /// services: one resolves a NAME to an opener, the other a view's BufferId
532    /// to its excerpt handle.
533    pub multibuffer_registry: Option<lattice_multibuffer::registry::MultibufferRegistryHandle>,
534    /// TC.2: the runtime-mutable context-producer registry (RCU-register a
535    /// loaded context plugin's `WasmContextSource`). The host's reparse-driven
536    /// refresh reads the same handle wait-free.
537    pub context_registry: Option<ContextSourceRegistryHandle>,
538    /// TC.4: the theme registry a `theme` plugin's elements register into — the
539    /// SAME one builtins use, so a plugin element is themeable and
540    /// `:customize`-able like any other.
541    pub theme_registry: Option<lattice_theme::ThemeRegistryHandle>,
542    /// SG.3a: the sign registry a `signs` plugin's definitions land in — the
543    /// SAME one native producers use, so a plugin's sign is styled through the
544    /// ordinary theme registry and contends for the mark cell by the same
545    /// priority rule, with no host kind-branch.
546    pub sign_registry: Option<lattice_mode::SignRegistryHandle>,
547    /// OC.3 / ML.6: the modeline element registry a plugin's `ui.register-segment`
548    /// declares into — the SAME one built-ins and native modes use, so a plugin
549    /// segment lays out, orders and hides exactly like `lsp` or `claude-code`
550    /// with no renderer branch anywhere.
551    pub modeline: Option<lattice_mode::ModelineServiceHandle>,
552    /// CM.6b: the compilation parser-factory registry an `error-parser`
553    /// plugin's factory RCU-registers into. The compilation service reads
554    /// the same handle once per run to mint each pipe reader's parser.
555    pub parser_factories: Option<lattice_compilation::CompilationParserFactoriesHandle>,
556    /// CR.3: the help-topic registry a `help` plugin's pages RCU-register
557    /// into — the SAME one the builtin docs live in, so a plugin page opens,
558    /// completes and cross-links like any other.
559    pub help_topics: Option<lattice_help::topics::HelpTopicRegistryHandle>,
560    /// CR.4: the dashboard section registry a `dashboard` plugin's sections
561    /// RCU-register into. Shadowing rather than overwriting (CR.2), so a
562    /// plugin replacing a builtin section is reversed by unload.
563    pub dashboard_sections: Option<lattice_dashboard::DashboardRegistryHandle>,
564    /// TR.2b: the transient-menu registry a `transient-source` plugin's menu
565    /// registers into — the SAME one magit's menus live in, so a plugin menu
566    /// opens through `Effect::OpenTransient` like any other. Owned by
567    /// `editor_boot` since TR.1, which is what stops a plugin menu depending
568    /// on whether magit happened to load.
569    pub transient_registry: Option<lattice_picker::TransientSourceRegistryHandle>,
570    /// PO.2: the boundary tracer the loader attaches to each async seam actor
571    /// (`actor.with_tracer(...)` before spawning `run()`), so the actor emits a
572    /// `PluginTraceRecord` per guest call. `None` degrades to no tracing.
573    pub tracer: Option<lattice_plugin_host::PluginTracerHandle>,
574}
575
576/// Which drain-required services the loader captured at [`install`] time —
577/// reported by [`PluginLoader::wired_seams`]. Every flag must be `true` after a
578/// real editor boot; a `false` is a boot-ordering regression (the loader was
579/// installed before that service registered) that silently degrades the
580/// dependent seam's drain to a `NotWired` skip.
581#[derive(Debug, Clone, Copy, PartialEq, Eq)]
582pub struct WiredSeams {
583    pub runtime: bool,
584    pub bus: bool,
585    pub picker_registry: bool,
586    pub config_registry: bool,
587    pub command_registry: bool,
588    pub mode_registry: bool,
589    pub keymap: bool,
590    pub meta_sink: bool,
591    pub decoration_registry: bool,
592    pub context_registry: bool,
593    pub theme_registry: bool,
594    /// SG.3a: the sign registry.
595    pub sign_registry: bool,
596    /// OC.3 / ML.6: the modeline element registry.
597    pub modeline: bool,
598    /// CM.6b: the compilation parser-factory registry.
599    pub parser_factories: bool,
600    /// CR.3: the help-topic registry.
601    pub help_topics: bool,
602    /// CR.4: the dashboard section registry.
603    pub dashboard_sections: bool,
604    /// IM.6b: the inline-media producer registry.
605    ///
606    /// Added at OM.A1 alongside `agenda_registry`. It was missing — media
607    /// drained through a service this struct never reported on, so a boot
608    /// ordering regression there would have degraded `drain_media` to a
609    /// `NotWired` skip with nothing asserting otherwise. Adding the sibling
610    /// and leaving this one silent would be aligned-by-silence.
611    pub media_registry: bool,
612    /// OM.A1: the agenda-row producer registry.
613    pub agenda_registry: bool,
614    /// TR.2b: the transient-menu registry.
615    pub transient_registry: bool,
616    /// MV.1: the multibuffer registry — where a plugin's declared views land.
617    pub multibuffer_registry: bool,
618    /// OA.23 / HB.2b: whether the HOST carries an excerpt-source resolver.
619    ///
620    /// Not a service capture like its neighbours: `install` builds the resolver
621    /// over the multibuffer registry and hands it to the host, so the thing to
622    /// assert is what the host holds. Reported because the failure is invisible
623    /// otherwise — an unwired seam answers `none`, which is also its answer for
624    /// "this line is not composed", so a guest standing on an agenda row cannot
625    /// tell a boot-ordering regression from an ordinary miss.
626    pub excerpt_source: bool,
627    /// OA.27: whether the HOST carries a view-args resolver.
628    ///
629    /// Reported for `excerpt_source`'s reason, and the invisibility is worse
630    /// here: an unwired seam answers an EMPTY LIST, which a guest parses as a
631    /// view showing nothing in particular — so every chord that walks a view
632    /// silently restarts from the default span, day and filter set, with no
633    /// error on any path. That is the bug the seam replaces; a boot-ordering
634    /// regression would reinstate it unnoticed.
635    pub view_args: bool,
636    /// OA.30: whether the HOST carries the decoration-refresh counter.
637    ///
638    /// Reported for `view_args`' reason and it fails the same silent way: a
639    /// guest producer whose state changed can say so, nobody is listening, and
640    /// the gutter simply never updates.
641    pub view_decoration_epoch: bool,
642    /// CD.6b: whether the HOST carries the buffer store `clamp-position` reads.
643    ///
644    /// Unwired, every buffer reads as closed, so a capture's write-back into
645    /// its caller is skipped with a message blaming a buffer that is open.
646    pub buffer_store: bool,
647}
648
649impl WiredSeams {
650    /// True when every drain-required service was captured — the boot pin's
651    /// assertion. `runtime` + `bus` are always present (`install` clones them
652    /// from the boot context directly, not via `service::<T>()`).
653    pub fn all(&self) -> bool {
654        self.runtime
655            && self.bus
656            && self.picker_registry
657            && self.config_registry
658            && self.command_registry
659            && self.mode_registry
660            && self.keymap
661            && self.meta_sink
662            && self.decoration_registry
663            && self.context_registry
664            && self.theme_registry
665            && self.sign_registry
666            && self.modeline
667            && self.parser_factories
668            && self.help_topics
669            && self.dashboard_sections
670            && self.transient_registry
671            && self.multibuffer_registry
672            && self.excerpt_source
673            && self.view_args
674            && self.view_decoration_epoch
675            && self.buffer_store
676    }
677}
678
679/// The plugin loader subsystem: owns the runtime, the loaded-plugin set, and the
680/// discovery + load orchestration. Stood up at boot by [`install`], which
681/// captures the editor environment and registers the loader as a
682/// [`PluginLoaderHandle`] service so the user surface reaches it generically.
683pub struct PluginLoader {
684    host: Arc<PluginHost>,
685    env: LoaderServices,
686    /// `std::sync::Mutex` (not `tokio`): taken only to push / read the loaded
687    /// set *after* the async load work completes, never across an `.await`.
688    loaded: Mutex<Vec<LoadedRecord>>,
689    /// PM.8b: builds running (or failed) **this session**, keyed by plugin
690    /// name.
691    ///
692    /// The only piece of build state not derived from disk. It is
693    /// deliberately not persisted: a build interrupted by a crash is not
694    /// still running after a restart, and a failure the user has since fixed
695    /// should not greet them on the next boot. On a fresh start the artifact
696    /// either exists — with a stamp saying whether it is stale — or it does
697    /// not, and that is the whole truth.
698    building: Mutex<std::collections::HashMap<String, BuildActivity>>,
699    /// PM.8b: how many builds are running, as a lock-free counter.
700    ///
701    /// Duplicated from `building` on purpose. The headerline's `version()` is
702    /// polled by the cells worker on **every tick** and the trait's contract
703    /// says it must not block; taking a mutex there — even an uncontended one
704    /// — puts a lock on a per-tick path for a number that is almost always
705    /// zero. The map stays the source of truth for *which* plugin is doing
706    /// what; this is the cheap "is anything happening" the tick asks.
707    building_count: std::sync::atomic::AtomicUsize,
708    /// PM.7b: specs declared via `plugin-manager.require`, accumulated as
709    /// config guests load and drained once by the boot task.
710    ///
711    /// It lives here rather than being returned from `load_discovered`
712    /// because the seam is one of several a guest may provide — an init.rs
713    /// that also contributes a keymap goes down the same path — and threading
714    /// a second return value through every arm to serve one of them would put
715    /// the cost on all of them.
716    required: Mutex<Vec<pipeline::RequiredSpec>>,
717    /// WT.4: plugins that tried to load and could not, for `:plugins`.
718    ///
719    /// In memory, not on disk. A load failure is a fact about *this* boot
720    /// against *this* editor — persisting it would mean showing a user an error
721    /// about a plugin they have since rebuilt, which is the same reasoning
722    /// [`BuildState::Failed`] is in-memory for.
723    failed: Mutex<Vec<FailedLoad>>,
724}
725
726/// Why a plugin failed to load. Every variant is graceful-degradation input for
727/// the caller (discovery logs + skips; the editor never aborts boot on one bad
728/// plugin) — the load path returns a value, never panics.
729#[derive(Debug, thiserror::Error)]
730pub enum PluginLoaderError {
731    /// The manifest was malformed or declared an unrecognised capability/seam.
732    #[error("plugin manifest invalid: {0}")]
733    Manifest(#[from] ManifestError),
734    /// The component failed to compile, instantiate, activate, or spawn a seam —
735    /// a wasm trap, fuel/epoch exhaustion, or a capability failure.
736    #[error("plugin runtime error: {0}")]
737    Host(#[from] PluginHostError),
738    /// A seam was declared but the loader was constructed without the editor
739    /// environment needed to drive it (the minimal test constructor). Never
740    /// happens on the real boot path.
741    #[error("plugin loader not wired for seam `{0}` (no editor environment)")]
742    NotWired(&'static str),
743    /// The plugin declared no seam the loader can drain yet, so nothing loaded.
744    #[error("plugin declares no loadable seam")]
745    NothingLoaded,
746    /// `:plugin-load <path>` pointed at a directory that is not a plugin (no
747    /// `plugin.toml`, bad TOML, or missing/ambiguous component).
748    #[error("cannot load plugin from path: {0}")]
749    Discovery(String),
750    /// `:plugin-unload` / `:plugin-reload <target>` named no currently-loaded
751    /// plugin (by manifest id or numeric plugin id).
752    #[error("no loaded plugin named `{0}`")]
753    NotLoaded(String),
754    /// `:plugin-reload` on a plugin the loader can't re-read from disk (loaded
755    /// from bytes in a test, or its source dir is gone).
756    #[error("plugin `{0}` has no on-disk source to reload from")]
757    NotReloadable(String),
758}
759
760/// The canonical id of the user-config plugin — the `:reload-config` /
761/// auto-reload target, and the `id = "init"` a `<config>/lattice/init/plugin.toml`
762/// declares.
763pub(crate) const INIT_PLUGIN_ID: &str = "init";
764
765/// How the source → artifact build went during [`reload_config`] — a
766/// `:reload-config` or a plugins-view rebuild of the `init` row. Carried in
767/// [`ReloadConfigReport`] so the surface (the `*messages*` echo, the plugins
768/// view row) can say precisely what happened, and on a failure show the
769/// compiler diagnostics rather than a bare "it failed".
770///
771/// [`reload_config`]: PluginLoader::reload_config
772#[derive(Debug, Clone, PartialEq, Eq)]
773pub enum ConfigBuildStatus {
774    /// The cargo project was rebuilt from source; the fresh artifact was loaded.
775    Rebuilt,
776    /// The source was already current (stamp match); the cached artifact was
777    /// reloaded — no toolchain ran.
778    AlreadyCurrent,
779    /// The init dir holds no cargo project — a hand-built `init.wasm` was
780    /// reloaded as-is (nothing to compile).
781    HandBuilt,
782    /// The build failed. A previous artifact (if any) is still loaded, so the
783    /// editor keeps running the LAST good config — but the edit did NOT take.
784    /// Carries the compiler diagnostics (the tail of cargo's stderr).
785    BuildFailed(String),
786}
787
788/// The detailed outcome of a [`reload_config`](PluginLoader::reload_config) —
789/// enough for the user to see what happened and, on a build failure, the
790/// compiler error.
791#[derive(Debug, Clone)]
792pub struct ReloadConfigReport {
793    /// The host id of the (re)loaded `init` plugin.
794    pub id: PluginId,
795    /// How the build went.
796    pub build: ConfigBuildStatus,
797}
798
799impl ReloadConfigReport {
800    /// Did the edited `init.rs` actually take effect? `false` when the build
801    /// failed and the previous artifact is what is running.
802    pub fn applied_new_config(&self) -> bool {
803        !matches!(self.build, ConfigBuildStatus::BuildFailed(_))
804    }
805
806    /// A user-facing message for `*messages*` / an echo — a one-line verdict,
807    /// plus the compiler diagnostics on a build failure.
808    pub fn summary(&self) -> String {
809        match &self.build {
810            ConfigBuildStatus::Rebuilt => {
811                "config reloaded: rebuilt init.rs and applied it".to_string()
812            }
813            ConfigBuildStatus::AlreadyCurrent => {
814                "config reloaded: init.rs already up to date, re-applied".to_string()
815            }
816            ConfigBuildStatus::HandBuilt => {
817                "config reloaded: re-applied the prebuilt init.wasm (no cargo project)".to_string()
818            }
819            ConfigBuildStatus::BuildFailed(error) => format!(
820                "config reload FAILED to rebuild init.rs — still running the previous \
821                 config. Fix the error and reload again:\n{error}"
822            ),
823        }
824    }
825}
826
827/// Match a loaded record against a `:plugin-unload` / `:plugin-reload` target —
828/// its manifest id (the common case) or its numeric host-issued plugin id.
829fn record_matches(record: &LoadedRecord, target: &str) -> bool {
830    record.name == target || target.parse::<u32>().ok() == Some(record.id.0)
831}
832
833/// Default priority bucket for a plugin completion source — below LSP (200) and
834/// snippets (150), above bare buffer-word sources. A per-plugin priority
835/// override (a manifest field / `completion.source.<id>.priority` option) is
836/// future work; for now every plugin source shares this documented default.
837const PLUGIN_COMPLETION_DEFAULT_PRIORITY: u32 = 100;
838
839/// OA.14d: how long a load waits for its `pre-plugin-loaded` handlers.
840///
841/// Generous rather than tight, because what runs behind it is an `init.rs`
842/// handler doing a handful of `set-option` calls — a whole second is already
843/// three orders of magnitude of headroom, and the number exists only so a
844/// handler that hangs cannot hang the boot with it. It is not a latency budget;
845/// nothing user-visible is waiting on this (plugin loading is already off the
846/// boot thread by design).
847const PRE_PLUGIN_LOADED_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
848
849/// The loader-owned minor mode that carries a plugin's completion source into
850/// the native aggregator (option A — completion is mode-attached everywhere:
851/// LSP rides the LSP mode, snippets ride the snippet mode). Registered
852/// [`ActivationPolicy::Universal`] so the source contributes on every
853/// completion-capable buffer; `recompute_active_completion_sources_for` walks
854/// the mode registry and picks up [`completion_sources`](Mode::completion_sources)
855/// like any native mode's. A manifest-declared scope (attach to a named
856/// language / major mode instead of universal) is the natural extension.
857#[derive(Debug)]
858struct PluginCompletionMode {
859    id: ModeId,
860    source: CompletionSourceContribution,
861}
862
863impl Mode for PluginCompletionMode {
864    type Guard = ();
865
866    fn id(&self) -> ModeId {
867        self.id
868    }
869
870    fn kind(&self) -> ModeKind {
871        ModeKind::Minor
872    }
873
874    fn activation_policy(&self) -> ActivationPolicy {
875        ActivationPolicy::Universal
876    }
877
878    fn required_capabilities(&self) -> CapabilitySet {
879        CapabilitySet::empty()
880    }
881
882    fn completion_sources(&self) -> Vec<CompletionSourceContribution> {
883        vec![self.source.clone()]
884    }
885
886    fn on_activate(&self, _ctx: ModeContext) -> LifecycleFuture<'_, ()> {
887        Box::pin(async { Ok(()) })
888    }
889}
890
891impl PluginLoader {
892    /// Construct a loader over `host` with **no** editor environment — the
893    /// minimal constructor for tests exercising only the lifecycle spine.
894    /// [`install`] uses [`with_env`](Self::with_env) to wire the real seams.
895    pub fn new(host: Arc<PluginHost>) -> Self {
896        Self {
897            host,
898            env: LoaderServices::default(),
899            loaded: Mutex::new(Vec::new()),
900            building: Mutex::new(std::collections::HashMap::new()),
901            building_count: std::sync::atomic::AtomicUsize::new(0),
902            required: Mutex::new(Vec::new()),
903            failed: Mutex::new(Vec::new()),
904        }
905    }
906
907    /// Construct a loader wired with the editor environment ([`LoaderServices`])
908    /// — the boot path ([`install`]) and headless harnesses / tests. The seams a
909    /// plugin declares are driven against the wired handles; an absent handle
910    /// makes that seam a logged skip.
911    pub fn with_services(host: Arc<PluginHost>, services: LoaderServices) -> Self {
912        // OA.14d: give the HOST the option registry, so every store it mints
913        // can answer `get-option` — not only the seams whose spawn signature
914        // happens to take one. Done here rather than in `install` because a
915        // test harness builds its loader through this constructor too, and the
916        // gap this closes (`theme` and `language` reading org's keyword set)
917        // is exactly the kind that hides when production and tests wire
918        // different things.
919        if let Some(registry) = &services.config_registry {
920            host.set_config_registry(Arc::clone(registry));
921        }
922        Self {
923            host,
924            env: services,
925            loaded: Mutex::new(Vec::new()),
926            building: Mutex::new(std::collections::HashMap::new()),
927            building_count: std::sync::atomic::AtomicUsize::new(0),
928            required: Mutex::new(Vec::new()),
929            failed: Mutex::new(Vec::new()),
930        }
931    }
932
933    /// The number of currently-loaded plugins. The spine proof + the PL8.H
934    /// manager view read it.
935    pub fn loaded_count(&self) -> usize {
936        self.loaded
937            .lock()
938            .expect("plugin-loader loaded-set mutex poisoned")
939            .len()
940    }
941
942    /// Which drain-required services the loader captured from the boot context
943    /// ([`install`]). A boot-ordering regression (installing the loader before a
944    /// service it depends on registers) silently leaves a field `false`, turning
945    /// that seam's drain into a `NotWired` skip — so the boot pin asserts every
946    /// flag is set after `Editor::boot`. Test/introspection affordance.
947    pub fn wired_seams(&self) -> WiredSeams {
948        WiredSeams {
949            runtime: self.env.runtime.is_some(),
950            bus: self.env.bus.is_some(),
951            picker_registry: self.env.picker_registry.is_some(),
952            config_registry: self.env.config_registry.is_some(),
953            command_registry: self.env.command_registry.is_some(),
954            mode_registry: self.env.mode_registry.is_some(),
955            keymap: self.env.keymap.is_some(),
956            meta_sink: self.env.meta_sink.is_some(),
957            decoration_registry: self.env.decoration_registry.is_some(),
958            context_registry: self.env.context_registry.is_some(),
959            theme_registry: self.env.theme_registry.is_some(),
960            sign_registry: self.env.sign_registry.is_some(),
961            modeline: self.env.modeline.is_some(),
962            parser_factories: self.env.parser_factories.is_some(),
963            help_topics: self.env.help_topics.is_some(),
964            dashboard_sections: self.env.dashboard_sections.is_some(),
965            media_registry: self.env.media_registry.is_some(),
966            agenda_registry: self.env.agenda_registry.is_some(),
967            transient_registry: self.env.transient_registry.is_some(),
968            multibuffer_registry: self.env.multibuffer_registry.is_some(),
969            excerpt_source: self.host.excerpt_source_wired(),
970            view_args: self.host.view_args_wired(),
971            view_decoration_epoch: self.host.decoration_epoch_wired(),
972            buffer_store: self.host.buffer_store_wired(),
973        }
974    }
975
976    /// Whether a plugin with manifest id `name` is currently loaded (the
977    /// `:plugin-unload <name>` / `:plugin-reload <name>` resolution, PL8.C).
978    pub fn is_loaded(&self, name: &str) -> bool {
979        self.loaded
980            .lock()
981            .expect("plugin-loader loaded-set mutex poisoned")
982            .iter()
983            .any(|r| r.name == name)
984    }
985
986    /// Discover every plugin under `dir` and load each, logging + skipping any
987    /// that fails (never aborting the others). Returns the count loaded. Runs on
988    /// the caller (the multi-thread runtime), off the editor actor.
989    pub async fn discover_and_load(&self, dir: &std::path::Path, tier: TrustTier) -> usize {
990        let discovered = discovery::discover(dir);
991        let mut loaded = 0;
992        for plugin in discovered {
993            // Already loaded by something else — skip rather than load it a
994            // SECOND time. Boot has two paths into the same directory: a
995            // `require`d plugin is staged into the user root and loaded
996            // eagerly (so `enable_mode` can fire against a real load), and the
997            // on-disk scan then walks that same root. Without this, every
998            // `require`d plugin registered its modes, commands and keymaps
999            // twice and appeared twice in `:plugins`.
1000            //
1001            // The guard belongs here, on the SCANNING path, rather than in
1002            // `load_discovered`: "load everything in this directory" can
1003            // always skip what is already in, whereas an explicit
1004            // `:plugin-load` is a request the user made and `reload` unloads
1005            // before loading again.
1006            if self.is_loaded(&plugin.manifest.id) {
1007                tracing::debug!(
1008                    plugin = %plugin.manifest.id,
1009                    dir = %plugin.dir.display(),
1010                    "already loaded; not loading it a second time"
1011                );
1012                continue;
1013            }
1014            match self.load_discovered(&plugin, tier).await {
1015                Ok(_) => {
1016                    loaded += 1;
1017                    self.clear_failure(&plugin.manifest.id);
1018                }
1019                Err(err) => {
1020                    // WT.4: `warn!` reaches `*messages*`, and the record reaches
1021                    // `:plugins`. Both, because they answer different questions:
1022                    // the log says a thing went wrong just now, the record
1023                    // answers "why is org not here?" asked ten minutes later.
1024                    // `error_chain`, not `%err`: Display on these enums is
1025                    // the category alone, and the cause is what a reader needs.
1026                    let detail = error_chain(&err);
1027                    tracing::warn!(
1028                        plugin = %plugin.manifest.id,
1029                        dir = %plugin.dir.display(),
1030                        error = %detail,
1031                        "plugin failed to load; skipped"
1032                    );
1033                    self.record_failure(&plugin.manifest.id, &plugin.dir, &detail);
1034                }
1035            }
1036        }
1037        loaded
1038    }
1039
1040    /// Load one already-discovered plugin: compile, then either drive the
1041    /// lifecycle spine (empty `provides`) or drain each declared seam, and
1042    /// record its provenance. Returns the host-issued [`PluginId`].
1043    pub async fn load_discovered(
1044        &self,
1045        plugin: &DiscoveredPlugin,
1046        tier: TrustTier,
1047    ) -> Result<PluginId, PluginLoaderError> {
1048        let manifest = &plugin.manifest;
1049        let component = self.host.compile(&plugin.component_bytes)?;
1050
1051        // PL8.H.1: resolve the capability grant once for the manager-view status.
1052        // `grant` is pure (manifest + tier), so this mirrors exactly what each
1053        // seam spawn computes internally — `denied` is the tier-withheld set,
1054        // `granted` the requested capabilities that survived it.
1055        let outcome = lattice_plugin_host::grant(manifest, tier);
1056        let denied = outcome.denied.clone();
1057        let granted: Vec<Capability> = manifest
1058            .requested
1059            .iter()
1060            .filter(|cap| !denied.contains(cap))
1061            .cloned()
1062            .collect();
1063
1064        let mut record = LoadedRecord {
1065            id: PluginId(0),
1066            name: manifest.id.clone(),
1067            source_dir: Some(plugin.dir.clone()),
1068            lifecycle: None,
1069            tasks: Vec::new(),
1070            teardown: {
1071                let mut t = PluginTeardown::new(PluginId(0));
1072                // OC.3 / ML.6: recorded up front rather than per drain, because
1073                // reversal is by NAMESPACE — any seam of this plugin may
1074                // register a modeline segment, at any point in its life, and a
1075                // per-drain token list would miss the late ones and leave an
1076                // orphan descriptor rendering forever.
1077                t.modeline_namespace = Some(manifest.id.clone());
1078                t
1079            },
1080            tier,
1081            granted,
1082            denied,
1083            health: PluginHealth::Healthy,
1084            default_modes: manifest.default_modes.clone(),
1085            source: plugin.source.clone(),
1086        };
1087        // EVERY host id this load issued, in DRAIN order (see the sort below —
1088        // no longer the order `provides` lists them in).
1089        //
1090        // Each `spawn_*` issues its own — deliberately, since a provenance id
1091        // must never be derived from guest-controlled input and so cannot be
1092        // keyed on the manifest's string id. That means a plugin providing N
1093        // seams stamps its contributions with N provenances, and teardown has
1094        // to reverse all of them. Keeping only the first is why bundled
1095        // `auto-pair` (grammar, modes, config, help) leaked its `:help` pages
1096        // on unload.
1097        let mut seam_ids: Vec<PluginId> = Vec::new();
1098
1099        if manifest.provides.is_empty() {
1100            // OA.14d: fires here too, so the event's contract is "once per
1101            // load, before this plugin runs anything" rather than "once per
1102            // load that happens to declare seams". A lifecycle-only plugin
1103            // declares no options of its own, but a handler may still want to
1104            // set a CORE option before its `activate` reads one.
1105            self.announce_pre_load(&manifest.id).await;
1106            // Lifecycle-only (base `plugin` world): instantiate + activate.
1107            let mut instance = self
1108                .host
1109                .instantiate_plugin(&component, manifest, tier, PluginBudget::default())
1110                .await?;
1111            instance.activate().await?;
1112            seam_ids.push(instance.id());
1113            record.lifecycle = Some(instance);
1114        } else {
1115            // OM.0: drain in DEPENDENCY order, not manifest order. A
1116            // `mode-keymap-binding` resolves its command name against the
1117            // `CommandRegistry` at registration, so a mode binding a chord to
1118            // the plugin's own grammar action needs `grammar` drained first —
1119            // and `provides` is guest-controlled input, so trusting its order
1120            // made a load-bearing invariant depend on a comment in someone
1121            // else's TOML. `drain_rank` decides; the sort is stable, so ties
1122            // keep the author's ordering.
1123            let mut seams = manifest.provides.clone();
1124            seams.sort_by_key(|s| s.drain_rank());
1125            // OA.14d: `pre-plugin-loaded` fires on the rank-0 boundary — after
1126            // every seam that DECLARES an option has drained, before the first
1127            // seam that READS one. Both halves matter and neither is arbitrary:
1128            // fire earlier and a handler's `set-option` names an option that
1129            // does not exist yet (the host rejects it as unknown and logs);
1130            // fire later and org's `register-theme-elements` has already
1131            // derived its per-keyword elements from the compiled default,
1132            // which IS the reported bug.
1133            let mut announced = false;
1134            for seam in &seams {
1135                if !announced && seam.drain_rank() > 0 {
1136                    self.announce_pre_load(&manifest.id).await;
1137                    announced = true;
1138                }
1139                match seam {
1140                    PluginSeam::PickerSource => {
1141                        let id = self
1142                            .drain_picker(&component, manifest, tier, &mut record)
1143                            .await?;
1144                        seam_ids.push(id);
1145                    }
1146                    PluginSeam::Config => {
1147                        let id = self
1148                            .drain_config(&component, manifest, tier, &mut record)
1149                            .await?;
1150                        seam_ids.push(id);
1151                    }
1152                    PluginSeam::Events => {
1153                        let id = self
1154                            .drain_events(&component, manifest, tier, &mut record)
1155                            .await?;
1156                        seam_ids.push(id);
1157                    }
1158                    PluginSeam::Grammar => {
1159                        let id = self.drain_grammar(&component, manifest, tier)?;
1160                        seam_ids.push(id);
1161                    }
1162                    PluginSeam::Modes => {
1163                        let id = self
1164                            .drain_mode(&component, manifest, tier, &mut record)
1165                            .await?;
1166                        seam_ids.push(id);
1167                    }
1168                    PluginSeam::CompletionSource => {
1169                        let id = self
1170                            .drain_completion(&component, manifest, tier, &mut record)
1171                            .await?;
1172                        seam_ids.push(id);
1173                    }
1174                    PluginSeam::Keymap => {
1175                        let id = self
1176                            .drain_keymap(&component, manifest, tier, &mut record)
1177                            .await?;
1178                        seam_ids.push(id);
1179                    }
1180                    PluginSeam::Media => {
1181                        let id = self
1182                            .drain_media(&component, manifest, tier, &mut record)
1183                            .await?;
1184                        seam_ids.push(id);
1185                    }
1186                    PluginSeam::Decorations => {
1187                        let id = self
1188                            .drain_decorations(&component, manifest, tier, &mut record)
1189                            .await?;
1190                        seam_ids.push(id);
1191                    }
1192                    PluginSeam::Context => {
1193                        let id = self
1194                            .drain_context(&component, manifest, tier, &mut record)
1195                            .await?;
1196                        seam_ids.push(id);
1197                    }
1198                    PluginSeam::Theme => {
1199                        let id = self
1200                            .drain_theme(&component, manifest, tier, &mut record)
1201                            .await?;
1202                        seam_ids.push(id);
1203                    }
1204                    PluginSeam::Signs => {
1205                        let id = self
1206                            .drain_signs(&component, manifest, tier, &mut record)
1207                            .await?;
1208                        seam_ids.push(id);
1209                    }
1210                    // PO.5: `logging` is a host import the guest CONSUMES (Layer 2),
1211                    // not a contribution it provides — it never appears in a
1212                    // well-formed `provides`, and the import is wired into the
1213                    // linker for every async world regardless. A malformed manifest
1214                    // that lists it drains nothing (no-op), never an error.
1215                    // PM.7b: the `require` seam. Drained during the ordinary
1216                    // load, so the component compiled at the top of this
1217                    // function is reused — the alternative (spawning the
1218                    // guest a second time to read its specs) would compile
1219                    // init.rs twice on every boot to fetch a list.
1220                    PluginSeam::PluginManager => {
1221                        let id = self
1222                            .drain_require(&component, manifest, tier, &mut record)
1223                            .await?;
1224                        seam_ids.push(id);
1225                    }
1226                    // CM.6b: live. The registry holds a FACTORY rather than a
1227                    // parser because the compilation `ParserRegistry` is built
1228                    // per pipe reader — stdout and stderr each get one — and a
1229                    // `WasmErrorParser` owns a `Store`, so it cannot be shared
1230                    // between them. Each reader mints its own, which is also
1231                    // semantically right: the two streams carry independent
1232                    // pending state.
1233                    PluginSeam::ErrorParser => {
1234                        let id = self.drain_error_parser(&component, manifest, tier)?;
1235                        seam_ids.push(id);
1236                    }
1237                    // OM.A1: an agenda-row producer. Live, like `media` — the
1238                    // guest stays instantiated and is called once per file of
1239                    // every scan, so its per-scan state (`begin`) has
1240                    // somewhere to live.
1241                    PluginSeam::ScannedExcerptSource => {
1242                        let id = self
1243                            .drain_agenda(&component, manifest, tier, &mut record)
1244                            .await?;
1245                        seam_ids.push(id);
1246                    }
1247                    // TR.2b: a keyed menu. Live, like `dashboard` — a menu's
1248                    // rows depend on where it was opened from, so the guest
1249                    // stays instantiated and `build` is called per open.
1250                    PluginSeam::TransientSource => {
1251                        let id = self
1252                            .drain_transient(&component, manifest, tier, &mut record)
1253                            .await?;
1254                        seam_ids.push(id);
1255                    }
1256                    // CR.3: the plugin's `:help` pages. Data, not a live
1257                    // guest — the bodies cross once here and the store is
1258                    // dropped, so reading `:help` never touches wasm.
1259                    PluginSeam::Help => {
1260                        let id = self.drain_help(&component, manifest, tier).await?;
1261                        seam_ids.push(id);
1262                    }
1263                    // LG.3c: the plugin's languages. Data like `help` — the
1264                    // grammar bytes and query sources cross once here, the
1265                    // host compiles the grammar, and the guest is dropped.
1266                    // Parsing never touches wasm-the-plugin again.
1267                    PluginSeam::Language => {
1268                        let id = self.drain_language(&component, manifest, tier).await?;
1269                        seam_ids.push(id);
1270                    }
1271                    // CR.4: the plugin's launch-page sections. Unlike `help`,
1272                    // each keeps a live guest — a section is a function of a
1273                    // `DashboardCtx`, so it is called per compose.
1274                    PluginSeam::Dashboard => {
1275                        let id = self.drain_dashboard(&component, manifest, tier)?;
1276                        seam_ids.push(id);
1277                    }
1278                    // MV.1: a plugin-owned multibuffer view.
1279                    PluginSeam::MultibufferViewSource => {
1280                        let id = self
1281                            .drain_multibuffer_views(&component, manifest, tier, &mut record)
1282                            .await?;
1283                        seam_ids.push(id);
1284                    }
1285                    PluginSeam::Logging => {} // Exhaustive: every contribution `PluginSeam` variant is drained
1286                                              // (PL8.E closed the last, decorations). A new seam variant
1287                                              // must add its drain here — the compiler enforces it rather
1288                                              // than a silent skip.
1289                }
1290            }
1291            // A plugin whose every seam is rank 0 never crossed the boundary.
1292            // Fire anyway, so "exactly once per load" is a property a handler
1293            // and a test can rely on rather than one that holds for most
1294            // manifests.
1295            if !announced {
1296                self.announce_pre_load(&manifest.id).await;
1297            }
1298        }
1299
1300        // The FIRST id is the plugin's user-facing identity (`:list-plugins`,
1301        // `SourceLayer::Plugin` rendering); all of them are what teardown
1302        // reverses.
1303        let Some(&id) = seam_ids.first() else {
1304            return Err(PluginLoaderError::NothingLoaded);
1305        };
1306        record.id = id;
1307        record.teardown.plugin_id = id;
1308        record.teardown.seam_ids = seam_ids;
1309
1310        // Provenance: `SourceLayer::Plugin(id)` renders as the name, and
1311        // `:list-plugins` shows it. Doc falls back to the manifest field.
1312        if let Some(sink) = &self.env.meta_sink {
1313            sink.register_plugin(
1314                id.0,
1315                manifest.id.clone(),
1316                manifest.doc.clone().unwrap_or_default(),
1317            );
1318            // Every seam's contributions are stamped with THAT seam's id, so
1319            // the name has to resolve from all of them — or a keymap-seam
1320            // binding reads `<plugin:29>` beside the same plugin's named
1321            // grammar.
1322            let seam_ids: Vec<u32> = record.teardown.seam_ids.iter().map(|s| s.0).collect();
1323            sink.register_seam_ids(id.0, &seam_ids);
1324        }
1325
1326        self.loaded
1327            .lock()
1328            .expect("plugin-loader loaded-set mutex poisoned")
1329            .push(record);
1330        // CI.1: announce the load AFTER the full drain (every seam registered) so
1331        // a subscriber's handler observes a fully-loaded plugin — an `init.rs`
1332        // runs its deferred `on-plugin-loaded` config here. Fires for `init.rs`
1333        // itself too (harmless: handlers match other plugins by name).
1334        if let Some(bus) = &self.env.bus {
1335            bus.publish(lattice_protocol::Event::PluginLoaded {
1336                name: manifest.id.clone(),
1337                id: id.0,
1338            });
1339        }
1340        // PM.3: a plugin declaring a `default_mode` gets a `<id>.enabled` bool
1341        // gate (default true). Register it, read its current value, and enable /
1342        // disable the declared mode accordingly — the batteries-included path
1343        // (auto-pair on out of the box), user-overridable via `:set
1344        // <id>.enabled=false`. Subsequent changes are handled by
1345        // `subscribe_mode_gates`.
1346        self.apply_default_mode_gate(&manifest.id, &manifest.default_modes);
1347        // LA.1: the mode/language CATALOG changed. Separate from `PluginLoaded`
1348        // above because the subscriber is different and much more expensive —
1349        // it re-resolves the major mode of every open buffer
1350        // (`mode-architecture.md` §7.4). Published last, after the default-mode
1351        // gate, so a subscriber resolving against the catalog sees it settled:
1352        // registered by the drain AND enabled/disabled by the gate.
1353        if manifest.provides.iter().any(|s| {
1354            matches!(
1355                s,
1356                lattice_plugin_host::PluginSeam::Language | lattice_plugin_host::PluginSeam::Modes
1357            )
1358        }) && let Some(bus) = &self.env.bus
1359        {
1360            bus.publish_typed(crate::events::LanguagesRegistered { plugin: id });
1361        }
1362        // One-shot, user-actionable event (the "LSP server attached" class).
1363        tracing::info!(plugin = %manifest.id, id = id.0, "plugin loaded");
1364        Ok(id)
1365    }
1366
1367    /// OA.14d: publish `PrePluginLoaded` for `name` and **wait** for every
1368    /// guest handler to return before the load continues.
1369    ///
1370    /// The wait is the whole feature. An ordinary publish hands the event to
1371    /// each plugin's actor channel and returns; the actor runs on the same
1372    /// multi-thread runtime this load is running on, so without the barrier the
1373    /// handler's `set-option` and the export that reads that option race — and
1374    /// the race is invisible when it is lost, because the export simply sees
1375    /// the compiled default and produces a plausible, wrong result.
1376    ///
1377    /// Bounded, because a barrier that a guest can hold forever is a boot that
1378    /// a guest can hang. On expiry the load proceeds and says so: the user gets
1379    /// an editor with one plugin misconfigured rather than no editor at all.
1380    async fn announce_pre_load(&self, name: &str) {
1381        let Some(bus) = &self.env.bus else { return };
1382        let waits = bus.publish_awaited(Event::PrePluginLoaded {
1383            name: name.to_string(),
1384        });
1385        if waits.is_empty() {
1386            return;
1387        }
1388        let handlers = waits.len();
1389        // `Err` on a receiver means the handler will never run (actor gone,
1390        // plugin quarantined) — which is a completed wait, not a failure. So
1391        // every arm of `join_all` is simply ignored; what is awaited is that
1392        // each one is *over*.
1393        let all = futures::future::join_all(waits);
1394        match tokio::time::timeout(PRE_PLUGIN_LOADED_TIMEOUT, all).await {
1395            Ok(_) => tracing::debug!(
1396                plugin = %name,
1397                handlers,
1398                "pre-plugin-loaded handlers completed"
1399            ),
1400            Err(_) => tracing::warn!(
1401                plugin = %name,
1402                handlers,
1403                timeout_secs = PRE_PLUGIN_LOADED_TIMEOUT.as_secs(),
1404                "pre-plugin-loaded handlers did not finish in time; loading anyway \
1405                 — options this plugin reads at load may fall back to their defaults"
1406            ),
1407        }
1408    }
1409
1410    /// The name of a plugin's enable-gate option — `<id>.enabled` (PM.3).
1411    fn enabled_option_name(plugin_id: &str) -> String {
1412        format!("{plugin_id}.enabled")
1413    }
1414
1415    /// PM.3: register (if new) the `<id>.enabled` gate for a plugin declaring a
1416    /// `default_mode`, then request the mode's enablement to match the option's
1417    /// current value. A no-op when the plugin declares no default mode, or when no
1418    /// config registry / bus is wired.
1419    fn apply_default_mode_gate(&self, plugin_id: &str, default_modes: &[String]) {
1420        if default_modes.is_empty() {
1421            return;
1422        }
1423        let (Some(registry), Some(bus)) =
1424            (self.env.config_registry.as_ref(), self.env.bus.as_ref())
1425        else {
1426            return;
1427        };
1428        let option = Self::enabled_option_name(plugin_id);
1429        // Idempotent: a re-load (or a plugin that declared the option itself)
1430        // leaves the existing value untouched; only the first load registers it.
1431        lattice_plugin_host::config_host::register_plugin_option(
1432            registry,
1433            &option,
1434            lattice_plugin_host::config_host::PluginOptionKind::Boolean,
1435            "true",
1436            "Enable this plugin's default mode.",
1437        );
1438        let enabled = registry
1439            .lookup(&option)
1440            .map(|opt| opt.get_formatted() == "true")
1441            .unwrap_or(true);
1442        // One gate, N modes (OC.1a). `<id>.enabled` is the PLUGIN's switch, so
1443        // a plugin with two on-by-default modes gets one option rather than
1444        // one per mode — the user is turning org on or off, not curating its
1445        // internals.
1446        for mode in default_modes {
1447            bus.publish(lattice_protocol::Event::ModeEnablementRequested {
1448                mode: mode.clone(),
1449                enabled,
1450            });
1451        }
1452    }
1453
1454    /// PM.7/PM.8 follow-up: honour a `require`'s `enable-mode` sugar.
1455    ///
1456    /// Publishes the same `ModeEnablementRequested` the manifest
1457    /// `default_mode` gate publishes — one mechanism, two ways of asking for
1458    /// it (a plugin declaring its own default, or a user's `init.rs` asking
1459    /// for it at the call site).
1460    ///
1461    /// The host never learns the mode-id statically: it arrives in the spec
1462    /// and is forwarded as an opaque string, so the mode stays the plugin's
1463    /// own surface (`feedback_mode_owns_its_surface`).
1464    ///
1465    /// A missing bus is a silent skip — the same degradation every other
1466    /// event publisher here uses when the editor is not fully wired (tests,
1467    /// headless harnesses).
1468    pub fn request_mode_enablement(&self, mode: &str) {
1469        let Some(bus) = self.env.bus.as_ref() else {
1470            return;
1471        };
1472        bus.publish(lattice_protocol::Event::ModeEnablementRequested {
1473            mode: mode.to_string(),
1474            enabled: true,
1475        });
1476    }
1477
1478    /// PL8.H.1: a read-only snapshot of every loaded plugin — identity, trust
1479    /// tier, capabilities granted/denied, and health — for the `:plugins`
1480    /// manager view (PL8.H.2/.3). Cloned out under the loaded-set lock, so the
1481    /// view renders a stable frame while loads/unloads proceed.
1482    pub fn plugin_status(&self) -> Vec<PluginStatus> {
1483        // Snapshot the in-flight set once, outside the loaded-set lock: two
1484        // locks held at once is how a deadlock gets written, and the build
1485        // task takes `building` while the view takes `loaded`.
1486        let activity = self.building.lock().map(|m| m.clone()).unwrap_or_default();
1487        let mut rows: Vec<PluginStatus> = self
1488            .loaded
1489            .lock()
1490            .expect("plugin-loader loaded-set mutex poisoned")
1491            .iter()
1492            .map(|r| PluginStatus {
1493                id: r.id.0,
1494                name: r.name.clone(),
1495                tier: r.tier,
1496                granted: r.granted.clone(),
1497                denied: r.denied.clone(),
1498                health: r.health.clone(),
1499                source: r.source.clone(),
1500                build: build_state_of(r, activity.get(&r.name)),
1501            })
1502            .collect();
1503        // Stable, name-sorted order (not raw load order). The `:plugins` view keys
1504        // its in-view chords on `cursor.line → this Vec's index`, and a `:plugin`
1505        // reload internally unloads + re-appends (moving the record to the end of
1506        // `loaded`). Sorting by name keeps a reloaded plugin's row in place, so the
1507        // cursor still targets it, and makes the list order predictable for the
1508        // user rather than discovery-order. Names are unique per loaded set.
1509        rows.sort_by(|a, b| a.name.cmp(&b.name));
1510        rows
1511    }
1512
1513    /// WT.4: the plugins that tried to load this session and could not.
1514    ///
1515    /// Name-sorted like [`plugin_status`](Self::plugin_status), for the same
1516    /// reason: the view is read down a column, and discovery order is not
1517    /// something a user can predict or reproduce.
1518    pub fn failed_loads(&self) -> Vec<FailedLoad> {
1519        let mut rows = self.failed.lock().map(|f| f.clone()).unwrap_or_default();
1520        rows.sort_by(|a, b| a.name.cmp(&b.name));
1521        rows
1522    }
1523
1524    /// Record (or replace) `name`'s load failure.
1525    ///
1526    /// Replaces rather than appends: a `:plugin-reload` that fails again should
1527    /// leave one row saying what is wrong now, not a growing pile of attempts.
1528    /// The view is a description of the current state, not a history.
1529    ///
1530    /// Pass [`error_chain`]'s output, not `err.to_string()` — see its doc.
1531    fn record_failure(&self, name: &str, dir: &std::path::Path, error: &str) {
1532        let Ok(mut failed) = self.failed.lock() else {
1533            // A poisoned mutex here would mean losing a diagnostic, and losing a
1534            // diagnostic is not worth taking the editor down over — this whole
1535            // mechanism exists because a missing message cost a debugging
1536            // session, so it must not itself become a crash.
1537            tracing::debug!(plugin = name, "failed-load set poisoned; not recording");
1538            return;
1539        };
1540        failed.retain(|f| f.name != name);
1541        failed.push(FailedLoad {
1542            name: name.to_string(),
1543            dir: dir.to_path_buf(),
1544            error: error.to_string(),
1545        });
1546    }
1547
1548    /// Drop `name`'s failure record — it loaded.
1549    ///
1550    /// Called on every successful load rather than only on a reload, because the
1551    /// paths into a load are several (boot scan, `require`, `:plugin-load`,
1552    /// `:plugin-reload`) and a stale "failed" row surviving a load that worked
1553    /// is a worse lie than no row at all.
1554    fn clear_failure(&self, name: &str) {
1555        if let Ok(mut failed) = self.failed.lock() {
1556            failed.retain(|f| f.name != name);
1557        }
1558    }
1559
1560    /// PL8.H.1: mark the plugin `plugin` quarantined (its instance trapped) — the
1561    /// body of the `Event::PluginCrashed` subscription ([`subscribe_health`]),
1562    /// exposed directly so a test can drive the health flip without a live bus.
1563    /// A crash id matching no loaded plugin is ignored (it may have been unloaded
1564    /// between the trap and the drain) — never a panic.
1565    ///
1566    /// [`subscribe_health`]: Self::subscribe_health
1567    pub fn mark_quarantined(&self, plugin: u32, func: String, kind: String) {
1568        let mut loaded = self
1569            .loaded
1570            .lock()
1571            .expect("plugin-loader loaded-set mutex poisoned");
1572        if let Some(record) = loaded.iter_mut().find(|r| r.id.0 == plugin) {
1573            record.health = PluginHealth::Quarantined { func, kind };
1574        }
1575    }
1576
1577    /// PL8.H.1: subscribe to `Event::PluginCrashed` so a trapped plugin's health
1578    /// flips to `Quarantined` in the manager view. Filtered by kind (indexed
1579    /// dispatch); events drain on the shared runtime via a `Channel` sink, OFF
1580    /// the keystroke path (the bus calls the sink lock-dropped). Holds a
1581    /// `Weak<Self>` so the drain task never keeps the loader alive — the loop
1582    /// ends when the loader drops. Called once by [`install`]; a no-op if no
1583    /// bus/runtime was wired (the minimal test constructor).
1584    pub fn subscribe_health(self: &Arc<Self>) {
1585        let (Some(bus), Some(runtime)) = (self.env.bus.as_ref(), self.env.runtime.as_ref()) else {
1586            return;
1587        };
1588        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Event>();
1589        bus.subscribe(
1590            EventFilter::kind(EventKind::PluginCrashed),
1591            SubscriptionTarget::Channel(tx),
1592        );
1593        let weak = Arc::downgrade(self);
1594        runtime.spawn(async move {
1595            while let Some(event) = rx.recv().await {
1596                if let Event::PluginCrashed { plugin, func, kind } = event {
1597                    let Some(loader) = weak.upgrade() else { break };
1598                    loader.mark_quarantined(plugin, func, kind);
1599                }
1600            }
1601        });
1602    }
1603
1604    /// PM.3: react to `<id>.enabled` changes — the config gate for a plugin's
1605    /// default mode. On a `:set <id>.enabled=<bool>` (an `OptionChanged`), map the
1606    /// option back to the loaded plugin's `default_mode` and request the mode's
1607    /// enablement to match, so the toggle activates / deactivates it live. Mirrors
1608    /// [`Self::subscribe_health`]; a no-op when no bus/runtime was wired.
1609    pub fn subscribe_mode_gates(self: &Arc<Self>) {
1610        let (Some(bus), Some(runtime)) = (self.env.bus.as_ref(), self.env.runtime.as_ref()) else {
1611            return;
1612        };
1613        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Event>();
1614        bus.subscribe(
1615            EventFilter::kind(EventKind::OptionChanged),
1616            SubscriptionTarget::Channel(tx),
1617        );
1618        let bus = bus.clone();
1619        let weak = Arc::downgrade(self);
1620        runtime.spawn(async move {
1621            while let Some(event) = rx.recv().await {
1622                let Event::OptionChanged { name, new, .. } = event else {
1623                    continue;
1624                };
1625                let Some(plugin_id) = name.strip_suffix(".enabled") else {
1626                    continue;
1627                };
1628                let Some(loader) = weak.upgrade() else { break };
1629                // Map `<id>.enabled` → the loaded plugin's default modes.
1630                let modes = {
1631                    let loaded = loader
1632                        .loaded
1633                        .lock()
1634                        .expect("plugin-loader loaded-set mutex poisoned");
1635                    loaded
1636                        .iter()
1637                        .find(|r| r.name == plugin_id)
1638                        .map(|r| r.default_modes.clone())
1639                        .unwrap_or_default()
1640                };
1641                for mode in modes {
1642                    bus.publish(lattice_protocol::Event::ModeEnablementRequested {
1643                        mode,
1644                        enabled: new == "true",
1645                    });
1646                }
1647            }
1648        });
1649    }
1650
1651    /// Self-register the `:plugin-load` / `:plugin-unload` / `:plugin-reload`
1652    /// ex-commands into the runtime-mutable command registry (option A — the
1653    /// loader owns its full command surface; zero host code). Plain command
1654    /// names resolve directly via `id_by_name` (no `expand_alias` host entry),
1655    /// exactly like plugin-contributed ex-commands. Called once by [`install`]
1656    /// after the loader is constructed; a no-op (logged) if no command registry
1657    /// was wired.
1658    ///
1659    /// The apply closures capture a `Weak<Self>`. The command registry holds
1660    /// them and the loader holds the registry, so a strong capture was a cycle
1661    /// that kept the loader — and its plugin host's engine and threads — alive
1662    /// after the editor that booted it had been dropped.
1663    pub fn register_ex_commands(self: &Arc<Self>) {
1664        let Some(registry) = self.env.command_registry.clone() else {
1665            tracing::warn!(
1666                "no command registry wired; :plugin-load / :plugin-unload / :plugin-reload unavailable"
1667            );
1668            return;
1669        };
1670        // load → clone → register → store (single-threaded at boot; no retry).
1671        let mut next = (**registry.load()).clone();
1672        ex_commands::register_all(&mut next, self);
1673        registry.store(Arc::new(next));
1674    }
1675
1676    /// Spawn an async [`load_path`](Self::load_path) on the loader's own runtime
1677    /// — the `:plugin-load` apply path (a sync ex-command closure kicking off
1678    /// async work). Completion / failure surfaces via `tracing` (→ `*messages*`).
1679    pub(crate) fn spawn_load_path(self: &Arc<Self>, dir: std::path::PathBuf) {
1680        let Some(runtime) = self.env.runtime.clone() else {
1681            tracing::warn!("no runtime wired; :plugin-load cannot run");
1682            return;
1683        };
1684        let this = Arc::clone(self);
1685        runtime.spawn(async move {
1686            match this.load_path(&dir, TrustTier::UserInstalled).await {
1687                Ok(id) => {
1688                    tracing::info!(id = id.0, dir = %dir.display(), "plugin loaded (:plugin-load)")
1689                }
1690                Err(err) => {
1691                    tracing::warn!(dir = %dir.display(), error = %err, ":plugin-load failed")
1692                }
1693            }
1694        });
1695    }
1696
1697    /// Spawn an async [`reload`](Self::reload) on the loader's own runtime — the
1698    /// `:plugin-reload` apply path. Reports via `tracing` (→ `*messages*`).
1699    pub(crate) fn spawn_reload(self: &Arc<Self>, target: String) {
1700        let Some(runtime) = self.env.runtime.clone() else {
1701            tracing::warn!("no runtime wired; :plugin-reload cannot run");
1702            return;
1703        };
1704        let this = Arc::clone(self);
1705        runtime.spawn(async move {
1706            match this.reload(&target, TrustTier::UserInstalled).await {
1707                Ok(id) => {
1708                    tracing::info!(id = id.0, plugin = %target, "plugin reloaded (:plugin-reload)")
1709                }
1710                Err(err) => {
1711                    tracing::warn!(plugin = %target, error = %err, ":plugin-reload failed")
1712                }
1713            }
1714        });
1715    }
1716
1717    /// The loaded plugins, in the order `:plugins` lists them.
1718    ///
1719    /// Snapshotted before a bulk run starts rather than iterated live: every
1720    /// leg of a rebuild or update unloads and re-appends its plugin, so
1721    /// walking the live set while mutating it would visit some plugins twice
1722    /// and miss others.
1723    fn bulk_targets(&self) -> Vec<(String, SourceRecord)> {
1724        self.plugin_status()
1725            .into_iter()
1726            .map(|row| (row.name, row.source))
1727            .collect()
1728    }
1729
1730    /// Run `op` over every loaded plugin, reporting each leg as it starts.
1731    ///
1732    /// **Sequential, deliberately.** The obvious reading is that N plugins
1733    /// should run concurrently, and it is wrong three times over: `cargo`
1734    /// already saturates the machine, so N of them contend rather than
1735    /// parallelise (and can exhaust the disk — a full build tree is tens of
1736    /// gigabytes); every leg finishes by reloading, which mutates the shared
1737    /// registries by copy-on-write RCU, so overlapping legs race to publish;
1738    /// and a user watching the view wants to read which plugin is building
1739    /// now, not six rows all claiming to be.
1740    ///
1741    /// A leg's failure never stops the next one — the same rule `install_all`
1742    /// follows at boot, for the same reason: one broken plugin should cost you
1743    /// that plugin, not the rest.
1744    ///
1745    /// `on_leg(done, total, name)` fires BEFORE each leg runs, which is what
1746    /// lets the `:plugins` view say which plugin it is on rather than only
1747    /// what it finished. A caller with nothing to show passes a no-op.
1748    pub async fn run_bulk(
1749        &self,
1750        op: BulkOp,
1751        on_leg: &(dyn Fn(usize, usize, &str) + Send + Sync),
1752    ) -> BulkReport {
1753        let targets = self.bulk_targets();
1754        let total = targets.len();
1755        let mut report = BulkReport::default();
1756        for (done, (name, source)) in targets.into_iter().enumerate() {
1757            on_leg(done, total, &name);
1758            let leg = self.run_leg(op, &name, &source).await;
1759            report.legs.push((name, leg));
1760        }
1761        report
1762    }
1763
1764    /// One plugin's leg of a bulk run.
1765    ///
1766    /// The skip arms are what keeps `op` honest about scope: a bundled plugin
1767    /// has nothing to build from and a pinned one has nothing to update to, and
1768    /// neither is a failure the user should go looking into.
1769    async fn run_leg(&self, op: BulkOp, name: &str, source: &SourceRecord) -> BulkLeg {
1770        match op {
1771            BulkOp::Reload => match self.reload(name, TrustTier::UserInstalled).await {
1772                Ok(_) => BulkLeg::Done,
1773                // `error_chain`, not `to_string`: a reload failure is almost
1774                // always reported by an inner cause (a missing artifact, a
1775                // trap at instantiation), and the outer layer alone says
1776                // nothing actionable.
1777                Err(why) => BulkLeg::Failed(error_chain(&why)),
1778            },
1779            BulkOp::Rebuild => {
1780                // `init` is buildable in place even though its `SourceRecord` is
1781                // `Unknown` (see `rebuild`), so it is the one exception to the
1782                // buildable-source skip — `rebuild` routes it correctly.
1783                if name != INIT_PLUGIN_ID && !source.is_buildable() {
1784                    // Bundled ships prebuilt; Unknown has nowhere to build from.
1785                    return BulkLeg::Skipped(format!("no buildable source ({})", source.label()));
1786                }
1787                match self.rebuild(name).await {
1788                    Ok(()) => BulkLeg::Done,
1789                    Err(why) => BulkLeg::Failed(why),
1790                }
1791            }
1792            BulkOp::Update => {
1793                if let Some(why) = update_refusal(name, source.as_plugin_source().as_ref()) {
1794                    return BulkLeg::Skipped(why);
1795                }
1796                if !source.is_buildable() && !matches!(source, SourceRecord::Prebuilt { .. }) {
1797                    return BulkLeg::Skipped(format!("no updatable source ({})", source.label()));
1798                }
1799                match self.update(name).await {
1800                    Ok(()) => BulkLeg::Done,
1801                    Err(why) => BulkLeg::Failed(why),
1802                }
1803            }
1804        }
1805    }
1806
1807    /// Rebuild every loaded plugin from the source it already has, then reload
1808    /// each. See [`Self::run_bulk`].
1809    pub async fn rebuild_all(&self) -> BulkReport {
1810        self.run_bulk(BulkOp::Rebuild, &|_, _, _| {}).await
1811    }
1812
1813    /// Update every loaded plugin: bring each source up to date, rebuild,
1814    /// reload. Pinned plugins are skipped. See [`Self::run_bulk`].
1815    pub async fn update_all(&self) -> BulkReport {
1816        self.run_bulk(BulkOp::Update, &|_, _, _| {}).await
1817    }
1818
1819    /// Re-instantiate every loaded plugin from the artifact already on disk —
1820    /// no build, no network. See [`Self::run_bulk`].
1821    pub async fn reload_all(&self) -> BulkReport {
1822        self.run_bulk(BulkOp::Reload, &|_, _, _| {}).await
1823    }
1824
1825    /// Staged plugin directories that nothing this session claims — what
1826    /// `clean` would remove.
1827    ///
1828    /// A directory is removable only when **all** of these hold, and each
1829    /// clause is here because dropping it deletes something a user wanted:
1830    ///
1831    /// 1. **Not loaded.** The obvious one.
1832    /// 2. **Not a load FAILURE this session.** A plugin that tried and broke
1833    ///    is still a plugin the user asked for; cleaning it would turn "my
1834    ///    plugin is failing" into "my plugin is gone" and hide the error the
1835    ///    view was showing.
1836    /// 3. **Not `init`.** That is the user's own configuration, not a plugin,
1837    ///    and it is never in the loaded set under that name.
1838    /// 4. **Carries a `.source` marker.** Provenance is what makes removal
1839    ///    recoverable — with it the directory can be re-resolved and rebuilt,
1840    ///    without it the bytes are the only copy. A hand-staged directory has
1841    ///    no marker, and is exactly the case where deleting is unrecoverable.
1842    ///
1843    /// Returns `(name, path)` pairs in name order. Reading only — the caller
1844    /// decides whether to act, which is what lets `:plugin-clean` show the
1845    /// list and `:plugin-clean!` act on it.
1846    pub fn removable_plugin_dirs(&self) -> Vec<(String, std::path::PathBuf)> {
1847        let Some(root) = default_plugins_dir() else {
1848            return Vec::new();
1849        };
1850        removable_under(&root, &self.clean_keep_set())
1851    }
1852
1853    /// Every name `clean` must leave alone — clauses 1-3 of
1854    /// [`Self::removable_plugin_dirs`].
1855    fn clean_keep_set(&self) -> std::collections::HashSet<String> {
1856        self.plugin_status()
1857            .into_iter()
1858            .map(|r| r.name)
1859            .chain(self.failed_loads().into_iter().map(|f| f.name))
1860            .chain(std::iter::once("init".to_string()))
1861            .collect()
1862    }
1863
1864    /// Remove the named staged plugin directories.
1865    ///
1866    /// Takes names rather than re-deriving the list, so the thing the user
1867    /// confirmed is the thing that gets deleted — `Effect::Confirm` carries
1868    /// the payload for this reason (effect.rs, IX.1). Re-deriving after the
1869    /// prompt would let a reload land in between and change the answer.
1870    ///
1871    /// Each name is re-checked against [`Self::removable_plugin_dirs`] before
1872    /// its directory goes: a confirmation the user left sitting while a plugin
1873    /// loaded must not delete the plugin that just arrived.
1874    pub fn clean(&self, names: &[String]) -> BulkReport {
1875        clean_listed(&self.removable_plugin_dirs(), names)
1876    }
1877
1878    /// Run a bulk verb on the loader's runtime, reporting through `*messages*`.
1879    ///
1880    /// The ex-command `apply` that calls this must return immediately — a bulk
1881    /// rebuild is minutes of `cargo`, and the dispatch path is the keystroke
1882    /// path. So the whole run is spawned and its outcome surfaces the way
1883    /// every other async plugin outcome does: one `info!` with the counts, one
1884    /// `warn!` per failure naming the plugin.
1885    pub(crate) fn spawn_bulk(self: &Arc<Self>, op: BulkOp) {
1886        let Some(runtime) = self.env.runtime.clone() else {
1887            tracing::warn!(?op, "no runtime wired; bulk plugin operation cannot run");
1888            return;
1889        };
1890        let this = Arc::clone(self);
1891        runtime.spawn(async move {
1892            let report = this.run_bulk(op, &|_, _, _| {}).await;
1893            for (name, why) in report.failures() {
1894                tracing::warn!(plugin = %name, error = %why, ?op, "bulk plugin operation failed");
1895            }
1896            tracing::info!(summary = %report.summary(op.past()), ?op, "bulk plugin operation done");
1897        });
1898    }
1899
1900    /// `:plugin-update <name>` — [`Self::update`] on the loader's runtime.
1901    ///
1902    /// Mirrors [`Self::spawn_reload`]: the ex-command's `apply` must not block
1903    /// the dispatch path, so the work is spawned and its outcome reported
1904    /// through `*messages*` — the one-shot user-actionable class.
1905    pub(crate) fn spawn_update(self: &Arc<Self>, target: String) {
1906        let Some(runtime) = self.env.runtime.clone() else {
1907            tracing::warn!("no runtime wired; :plugin-update cannot run");
1908            return;
1909        };
1910        let this = Arc::clone(self);
1911        runtime.spawn(async move {
1912            match this.update(&target).await {
1913                Ok(()) => tracing::info!(plugin = %target, "plugin updated (:plugin-update)"),
1914                Err(err) => tracing::warn!(plugin = %target, error = %err, ":plugin-update failed"),
1915            }
1916        });
1917    }
1918
1919    /// Load a single plugin from an explicit directory — the `:plugin-load <path>`
1920    /// entry point (PL8.C). Unlike [`discover_and_load`](Self::discover_and_load)
1921    /// (a tree scan that silently skips non-plugin dirs), a direct request
1922    /// surfaces a bad path as a [`PluginLoaderError::Discovery`] the user sees.
1923    pub async fn load_path(
1924        &self,
1925        dir: &std::path::Path,
1926        tier: TrustTier,
1927    ) -> Result<PluginId, PluginLoaderError> {
1928        let plugin = discovery::discover_one(dir).map_err(PluginLoaderError::Discovery)?;
1929        // WT.4: a `discover_one` failure is deliberately NOT recorded above —
1930        // that is "this directory is not a plugin", which the caller asked about
1931        // and gets as an error. Past this point the directory *is* a plugin, so
1932        // a failure is a plugin that should be here and is not, and it belongs
1933        // in `:plugins` however the load was triggered.
1934        let outcome = self.load_discovered(&plugin, tier).await;
1935        match &outcome {
1936            Ok(_) => self.clear_failure(&plugin.manifest.id),
1937            Err(err) => self.record_failure(&plugin.manifest.id, dir, &error_chain(err)),
1938        }
1939        outcome
1940    }
1941
1942    /// PM.8b: how many builds are running right now.
1943    ///
1944    /// The `:plugins` headerline reads this, per the
1945    /// async-buffer-status-in-headerline rule — a build takes seconds to
1946    /// minutes and the user needs to see it is happening somewhere other than
1947    /// a status line that the next echo will overwrite.
1948    pub fn builds_in_flight(&self) -> usize {
1949        self.building_count
1950            .load(std::sync::atomic::Ordering::Relaxed)
1951    }
1952
1953    fn set_build_activity(&self, name: &str, activity: Option<BuildActivity>) {
1954        if let Ok(mut map) = self.building.lock() {
1955            match activity {
1956                Some(a) => {
1957                    map.insert(name.to_string(), a);
1958                }
1959                None => {
1960                    map.remove(name);
1961                }
1962            }
1963            // Recount under the same lock the map was mutated under, so the
1964            // counter can never disagree with it.
1965            let running = map
1966                .values()
1967                .filter(|a| matches!(a, BuildActivity::Running))
1968                .count();
1969            self.building_count
1970                .store(running, std::sync::atomic::Ordering::Relaxed);
1971        }
1972    }
1973
1974    /// PM.8b: force a fresh build of `name` from its recorded source, then
1975    /// reload it.
1976    ///
1977    /// "Force" is the difference from an ordinary load: the build service
1978    /// short-circuits on a matching stamp, which is exactly what you do NOT
1979    /// want when a user pressed rebuild. The stamp is removed first so the
1980    /// build is unconditional — the user asked, not the staleness check.
1981    ///
1982    /// Returns the error when the rebuild could not happen or did not
1983    /// succeed, having left the plugin as it was. A failed rebuild never
1984    /// unloads a working plugin: PM.5's `StaleKept` keeps the old artifact,
1985    /// and this reloads from it.
1986    ///
1987    /// Blocking work runs on `spawn_blocking`; only the reload is awaited.
1988    pub async fn rebuild(&self, name: &str) -> Result<(), String> {
1989        // `init` compiles in place in the config dir and carries no buildable
1990        // `SourceRecord`, so the generic `rebuild_with` pipeline (stage-into-
1991        // cache, refuses a non-buildable source) cannot rebuild it — the
1992        // plugins view's `b` on the `init` row would fail with "no buildable
1993        // source". Route it to the in-place build the boot path and
1994        // `:reload-config` share, so `b` on `init` does the thing the user
1995        // pressed it for and surfaces the compiler error on failure.
1996        if name == INIT_PLUGIN_ID {
1997            return self.rebuild_init().await;
1998        }
1999        self.rebuild_with(name, resolve::RefreshPolicy::UseCache, "rebuild")
2000            .await
2001    }
2002
2003    /// The plugins-view `b` on the `init` row (and `B` when it reaches `init`):
2004    /// build `init.rs` in place, reload, and reflect the build in the view's
2005    /// activity flag so the row reads `cached` on success or `build-failed` on
2006    /// failure. A build failure returns `Err` with the compiler diagnostics —
2007    /// even though the previous config keeps running — so the handler logs the
2008    /// detail to `*messages*` and the row flips to `build-failed`.
2009    async fn rebuild_init(&self) -> Result<(), String> {
2010        self.set_build_activity(INIT_PLUGIN_ID, Some(BuildActivity::Running));
2011        match self.reload_config().await {
2012            Ok(report) => match report.build {
2013                ConfigBuildStatus::BuildFailed(error) => {
2014                    self.set_build_activity(INIT_PLUGIN_ID, Some(BuildActivity::Failed));
2015                    Err(error)
2016                }
2017                _ => {
2018                    self.set_build_activity(INIT_PLUGIN_ID, None);
2019                    Ok(())
2020                }
2021            },
2022            Err(err) => {
2023                self.set_build_activity(INIT_PLUGIN_ID, Some(BuildActivity::Failed));
2024                Err(error_chain(&err))
2025            }
2026        }
2027    }
2028
2029    /// Bring `name` up to date with its upstream, then rebuild and reload it.
2030    ///
2031    /// The difference from [`Self::rebuild`] is one argument — the
2032    /// [`RefreshPolicy`](resolve::RefreshPolicy) the resolver runs under — but
2033    /// it is the whole verb: rebuild compiles the source you already have,
2034    /// update goes and gets a newer one first.
2035    ///
2036    /// What "newer" means is the source's to answer, and three of the four
2037    /// kinds answer it without any work here:
2038    ///
2039    /// | source | update |
2040    /// |---|---|
2041    /// | `Git { rev: None }` | fetch, move to the tracked head, rebuild |
2042    /// | `Git { rev: Some(_) }` | **declines** — a pin is the answer already |
2043    /// | `Local(_)` | rebuild; the directory IS the source, so it is always current |
2044    /// | `Prebuilt { url }` | re-download (the resolver fetches unconditionally) |
2045    ///
2046    /// The pinned arm declines rather than silently rebuilding, because those
2047    /// are different outcomes and a user who pinned a plugin and then pressed
2048    /// update is owed the reason nothing moved.
2049    pub async fn update(&self, name: &str) -> Result<(), String> {
2050        let source = {
2051            let loaded = self
2052                .loaded
2053                .lock()
2054                .map_err(|_| "plugin registry unavailable".to_string())?;
2055            loaded
2056                .iter()
2057                .find(|r| r.name == name)
2058                .ok_or_else(|| format!("`{name}` is not loaded"))?
2059                .source
2060                .clone()
2061        };
2062        if let Some(reason) = update_refusal(name, source.as_plugin_source().as_ref()) {
2063            return Err(reason);
2064        }
2065        self.rebuild_with(name, resolve::RefreshPolicy::Update, "update")
2066            .await
2067    }
2068
2069    /// The body [`Self::rebuild`] and [`Self::update`] share.
2070    ///
2071    /// `verb` appears only in the error text for a failed task join, so the
2072    /// message names the thing the user actually pressed.
2073    async fn rebuild_with(
2074        &self,
2075        name: &str,
2076        policy: resolve::RefreshPolicy,
2077        verb: &str,
2078    ) -> Result<(), String> {
2079        let (source, dir) = {
2080            let loaded = self
2081                .loaded
2082                .lock()
2083                .map_err(|_| "plugin registry unavailable".to_string())?;
2084            let record = loaded
2085                .iter()
2086                .find(|r| r.name == name)
2087                .ok_or_else(|| format!("`{name}` is not loaded"))?;
2088            (record.source.clone(), record.source_dir.clone())
2089        };
2090        if !source.is_buildable() {
2091            // Bundled ships prebuilt and Unknown has nowhere to build from.
2092            // Saying so beats running a build that cannot work.
2093            return Err(format!(
2094                "`{name}` has no buildable source ({})",
2095                source.label()
2096            ));
2097        }
2098        let Some(plugin_source) = source.as_plugin_source() else {
2099            return Err(format!("`{name}` has no recorded source"));
2100        };
2101        let Some(user_root) = default_plugins_dir() else {
2102            return Err("no config directory for the plugin cache".to_string());
2103        };
2104
2105        self.set_build_activity(name, Some(BuildActivity::Running));
2106        // Drop the stamp so the build is unconditional — see above.
2107        if let Some(dir) = &dir {
2108            let _ = std::fs::remove_file(dir.join(".build-stamp"));
2109        }
2110
2111        let spec = pipeline::RequiredSpec {
2112            name: name.to_string(),
2113            source: plugin_source,
2114            enable_mode: None,
2115            pinned: false,
2116        };
2117        let cache_root = default_source_cache_dir();
2118        let install = tokio::task::spawn_blocking(move || {
2119            pipeline::install_required(
2120                &resolve::SystemGit,
2121                &resolve::HttpFetcher,
2122                &build::CargoComponentBuilder,
2123                &spec,
2124                &cache_root,
2125                &user_root,
2126                policy,
2127            )
2128        })
2129        .await
2130        .map_err(|e| format!("{verb} task failed: {e}"))?;
2131
2132        match install {
2133            pipeline::Install::Ready {
2134                stale: Some(err), ..
2135            } => {
2136                self.set_build_activity(name, Some(BuildActivity::Failed));
2137                Err(err)
2138            }
2139            pipeline::Install::Skipped { error, .. } => {
2140                self.set_build_activity(name, Some(BuildActivity::Failed));
2141                Err(error)
2142            }
2143            pipeline::Install::Ready { .. } => {
2144                // Clear before the reload, not after: the reload republishes
2145                // status, and a row still reading `building…` after its build
2146                // finished is the kind of stuck indicator users stop trusting.
2147                self.set_build_activity(name, None);
2148                let tier = TrustTier::UserInstalled;
2149                self.reload(name, tier)
2150                    .await
2151                    .map(|_| ())
2152                    .map_err(|e| format!("rebuilt, but reload failed: {e}"))
2153            }
2154        }
2155    }
2156
2157    /// PM.7b: take the plugins declared via `require` so far, leaving the
2158    /// queue empty.
2159    ///
2160    /// Drained exactly once per boot by the install task. Draining rather than
2161    /// reading is what stops a second call from resolving, building and
2162    /// loading the same set twice.
2163    pub fn take_required(&self) -> Vec<pipeline::RequiredSpec> {
2164        self.required
2165            .lock()
2166            .map(|mut q| std::mem::take(&mut *q))
2167            .unwrap_or_default()
2168    }
2169
2170    /// Unload the plugin named `target` (its manifest id, or its numeric plugin
2171    /// id): abort its actor tasks and reverse every registry contribution via
2172    /// [`PluginTeardown`]. Returns the [`TeardownReport`] (what each surface
2173    /// removed), or `None` if no loaded plugin matched. **Synchronous** —
2174    /// teardown and `JoinHandle::abort` don't await — so an ex-command `apply`
2175    /// closure can call it directly. Idempotent per the teardown contract.
2176    pub fn unload(&self, target: &str) -> Option<TeardownReport> {
2177        let record = {
2178            let mut loaded = self
2179                .loaded
2180                .lock()
2181                .expect("plugin-loader loaded-set mutex poisoned");
2182            let pos = loaded.iter().position(|r| record_matches(r, target))?;
2183            loaded.remove(pos)
2184        };
2185
2186        // The running-actor half: abort the detached seam tasks (picker / events
2187        // / completion). The registry half is the `PluginTeardown` below.
2188        for task in &record.tasks {
2189            task.abort();
2190        }
2191        let report = self.run_teardown(&record.teardown);
2192        if let Some(sink) = &self.env.meta_sink {
2193            sink.unregister_plugin(record.id.0);
2194        }
2195        // PO.1/PO.5: reclaim this plugin's boundary-trace state (its per-plugin
2196        // ring, gate override, and hot-path gate). ids are monotonic, so without
2197        // this every unload/reload would leak a ring — the global ring keeps the
2198        // historical records. The tracer lock is poison-tolerant, so this never
2199        // fails the unload.
2200        if let Some(tracer) = &self.env.tracer {
2201            tracer.forget_plugin(record.id.0);
2202        }
2203        // CI.1: announce the unload AFTER teardown reversed every contribution, so
2204        // a handler tears down its own dependent setup against a plugin that's
2205        // already gone from the registries.
2206        if let Some(bus) = &self.env.bus {
2207            bus.publish(lattice_protocol::Event::PluginUnloaded {
2208                name: record.name.clone(),
2209                id: record.id.0,
2210            });
2211        }
2212        tracing::info!(
2213            plugin = %record.name,
2214            id = record.id.0,
2215            ?report,
2216            "plugin unloaded"
2217        );
2218        Some(report)
2219    }
2220
2221    /// Reload the plugin named `target`: [`unload`](Self::unload) it, then
2222    /// re-[`load_path`](Self::load_path) from its recorded source directory —
2223    /// minting a fresh `Store` with a fresh, untripped `Quarantine` (the reload
2224    /// contract, teardown.rs §"Why no reload method"). Errors if `target` names
2225    /// no loaded plugin ([`NotLoaded`](PluginLoaderError::NotLoaded)) or it has
2226    /// no on-disk source ([`NotReloadable`](PluginLoaderError::NotReloadable)).
2227    pub async fn reload(
2228        &self,
2229        target: &str,
2230        tier: TrustTier,
2231    ) -> Result<PluginId, PluginLoaderError> {
2232        // Capture the source dir before unloading (unload removes the record).
2233        let dir = {
2234            let loaded = self
2235                .loaded
2236                .lock()
2237                .expect("plugin-loader loaded-set mutex poisoned");
2238            let record = loaded
2239                .iter()
2240                .find(|r| record_matches(r, target))
2241                .ok_or_else(|| PluginLoaderError::NotLoaded(target.to_string()))?;
2242            record
2243                .source_dir
2244                .clone()
2245                .ok_or_else(|| PluginLoaderError::NotReloadable(record.name.clone()))?
2246        };
2247        self.unload(target);
2248        self.load_path(&dir, tier).await
2249    }
2250
2251    /// Load the `init` config if it isn't loaded, or reload it if it is — the
2252    /// idempotent "make init reflect what's on disk" the auto-reload watcher
2253    /// (PL8.D.4) fires on every change to `<config>/lattice/init/`. First good
2254    /// build loads; a rebuild reloads (unbinding the old keymaps / commands and
2255    /// re-applying); a *broken* rebuild leaves `init` unloaded (reload unloads
2256    /// before it fails to re-load), which the next good build heals — `is_loaded`
2257    /// is then false, so this loads rather than reloads. Errors propagate for the
2258    /// caller to log; never panics.
2259    pub async fn sync_init(
2260        &self,
2261        init_dir: &std::path::Path,
2262        tier: TrustTier,
2263    ) -> Result<PluginId, PluginLoaderError> {
2264        if self.is_loaded(INIT_PLUGIN_ID) {
2265            self.reload(INIT_PLUGIN_ID, tier).await
2266        } else {
2267            self.load_path(init_dir, tier).await
2268        }
2269    }
2270
2271    /// The `source_dir` of the loaded `init` plugin, if it is loaded — the dir
2272    /// `reload_config` rebuilds and reloads from. `None` before init's first
2273    /// load (the caller then falls back to [`default_init_dir`](crate::default_init_dir)).
2274    fn init_source_dir(&self) -> Option<std::path::PathBuf> {
2275        let loaded = self.loaded.lock().ok()?;
2276        loaded
2277            .iter()
2278            .find(|r| r.name == INIT_PLUGIN_ID)?
2279            .source_dir
2280            .clone()
2281    }
2282
2283    /// Rebuild the user's `init.rs` **in place**, then (re)load it — the
2284    /// source → artifact step `:reload-config` and the plugins view's
2285    /// rebuild-of-`init` need and that the bare artifact reload
2286    /// ([`reload`](Self::reload) / [`load_path`](Self::load_path)) cannot do.
2287    ///
2288    /// This mirrors the boot path ([`install::build_init`](crate::install) +
2289    /// [`sync_init`](Self::sync_init)). Before it existed, `:reload-config`
2290    /// re-instantiated the **stale** on-disk `init.wasm`, so an edited option
2291    /// (e.g. `tabstop`) took effect only after a full restart — the one path
2292    /// that recompiled. `init`'s `SourceRecord` is `Unknown` (it is discovered
2293    /// directly, never "installed", so it has no `.source` marker) and it
2294    /// compiles in place rather than staging into the plugin cache, so it
2295    /// cannot go through the generic [`rebuild_with`](Self::rebuild_with)
2296    /// pipeline — hence this dedicated seam.
2297    ///
2298    /// Loads at the [`Bundled`](TrustTier::Bundled) tier the boot path uses —
2299    /// the user's own config is trusted, not a third-party plugin.
2300    ///
2301    /// A build failure with a previous artifact present keeps the last good
2302    /// config running and reports [`ConfigBuildStatus::BuildFailed`] with the
2303    /// compiler error (an `Ok` whose [`applied_new_config`] is `false`); a build
2304    /// failure with **no** previous artifact surfaces as `Err` — there is
2305    /// nothing to load.
2306    ///
2307    /// [`applied_new_config`]: ReloadConfigReport::applied_new_config
2308    pub async fn reload_config(&self) -> Result<ReloadConfigReport, PluginLoaderError> {
2309        // Rebuild exactly what is loaded: prefer the loaded `init` record's own
2310        // source dir (which `sync_init` → `reload` reloads from), so the build
2311        // target and the reload target can never diverge; fall back to the
2312        // default config dir for the first load (init not yet loaded).
2313        let init_dir = self
2314            .init_source_dir()
2315            .or_else(crate::default_init_dir)
2316            .ok_or_else(|| {
2317                PluginLoaderError::Discovery(
2318                    "no config directory for init.rs (set XDG_CONFIG_HOME / HOME)".to_string(),
2319                )
2320            })?;
2321        // Compile source → artifact in place first (the step reload cannot do).
2322        let outcome = crate::install::build_init(&init_dir).await;
2323        let build = match &outcome {
2324            // No cargo project: a hand-built init.wasm is loaded as-is.
2325            None => ConfigBuildStatus::HandBuilt,
2326            Some(o) => match o.error() {
2327                // A `Fresh` build recompiled; a `Cached` one was already current.
2328                None => {
2329                    if matches!(o, crate::build::BuildOutcome::Fresh { .. }) {
2330                        ConfigBuildStatus::Rebuilt
2331                    } else {
2332                        ConfigBuildStatus::AlreadyCurrent
2333                    }
2334                }
2335                // `StaleKept` / `Failed`: the build failed. If a previous
2336                // artifact survived (`StaleKept`) `sync_init` reloads it below;
2337                // if not (`Failed`, no artifact) the load errors and the `?`
2338                // propagates — either way the user gets the compiler error.
2339                Some(error) => ConfigBuildStatus::BuildFailed(error.to_string()),
2340            },
2341        };
2342        let id = self.sync_init(&init_dir, TrustTier::Bundled).await?;
2343        Ok(ReloadConfigReport { id, build })
2344    }
2345
2346    /// Spawn [`reload_config`](Self::reload_config) on the runtime and report the
2347    /// detailed result to `*messages*`: `info!` when the edited config applied,
2348    /// `warn!` when the build failed but the previous config still runs, `error!`
2349    /// when `init` did not load at all. The ex-command's `apply` returns
2350    /// immediately (it must not block the dispatch path); this is where the
2351    /// success/failure detail the user asked for lands.
2352    ///
2353    /// `info!` / `warn!` / `error!` route to `*messages*` via `MessagesLayer`
2354    /// (the one-shot, user-actionable log-level rule), so the compiler error on
2355    /// a failed rebuild is visible to `:messages` without a keypress.
2356    pub(crate) fn spawn_reload_config(self: &Arc<Self>) {
2357        let Some(runtime) = self.env.runtime.clone() else {
2358            tracing::warn!("no runtime wired; :reload-config cannot run");
2359            return;
2360        };
2361        let this = Arc::clone(self);
2362        runtime.spawn(async move {
2363            match this.reload_config().await {
2364                Ok(report) if report.applied_new_config() => {
2365                    tracing::info!(id = report.id.0, "{}", report.summary());
2366                }
2367                Ok(report) => {
2368                    // Build failed; the previous config is still running.
2369                    tracing::warn!(id = report.id.0, "{}", report.summary());
2370                }
2371                Err(err) => tracing::error!(
2372                    error = %error_chain(&err),
2373                    "config reload FAILED — init.rs did not load"
2374                ),
2375            }
2376        });
2377    }
2378
2379    /// Reverse a plugin's registry contributions against the live registries.
2380    /// The `ArcSwap`-held registries (command / picker / mode) are RCU'd —
2381    /// snapshot-clone → `&mut` → [`PluginTeardown::unload`] → store — while the
2382    /// `Arc`-shared interior-mutable ones (config / keymap / bus) pass by
2383    /// reference. A missing registry handle (a partially-wired test loader)
2384    /// downgrades to a logged no-op reversal, never a panic.
2385    fn run_teardown(&self, teardown: &PluginTeardown) -> TeardownReport {
2386        // CR.3: the help registry is reversed FIRST, and deliberately outside
2387        // the all-or-nothing `let-else` below.
2388        //
2389        // Two reasons. It lives in `lattice-help`, so `PluginTeardown::unload`
2390        // — which is in `lattice-plugin-host` — cannot touch it without
2391        // pulling that crate across the boundary for one field. And it shares
2392        // nothing with the handles the `let-else` demands, so gating it on
2393        // them would mean an under-wired loader leaves a plugin's `:help`
2394        // pages behind after `:plugin-unload` reported success. Stale docs
2395        // for code that is gone is a worse failure than the partial teardown
2396        // that caused it, and a quieter one.
2397        // Over EVERY seam id — `help` is rarely a plugin's first seam, and
2398        // reversing only `plugin_id` is what left `auto-pair`'s pages behind.
2399        let provenances = teardown.provenances();
2400        let mut help_topics_removed = 0;
2401        if let Some(help_h) = self.env.help_topics.as_ref() {
2402            help_h.rcu(|current| {
2403                let mut next = (**current).clone();
2404                help_topics_removed = 0;
2405                for id in &provenances {
2406                    help_topics_removed += next.unregister_plugin(id.0 as u64);
2407                }
2408                Arc::new(next)
2409            });
2410        }
2411        // LG.3c: same placement and reasoning as `help` above, with one
2412        // difference worth noting — the language registry is process-global,
2413        // so unlike every other registry here there is no handle that can be
2414        // absent and therefore no way for this to be silently skipped by an
2415        // under-wired loader. Leaving a language registered would be worse
2416        // than stale docs: a buffer would keep claiming a grammar its plugin
2417        // no longer provides.
2418        let languages_removed: usize = provenances
2419            .iter()
2420            .map(|id| lattice_syntax::plugin_lang::unregister_plugin(id.0 as u64))
2421            .sum();
2422        // CR.4: same placement, same reasoning — plus one of its own. Leaving
2423        // a plugin's section registered after unload would keep calling a
2424        // guest whose plugin is gone on every compose.
2425        let mut dashboard_sections_removed = 0;
2426        if let Some(dash_h) = self.env.dashboard_sections.as_ref() {
2427            dash_h.rcu(|current| {
2428                let mut next = (**current).clone();
2429                dashboard_sections_removed = 0;
2430                for id in &provenances {
2431                    dashboard_sections_removed += next.unregister_plugin(id.0 as u64);
2432                }
2433                Arc::new(next)
2434            });
2435        }
2436        // TR.2b: same placement and reasoning as `help` above — the registry is
2437        // `Arc`-shared with interior mutability rather than one of the `&mut`
2438        // snapshots below, and leaving a name registered would be worse than
2439        // stale docs: the entry holds a client whose actor has ended, so the
2440        // chord would report a host error rather than "unknown source".
2441        let mut transient_sources_removed = 0;
2442        if let Some(tr_h) = self.env.transient_registry.as_ref() {
2443            for name in &teardown.transient_sources {
2444                if tr_h.unregister(name) {
2445                    transient_sources_removed += 1;
2446                }
2447            }
2448        }
2449        // MV.1: same placement and reasoning as `help` and `transient` above,
2450        // and it is a correctness point rather than tidiness. The guard below
2451        // is all-or-nothing — a missing command registry skips the ENTIRE
2452        // unload — and a command registry is not a precondition for reversing a
2453        // VIEW registration. Leaving one registered is worse than stale docs:
2454        // `ProviderViewRegistry::register` refuses rather than replaces, so a
2455        // reload would hit `false` against the plugin's own dead opener and its
2456        // views would come back permanently broken.
2457        if let Some(pv_h) = self.env.provider_view_registry.as_ref() {
2458            for name in &teardown.provider_views {
2459                pv_h.unregister(name);
2460            }
2461        }
2462        let (
2463            Some(cmd_h),
2464            Some(pick_h),
2465            Some(mode_h),
2466            Some(config),
2467            Some(keymap),
2468            Some(bus),
2469            Some(deco_h),
2470            Some(ctx_h),
2471            Some(theme_h),
2472            Some(parsers_h),
2473        ) = (
2474            self.env.command_registry.as_ref(),
2475            self.env.picker_registry.as_ref(),
2476            self.env.mode_registry.as_ref(),
2477            self.env.config_registry.as_ref(),
2478            self.env.keymap.as_ref(),
2479            self.env.bus.as_ref(),
2480            self.env.decoration_registry.as_ref(),
2481            self.env.context_registry.as_ref(),
2482            self.env.theme_registry.as_ref(),
2483            self.env.parser_factories.as_ref(),
2484        )
2485        else {
2486            tracing::warn!(
2487                "plugin teardown skipped: loader missing a registry handle (partial unload)"
2488            );
2489            return TeardownReport {
2490                help_topics: help_topics_removed,
2491                dashboard_sections: dashboard_sections_removed,
2492                languages: languages_removed,
2493                transient_sources: transient_sources_removed,
2494                ..TeardownReport::default()
2495            };
2496        };
2497
2498        // Owned snapshots of the ArcSwap registries for the `&mut` unload needs.
2499        let mut commands = (**cmd_h.load()).clone();
2500        let mut pickers = (**pick_h.load()).clone();
2501        let mut modes = (**mode_h.load()).clone();
2502        let mut decorations = (**deco_h.load()).clone();
2503        let mut contexts = (**ctx_h.load()).clone();
2504        let report = {
2505            let mut media_reg = self
2506                .env
2507                .media_registry
2508                .as_ref()
2509                .map(|r| (**r.load()).clone())
2510                .unwrap_or_default();
2511            let mut agenda_reg = self
2512                .env
2513                .agenda_registry
2514                .as_ref()
2515                .map(|r| (**r.load()).clone())
2516                .unwrap_or_default();
2517            let mut reg = TeardownRegistries {
2518                // Reversed ABOVE the guard, not here — see the comment there.
2519                provider_views: None,
2520                media: &mut media_reg,
2521                agenda: &mut agenda_reg,
2522                commands: &mut commands,
2523                pickers: &mut pickers,
2524                modes: &mut modes,
2525                keymap,
2526                config,
2527                bus,
2528                decorations: &mut decorations,
2529                contexts: &mut contexts,
2530                theme: &**theme_h,
2531                // SG.3a: NOT part of the guard tuple above, for the same
2532                // reason as `modeline` below — a sign registry is not a
2533                // precondition for reversing a config option, and making it
2534                // one would turn every unload in a harness without one into a
2535                // silent no-op.
2536                signs: self.env.sign_registry.as_ref(),
2537                // OC.3: NOT part of the guard tuple above. That tuple is
2538                // all-or-nothing — a missing handle skips the ENTIRE unload —
2539                // and a modeline is not a precondition for reversing a config
2540                // option. `WiredSeams::all()` is where its absence is caught.
2541                modeline: self.env.modeline.as_ref(),
2542                // CM.6b: RCU'd inside `unload` (it holds the `ArcSwap`
2543                // handle directly rather than a `&mut` snapshot), because a
2544                // compilation run may be reading it concurrently and the
2545                // common case removes nothing at all.
2546                parsers: parsers_h,
2547            };
2548            let report = teardown.unload(&mut reg);
2549            // The media / agenda snapshots are `&mut` clones, so the reversal
2550            // has to be published back the same way the ArcSwap registries
2551            // below are. Missing this is how an unloaded producer keeps
2552            // contributing until the next reload.
2553            if let Some(h) = self.env.media_registry.as_ref() {
2554                h.store(Arc::new(media_reg));
2555            }
2556            if let Some(h) = self.env.agenda_registry.as_ref() {
2557                h.store(Arc::new(agenda_reg));
2558            }
2559            report
2560        };
2561        // Publish the reversed snapshots (RCU store).
2562        cmd_h.store(Arc::new(commands));
2563        pick_h.store(Arc::new(pickers));
2564        mode_h.store(Arc::new(modes));
2565        deco_h.store(Arc::new(decorations));
2566        ctx_h.store(Arc::new(contexts));
2567        let mut report = report;
2568        report.help_topics = help_topics_removed;
2569        report.transient_sources = transient_sources_removed;
2570        report.languages = languages_removed;
2571        report.dashboard_sections = dashboard_sections_removed;
2572        report
2573    }
2574
2575    /// Drain the picker seam: spawn the source actor, fetch its spec, register
2576    /// the `WasmPickerSource` into the picker registry by copy-on-write RCU, and
2577    /// spawn the actor's `run` loop on the runtime. Records the actor task +
2578    /// source id on `record` for teardown (PL8.C).
2579    async fn drain_picker(
2580        &self,
2581        component: &lattice_plugin_host::Component,
2582        manifest: &PluginManifest,
2583        tier: TrustTier,
2584        record: &mut LoadedRecord,
2585    ) -> Result<PluginId, PluginLoaderError> {
2586        let bus = self
2587            .env
2588            .bus
2589            .as_ref()
2590            .ok_or(PluginLoaderError::NotWired("picker-source"))?;
2591        let runtime = self
2592            .env
2593            .runtime
2594            .as_ref()
2595            .ok_or(PluginLoaderError::NotWired("picker-source"))?;
2596        let registry = self
2597            .env
2598            .picker_registry
2599            .as_ref()
2600            .ok_or(PluginLoaderError::NotWired("picker-source"))?;
2601
2602        let (client, actor) = self
2603            .host
2604            .spawn_picker_source(
2605                component,
2606                manifest,
2607                tier,
2608                PluginBudget::default(),
2609                bus,
2610                // OR.6: a source reads options to decide what it offers. Absent,
2611                // `get-option` answers `none` on this store and a
2612                // configuration-driven source reports itself unconfigured.
2613                self.env.config_registry.as_ref(),
2614            )
2615            .await?;
2616
2617        // Drive the actor's request loop on the multi-thread runtime FIRST —
2618        // `connect_all` below issues a `register-picker-sources()` guest call
2619        // over the client channel, which the actor must be running to answer
2620        // (else the await deadlocks). PO.2: attach the boundary tracer so the
2621        // actor emits a trace record per guest call (a no-op when unwired).
2622        let actor = actor.with_tracer(self.env.tracer.clone());
2623        // The host-issued id, captured before `client` moves into the
2624        // registration call — a plugin that declares NO sources still loaded,
2625        // and still has an identity to report.
2626        let plugin_id = client.id();
2627        let task = runtime.spawn(actor.run());
2628
2629        // OR.5b: registration is a guest call that may declare SEVERAL sources.
2630        // A trapping registration fails loudly rather than registering broken
2631        // sources; an empty list registers nothing, which is what a plugin that
2632        // declared nothing asked for.
2633        let sources = WasmPickerSource::connect_all(client).await?;
2634        let id = plugin_id;
2635        // Own the ids for the teardown tokens before the sources move into the
2636        // generators below.
2637        let source_ids: Vec<String> = sources.iter().map(|s| s.spec().id.to_string()).collect();
2638
2639        // Copy-on-write RCU into the wait-free registry: clone the current
2640        // snapshot, add every source, publish. Concurrent picker-open readers
2641        // keep seeing the old snapshot until the store lands — no lock on their
2642        // path. ONE rcu for the whole batch, so a plugin's sources appear
2643        // together rather than one snapshot at a time.
2644        let generators: Vec<Arc<dyn PickerSourceGenerator>> = sources
2645            .into_iter()
2646            .map(|s| Arc::new(s) as Arc<dyn PickerSourceGenerator>)
2647            .collect();
2648        registry.rcu(|current| {
2649            let mut next = (**current).clone();
2650            for generator in &generators {
2651                next.register_generator(generator.clone());
2652            }
2653            Arc::new(next)
2654        });
2655
2656        record.tasks.push(task);
2657        // Teardown tokens: the picker registry unregisters each source by id.
2658        record.teardown.picker_sources.extend(source_ids);
2659        Ok(id)
2660    }
2661
2662    /// MV.1 — drain the multibuffer-view seam: spawn the view actor, ask the
2663    /// guest which views it owns, and register a provider-view opener for each.
2664    ///
2665    /// ## Why the opener is sync and the build is not
2666    ///
2667    /// `ProviderViewOpener` is a synchronous closure called on the dispatch
2668    /// path; `build` is an async guest call that may read files. So the opener
2669    /// seats an empty view with an in-progress headerline and returns, and a
2670    /// spawned task awaits the guest and fills it — `providers/agenda.rs`'s
2671    /// shape, for its reason: a plugin's file reads must never land on the
2672    /// dispatch path.
2673    ///
2674    /// The fill publishes `MultibufferExcerptsReady`, which has a wake wired.
2675    /// Without it the rows would sit until the user happened to press a key,
2676    /// and the symptom would read as a rendering bug rather than a missing
2677    /// wake — the bug class this codebase has re-introduced repeatedly.
2678    async fn drain_multibuffer_views(
2679        &self,
2680        component: &lattice_plugin_host::Component,
2681        manifest: &PluginManifest,
2682        tier: TrustTier,
2683        record: &mut LoadedRecord,
2684    ) -> Result<PluginId, PluginLoaderError> {
2685        let bus = self
2686            .env
2687            .bus
2688            .as_ref()
2689            .ok_or(PluginLoaderError::NotWired("multibuffer-view-source"))?;
2690        let runtime = self
2691            .env
2692            .runtime
2693            .as_ref()
2694            .ok_or(PluginLoaderError::NotWired("multibuffer-view-source"))?;
2695        let providers = self
2696            .env
2697            .provider_view_registry
2698            .as_ref()
2699            .ok_or(PluginLoaderError::NotWired("multibuffer-view-source"))?;
2700        let mb_registry = self
2701            .env
2702            .multibuffer_registry
2703            .as_ref()
2704            .ok_or(PluginLoaderError::NotWired("multibuffer-view-source"))?;
2705
2706        let (client, actor) = self
2707            .host
2708            .spawn_multibuffer_view_source(
2709                component,
2710                manifest,
2711                tier,
2712                PluginBudget::default(),
2713                bus,
2714                self.env.config_registry.as_ref(),
2715            )
2716            .await?;
2717
2718        // Drive the actor FIRST — `register_views` is a guest call over the
2719        // client channel, which the actor must be running to answer.
2720        let actor = actor.with_tracer(self.env.tracer.clone());
2721        let plugin_id = client.id();
2722        let task = runtime.spawn(actor.run());
2723
2724        let specs = client.register_views().await?;
2725
2726        // SECURITY: the plugin's effective fs grant, resolved once for the
2727        // whole drain.
2728        //
2729        // An excerpt names a PATH the guest chose, and the host reads it to
2730        // build the source document. Without this, a plugin holding no fs
2731        // capability at all could name `/etc/passwd` — or a symlink pointing
2732        // there — and have the host read it into a buffer on its behalf. The
2733        // authorizer's own rule is that guest-named paths are checked at the
2734        // BOUNDARY, where provenance is still known; by the time an excerpt
2735        // reaches `lattice-multibuffer` the host no longer knows which plugin
2736        // asked, exactly as `EffectAuthorizer` documents for `WriteToFile`.
2737        let grant = lattice_plugin_host::capability::grant(manifest, tier).grant;
2738
2739        for spec in specs {
2740            let native = lattice_multibuffer::providers::plugin_view::PluginViewSpec {
2741                id: spec.id.clone(),
2742                doc: spec.doc.clone(),
2743                buffer_name: spec.buffer_name.clone(),
2744                view_mode: spec.view_mode.clone(),
2745                reuse: spec.reuse,
2746            };
2747            // MV.3: a `scan` view is driven by the host's walk over the
2748            // registered `scanned-excerpt-source`s — the agenda's machinery,
2749            // now parameterised by identity rather than by module constants. A
2750            // `pull` view calls the guest's `build`.
2751            let is_pull = matches!(
2752                spec.input,
2753                lattice_plugin_host::lattice::plugin_host::types::MultibufferViewInput::Pull
2754            );
2755
2756            let client = client.clone();
2757            let grant = grant.clone();
2758            let mb = (*mb_registry).clone();
2759            let bus = (*bus).clone();
2760            let runtime = runtime.clone();
2761            let view_id = spec.id.clone();
2762            // The view this opener made last time, so `reuse` re-enters its OWN
2763            // buffer rather than resolving a guest-chosen name — which would
2764            // let a plugin declare `*agenda*` and take over someone else's view.
2765            let last_view: Arc<std::sync::Mutex<Option<lattice_core::BufferId>>> =
2766                Arc::new(std::sync::Mutex::new(None));
2767            let opener: lattice_mode::ProviderViewOpener = Arc::new(
2768                move |activator: &mut dyn lattice_mode::ModeActivator,
2769                      args: &lattice_grammar::Args| {
2770                    // A scan view IS the agenda's shape, so it takes the
2771                    // agenda's opener with this view's identity. Nothing about
2772                    // the walk, the sort or the grouping differs — only who
2773                    // named the view.
2774                    if !is_pull {
2775                        return lattice_multibuffer::providers::scan_view::open_scan_view(
2776                            activator,
2777                            &lattice_multibuffer::providers::scan_view::ScanViewIdentity {
2778                                provider: native.id.clone(),
2779                                buffer_name: native.buffer_name.clone(),
2780                                view_mode: native.view_mode.clone(),
2781                                no_rows_message: "no plugin provides rows for it".to_string(),
2782                            },
2783                            args,
2784                        );
2785                    }
2786                    let previous = last_view.lock().ok().and_then(|g| *g);
2787                    let outcome = lattice_multibuffer::providers::plugin_view::open_plugin_view(
2788                        activator, &native, previous,
2789                    );
2790                    let lattice_mode::ProviderViewOutcome::Opened { view, .. } = outcome else {
2791                        return outcome;
2792                    };
2793                    if let Ok(mut slot) = last_view.lock() {
2794                        *slot = Some(view);
2795                    }
2796                    let args: Vec<String> = match args {
2797                        lattice_grammar::Args::String(s) if !s.trim().is_empty() => {
2798                            vec![s.trim().to_string()]
2799                        }
2800                        // Only the string-shaped values carry through: a view's
2801                        // args are names and paths, and a bool or an int in the
2802                        // list is a caller error rather than something to
2803                        // stringify into a filter the guest will not recognise.
2804                        lattice_grammar::Args::List(items) => items
2805                            .iter()
2806                            .filter_map(|v| v.as_str().map(str::to_string))
2807                            .collect(),
2808                        _ => Vec::new(),
2809                    };
2810                    let client = client.clone();
2811                    let grant = grant.clone();
2812                    let mb = mb.clone();
2813                    let bus = bus.clone();
2814                    let view_id = view_id.clone();
2815                    runtime.spawn(async move {
2816                        match client.build(view_id.clone(), args).await {
2817                            Ok(Ok(result)) => {
2818                                let spec_for_fill =
2819                                    lattice_multibuffer::providers::plugin_view::PluginViewSpec {
2820                                        id: view_id,
2821                                        doc: String::new(),
2822                                        buffer_name: String::new(),
2823                                        view_mode: None,
2824                                        reuse: true,
2825                                    };
2826                                lattice_multibuffer::providers::plugin_view::fill_plugin_view(
2827                                    &mb,
2828                                    Some(&bus),
2829                                    view,
2830                                    &spec_for_fill,
2831                                    convert_view_result(result, &grant),
2832                                );
2833                            }
2834                            // The guest's own words — the host has no vocabulary
2835                            // for "nothing links here yet".
2836                            Ok(Err(message)) => {
2837                                lattice_multibuffer::providers::plugin_view::decline_plugin_view(
2838                                    &mb,
2839                                    Some(&bus),
2840                                    view,
2841                                    &format!("{view_id}: {message}"),
2842                                );
2843                            }
2844                            Err(error) => {
2845                                lattice_multibuffer::providers::plugin_view::decline_plugin_view(
2846                                    &mb,
2847                                    Some(&bus),
2848                                    view,
2849                                    &format!("{view_id}: {error}"),
2850                                );
2851                            }
2852                        }
2853                    });
2854                    outcome
2855                },
2856            );
2857
2858            if !providers.register(&spec.id, opener) {
2859                // An id a NATIVE provider already owns. Refused with both names
2860                // — and the guest's OTHER views still register, because one bad
2861                // name must not cost a plugin its whole contribution.
2862                tracing::warn!(
2863                    plugin = %manifest.id,
2864                    view = %spec.id,
2865                    "multibuffer view id is already registered; this view is unreachable"
2866                );
2867                continue;
2868            }
2869            record.teardown.provider_views.push(spec.id.clone());
2870        }
2871
2872        record.tasks.push(task);
2873        Ok(plugin_id)
2874    }
2875
2876    /// TR.2b — drain the transient seam: spawn the menu actor, ask the guest for
2877    /// the name it registers under, and install a `register_async` builder into
2878    /// the editor's `TransientSourceRegistry`. Records the actor task + the
2879    /// menu name on `record` for teardown.
2880    ///
2881    /// The `id()` call happens ONCE, here, because it keys the registry entry
2882    /// and cannot change. `build` is called per open — that is the point of the
2883    /// seam, and it is what makes a plugin menu context-aware the way magit's
2884    /// dispatch is.
2885    async fn drain_transient(
2886        &self,
2887        component: &lattice_plugin_host::Component,
2888        manifest: &PluginManifest,
2889        tier: TrustTier,
2890        record: &mut LoadedRecord,
2891    ) -> Result<PluginId, PluginLoaderError> {
2892        let bus = self
2893            .env
2894            .bus
2895            .as_ref()
2896            .ok_or(PluginLoaderError::NotWired("transient-source"))?;
2897        let runtime = self
2898            .env
2899            .runtime
2900            .as_ref()
2901            .ok_or(PluginLoaderError::NotWired("transient-source"))?;
2902        let registry = self
2903            .env
2904            .transient_registry
2905            .as_ref()
2906            .ok_or(PluginLoaderError::NotWired("transient-source"))?;
2907        // The builder resolves each row's command NAME at build time, so it
2908        // needs the live registry rather than a snapshot taken here — a menu
2909        // opened later must see commands registered later.
2910        let commands = self
2911            .env
2912            .command_registry
2913            .as_ref()
2914            .ok_or(PluginLoaderError::NotWired("transient-source"))?;
2915
2916        let (client, actor) = self
2917            .host
2918            .spawn_transient_source(
2919                component,
2920                manifest,
2921                tier,
2922                PluginBudget::default(),
2923                bus,
2924                self.env.config_registry.as_ref(),
2925            )
2926            .await?;
2927
2928        // Drive the actor's request loop FIRST — `menu_id()` below is a guest
2929        // call over the client channel, which the actor must be running to
2930        // answer (else the await deadlocks). Same ordering as `drain_picker`.
2931        let actor = actor.with_tracer(self.env.tracer.clone());
2932        let id = client.id();
2933        let task = runtime.spawn(actor.run());
2934
2935        // A menu with no name has nothing to register under, so a failed or
2936        // blank `id()` fails the drain loudly rather than registering an
2937        // unreachable menu.
2938        let name = self.host_transient_name(&client, &manifest.id).await?;
2939
2940        registry.register_async(
2941            name.clone(),
2942            lattice_plugin_host::transient_builder(
2943                client,
2944                (*commands).clone(),
2945                manifest.id.clone(),
2946            ),
2947        );
2948
2949        record.tasks.push(task);
2950        record.teardown.transient_sources.push(name.clone());
2951        tracing::debug!(
2952            plugin = %manifest.id,
2953            menu = %name,
2954            "transient plugin registered its menu"
2955        );
2956        Ok(id)
2957    }
2958
2959    /// Ask a transient guest for its menu name, rejecting a blank one.
2960    ///
2961    /// Split out so the failure reads as one sentence at the call site: a blank
2962    /// name would register a menu `Effect::OpenTransient` could never address,
2963    /// which is a plugin that loads "successfully" and contributes nothing.
2964    async fn host_transient_name(
2965        &self,
2966        client: &lattice_plugin_host::TransientClient,
2967        plugin: &str,
2968    ) -> Result<String, PluginLoaderError> {
2969        let name = client.menu_id().await?;
2970        let trimmed = name.trim();
2971        if trimmed.is_empty() {
2972            tracing::warn!(plugin, "transient plugin returned a blank menu name");
2973            return Err(PluginLoaderError::NotWired("transient-source"));
2974        }
2975        Ok(trimmed.to_string())
2976    }
2977
2978    /// Drain the config seam: run the guest's `register-options` against the live
2979    /// config registry (already interior-mutable — `:set` / `:describe-option` /
2980    /// `:customize` treat plugin options uniformly). One-shot: no actor to spawn.
2981    async fn drain_config(
2982        &self,
2983        component: &lattice_plugin_host::Component,
2984        manifest: &PluginManifest,
2985        tier: TrustTier,
2986        record: &mut LoadedRecord,
2987    ) -> Result<PluginId, PluginLoaderError> {
2988        let registry = self
2989            .env
2990            .config_registry
2991            .as_ref()
2992            .ok_or(PluginLoaderError::NotWired("config"))?;
2993        let (id, names) = self
2994            .host
2995            .spawn_config_plugin(component, manifest, tier, PluginBudget::default(), registry)
2996            .await?;
2997        tracing::debug!(plugin = %manifest.id, options = ?names, "config plugin registered options");
2998        // Teardown token: the config registry unregisters each option by name.
2999        record.teardown.config_options = names;
3000        Ok(id)
3001    }
3002
3003    /// Drain the events seam: register the guest's subscriptions on the bus and
3004    /// drive its `on-event` actor on the runtime (off the keystroke path). A
3005    /// trapping handler quarantines the plugin without touching the publisher or
3006    /// other subscribers (the event-seam isolation contract).
3007    async fn drain_events(
3008        &self,
3009        component: &lattice_plugin_host::Component,
3010        manifest: &PluginManifest,
3011        tier: TrustTier,
3012        record: &mut LoadedRecord,
3013    ) -> Result<PluginId, PluginLoaderError> {
3014        let bus = self
3015            .env
3016            .bus
3017            .as_ref()
3018            .ok_or(PluginLoaderError::NotWired("events"))?;
3019        let runtime = self
3020            .env
3021            .runtime
3022            .as_ref()
3023            .ok_or(PluginLoaderError::NotWired("events"))?;
3024
3025        let (subscriptions, actor) = self
3026            .host
3027            .spawn_event_plugin(
3028                component,
3029                manifest,
3030                tier,
3031                PluginBudget::event(),
3032                bus,
3033                self.env.config_registry.as_ref(),
3034            )
3035            .await?;
3036        let id = actor.id();
3037        // PO.2: attach the boundary tracer so the actor emits a trace record per
3038        // guest call (a no-op when unwired).
3039        let actor = actor.with_tracer(self.env.tracer.clone());
3040        let task = runtime.spawn(actor.run());
3041        record.tasks.push(task);
3042        tracing::debug!(
3043            plugin = %manifest.id,
3044            subscriptions = subscriptions.len(),
3045            "event plugin subscribed"
3046        );
3047        // Teardown token: the bus unsubscribes each subscription id.
3048        record.teardown.subscriptions = subscriptions;
3049        Ok(id)
3050    }
3051
3052    /// Drain the grammar seam: instantiate the `grammar-plugin` component, drive
3053    /// its `register-grammar` export, and register the resulting native specs
3054    /// into the runtime-mutable command registry (B3a/B3b).
3055    ///
3056    /// The grammar seam is the **synchronous** one (the PH7.7 fork): each
3057    /// contributed motion / operator / text-object / ex-command carries a sync
3058    /// trampoline the dispatcher fires on keystroke, so — unlike the async actor
3059    /// seams (picker / events) — there is *no* actor `run()` loop to spawn. The
3060    /// command registry itself owns the guest `Store` (inside the boxed
3061    /// trampolines the specs carry), so registering the set is all that keeps the
3062    /// plugin's grammar alive; teardown (PL8.C) unregisters by `plugin_id`.
3063    ///
3064    /// Registration is **load → clone → register → store**, *not* an
3065    /// [`rcu`](arc_swap::ArcSwap::rcu): [`register_all`] consumes the set (the
3066    /// specs own non-`Clone` boxed trampolines) so it cannot run inside a
3067    /// retrying `rcu` closure. B3a made `CommandRegistry: Clone` (Arc'd spec
3068    /// closures) precisely so this snapshot clone is cheap (Arc bumps, no deep
3069    /// copy). Loads are serialized — boot discovery is a sequential
3070    /// [`discover_and_load`](Self::discover_and_load) loop, and the PL8.C
3071    /// `:plugin-load` ex-command dispatches one at a time — so the load→store
3072    /// window carries no lost-write race against a concurrent grammar
3073    /// registration. A malformed spec fails loudly with `PluginHostError`
3074    /// (mapped to [`PluginLoaderError::Host`]); a *runtime* `apply` trap degrades
3075    /// to a graceful no-op inside the trampoline (`CommandError::Plugin`), never
3076    /// a host crash.
3077    ///
3078    /// [`register_all`]: lattice_plugin_host::GrammarContributionSet::register_all
3079    fn drain_grammar(
3080        &self,
3081        component: &lattice_plugin_host::Component,
3082        manifest: &PluginManifest,
3083        tier: TrustTier,
3084    ) -> Result<PluginId, PluginLoaderError> {
3085        let bus = self
3086            .env
3087            .bus
3088            .as_ref()
3089            .ok_or(PluginLoaderError::NotWired("grammar"))?;
3090        let registry = self
3091            .env
3092            .command_registry
3093            .as_ref()
3094            .ok_or(PluginLoaderError::NotWired("grammar"))?;
3095
3096        // SYNC end to end (no async host import to drive) — instantiation +
3097        // `register-grammar` run at load time, off the keystroke path (the
3098        // sibling `self.host.compile` above is likewise a synchronous call).
3099        // PO.3: attach the boundary tracer so the sync grammar trampoline emits a
3100        // gated boundary-trace record per guest call (zero cost at the default
3101        // gate — a single relaxed-atomic load; design §4).
3102        let set = self.host.instantiate_grammar_plugin(
3103            component,
3104            manifest,
3105            tier,
3106            bus,
3107            self.env.tracer.as_ref(),
3108            // AP.3: the shared editor config registry, so a grammar action can
3109            // read an option (auto-pair's `auto-pair.style` gate).
3110            self.env.config_registry.as_ref(),
3111        )?;
3112        let id = set.plugin_id();
3113        let count = set.len();
3114
3115        // load → clone → register → store. `register_all` consumes `set`, so a
3116        // retrying `rcu` closure is impossible; the serialized-load invariant
3117        // (doc above) makes the plain swap race-free in practice.
3118        let mut next = (**registry.load()).clone();
3119        let operator_chords = set.register_all(&mut next);
3120        registry.store(Arc::new(next));
3121
3122        // CM.2: an operator that registered but has no keys is indistinguishable
3123        // from one that never loaded — so a declared chord with nowhere to land
3124        // fails the load rather than being skipped, the `agenda_registry`
3125        // contract.
3126        if !operator_chords.is_empty() {
3127            // Degrades, like the two cases below it. The first draft made this
3128            // `NotWired` on the `agenda_registry` precedent, and that precedent
3129            // does not transfer: an agenda plugin whose registry is missing
3130            // contributes NOTHING — its whole purpose is the view. A chord is
3131            // one contribution among many, and refusing the load costs the
3132            // plugin's motions, text objects and actions to punish a missing
3133            // keybinding. A harness with no keymap would kill every grammar
3134            // plugin too, which is how this was found.
3135            let Some(wirer) = self.env.operator_chords.as_ref() else {
3136                tracing::warn!(
3137                    plugin = %manifest.id,
3138                    "operator chord(s) declared but no chord wirer is wired; \
3139                     the operator is reachable by name only"
3140                );
3141                return Ok(id);
3142            };
3143            // The chords belong to the plugin's own minor mode, not to the
3144            // universal grammar: bound at `Builtin` they would outlive
3145            // `:set <id>.enabled=false` and point at a handler that is gone.
3146            // A chord with no mode to scope to has nowhere legitimate to land:
3147            // `Builtin` is the universal grammar and a plugin does not belong
3148            // there.
3149            //
3150            // But this DEGRADES rather than failing the load, for the same
3151            // reason a withheld capability does — it is a plugin-authoring
3152            // error, not broken infrastructure, and the rest of the plugin's
3153            // contributions are fine. Only a missing wirer (above) fails the
3154            // load, because that is a host wiring bug.
3155            //
3156            // Caught by `discovered_grammar_plugin_registers_and_dispatches_
3157            // through_the_registry`: the fixture gained a chord, that test's
3158            // manifest declares no modes, and the first draft of this refused
3159            // the whole plugin. One contribution that cannot be bound must not
3160            // cost the other nine.
3161            let Some(mode) = manifest.default_modes.first() else {
3162                tracing::warn!(
3163                    plugin = %manifest.id,
3164                    "operator chord(s) declared but the manifest has no \
3165                     `default_modes` to scope them to; the operator is \
3166                     reachable by name only"
3167                );
3168                return Ok(id);
3169            };
3170            // CM.2: the chord is a declared capability. `grant` is pure over
3171            // (manifest, tier), so this resolves exactly what every seam spawn
3172            // resolves internally.
3173            //
3174            // Withheld is NOT a load failure — distinct from the missing-wirer
3175            // case above. That one is broken infrastructure; this one is the
3176            // user's decision, and `register-binding`'s standing contract is
3177            // that a plugin never silently mis-binds. The operator stays
3178            // registered and reachable by name.
3179            if !lattice_plugin_host::grant(manifest, tier)
3180                .grant
3181                .grammar_chord
3182            {
3183                tracing::info!(
3184                    plugin = %manifest.id,
3185                    "operator chord(s) not bound: `grammar:chord` was not \
3186                     granted. The operator is still reachable by name."
3187                );
3188                return Ok(id);
3189            }
3190
3191            let mode_id = lattice_mode::ModeId::new(mode);
3192            for c in operator_chords {
3193                if let Err(err) = wirer.wire(
3194                    c.operator,
3195                    &c.chord,
3196                    c.doubled,
3197                    mode_id,
3198                    id.0,
3199                    &manifest.id,
3200                    false,
3201                ) {
3202                    tracing::warn!(
3203                        plugin = %manifest.id,
3204                        chord = %c.chord,
3205                        %err,
3206                        "operator chord could not be bound; the operator is \
3207                         reachable by name only"
3208                    );
3209                }
3210            }
3211        }
3212
3213        tracing::debug!(
3214            plugin = %manifest.id,
3215            contributions = count,
3216            "grammar plugin registered its motions / operators / text-objects / ex-commands"
3217        );
3218        // No teardown token needed: `PluginTeardown::unload` unconditionally
3219        // removes every `SourceLayer::Plugin(id)` command by provenance (grammar
3220        // here + the modes seam's `:<mode>` toggles).
3221        Ok(id)
3222    }
3223
3224    /// Drain the modes seam: instantiate the `modes-plugin` component, drive its
3225    /// `register-modes` export, and register each accepted minor mode into the
3226    /// runtime-mutable mode registry (B2), binding each mode's declared keymap
3227    /// into its own `MinorMode` layer.
3228    ///
3229    /// A registered mode is **declarative data** — id / kind / activation policy
3230    /// / capability requirements + keymap bindings that resolve to *existing*
3231    /// commands — so once `spawn_mode_plugin` copies it into the registry, the
3232    /// guest `Store` drops (no actor task, no live callback, nothing to keep
3233    /// alive); teardown (PL8.C) removes the modes + keymap layers by `plugin_id`.
3234    ///
3235    /// Registration RCUs the mode registry — **load → clone → spawn → store**.
3236    /// `spawn_mode_plugin` takes `&mut ModeRegistry` (registration drains after
3237    /// its async `register-modes`) so it holds the borrow across an `.await`;
3238    /// passing a local owned snapshot clone keeps that sound, and it can't run in
3239    /// a retrying `rcu` closure anyway (it instantiates a guest). B2 made
3240    /// `ModeRegistry` an `ArcSwap` handle + `Clone` for exactly this. The
3241    /// `commands` snapshot (read-only, for bind-time command resolution) and the
3242    /// interior-mutable `keymap` handle come straight from the wired services.
3243    /// A missing service degrades the modes seam to a logged skip
3244    /// (`NotWired("modes")`), never a boot abort; a `register-modes` trap maps to
3245    /// `PluginLoaderError::Host` and skips only this plugin.
3246    async fn drain_mode(
3247        &self,
3248        component: &lattice_plugin_host::Component,
3249        manifest: &PluginManifest,
3250        tier: TrustTier,
3251        record: &mut LoadedRecord,
3252    ) -> Result<PluginId, PluginLoaderError> {
3253        let mode_registry = self
3254            .env
3255            .mode_registry
3256            .as_ref()
3257            .ok_or(PluginLoaderError::NotWired("modes"))?;
3258        let keymap = self
3259            .env
3260            .keymap
3261            .as_ref()
3262            .ok_or(PluginLoaderError::NotWired("modes"))?;
3263        let commands = self
3264            .env
3265            .command_registry
3266            .as_ref()
3267            .ok_or(PluginLoaderError::NotWired("modes"))?;
3268
3269        // Read-only command snapshot for keymap-binding resolution; owned so it
3270        // outlives the `&mut next` borrow across the await.
3271        let commands_snapshot = commands.load_full();
3272        // load → clone → spawn → store (see the RCU note above).
3273        let mut next = (**mode_registry.load()).clone();
3274        let (id, mode_ids) = self
3275            .host
3276            .spawn_mode_plugin(
3277                component,
3278                manifest,
3279                tier,
3280                PluginBudget::default(),
3281                &mut next,
3282                &commands_snapshot,
3283                keymap,
3284                // MO.1: resolves each declared option override's name + value
3285                // against the registry `:set` writes to. Absent in a loader
3286                // built without config (the minimal harnesses) — the overrides
3287                // are then skipped with a warning naming the mode, the same
3288                // logged-skip every other unwired handle takes.
3289                self.env.config_registry.as_deref(),
3290            )
3291            .await?;
3292        mode_registry.store(Arc::new(next));
3293
3294        // Give each plugin-registered mode the SAME `:<mode-name>` toggle
3295        // ex-command native modes get at boot (`register_mode_toggle_commands`)
3296        // — so plugin modes are togglable by name uniformly and the
3297        // `:describe-mode` / `:list-modes` "Toggle with `:<mode-id>`" hint is
3298        // honest. Registered under `SourceLayer::Plugin(id)` so unload's
3299        // `unregister_plugin` reverses them; the registry `generation` bump the
3300        // registration triggers refreshes `:`-command completion. RCU the
3301        // command registry (load → clone → register → store), mirroring
3302        // `drain_grammar` above.
3303        if !mode_ids.is_empty() {
3304            let mut next_cmds = (**commands.load()).clone();
3305            for mode in &mode_ids {
3306                let name = mode.to_string();
3307                next_cmds.register_plugin_ex_command(
3308                    id.0,
3309                    &name,
3310                    lattice_grammar::registry::MODE_TOGGLE_COMMAND_DOC,
3311                    lattice_grammar::registry::mode_toggle_ex_command_spec(&name),
3312                );
3313            }
3314            commands.store(Arc::new(next_cmds));
3315        }
3316
3317        tracing::debug!(
3318            plugin = %manifest.id,
3319            modes = ?mode_ids,
3320            "mode plugin registered its minor modes"
3321        );
3322        // Teardown tokens: each mode reverses via `ModeRegistry::unregister` +
3323        // `KeymapHandle::remove_layer(MinorMode(id))`; the `:<mode>` toggle
3324        // ex-commands registered above reverse via `unregister_plugin(id)` in
3325        // `PluginTeardown::unload` (provenance-driven, no per-command token).
3326        record.teardown.modes.extend(mode_ids);
3327        Ok(id)
3328    }
3329
3330    /// Drain the completion seam: spawn the source actor, wrap the plugin's
3331    /// `WasmCompletionSource` as a native async `CompletionSourceContribution`,
3332    /// and register a loader-owned universal [`PluginCompletionMode`] carrying it
3333    /// into the runtime-mutable mode registry (option A).
3334    ///
3335    /// Completion is mode-attached across the whole editor (the aggregator
3336    /// `recompute_active_completion_sources_for` walks the mode registry calling
3337    /// `completion_sources()`), so the source rides a mode rather than a parallel
3338    /// registry. The async `generate` runs on the spawned actor (off the
3339    /// keystroke path); matching / ranking / annotation stay native, so paramount
3340    /// #1 holds. The `WasmCompletionSource` actor mirrors the picker seam — driven
3341    /// on the runtime, recorded on `record` for unload (PL8.C).
3342    ///
3343    /// Runtime-visibility caveat: a Universal mode contributes on a buffer only
3344    /// once it is *active* there and the completion-source cache is recomputed
3345    /// (on mode-activation transitions). At boot, discovery runs before buffers
3346    /// open, so the first cache build includes it; a plugin loaded *after* buffers
3347    /// are open reaches new buffers immediately but needs a re-activation +
3348    /// recompute pass for existing ones — that pass lands with the PL8.C
3349    /// `:plugin-load` / reload surface. A missing service degrades the seam to a
3350    /// logged skip (`NotWired`); a spawn/connect trap maps to
3351    /// `PluginLoaderError::Host` and skips only this plugin.
3352    async fn drain_completion(
3353        &self,
3354        component: &lattice_plugin_host::Component,
3355        manifest: &PluginManifest,
3356        tier: TrustTier,
3357        record: &mut LoadedRecord,
3358    ) -> Result<PluginId, PluginLoaderError> {
3359        let bus = self
3360            .env
3361            .bus
3362            .as_ref()
3363            .ok_or(PluginLoaderError::NotWired("completion-source"))?;
3364        let runtime = self
3365            .env
3366            .runtime
3367            .as_ref()
3368            .ok_or(PluginLoaderError::NotWired("completion-source"))?;
3369        let mode_registry = self
3370            .env
3371            .mode_registry
3372            .as_ref()
3373            .ok_or(PluginLoaderError::NotWired("completion-source"))?;
3374
3375        let (client, actor) = self
3376            .host
3377            .spawn_completion_source(
3378                component,
3379                manifest,
3380                tier,
3381                PluginBudget::default(),
3382                bus,
3383                self.env.config_registry.as_ref(),
3384            )
3385            .await?;
3386        // Drive the actor FIRST — `connect` issues a `spec()` guest call over the
3387        // client channel, which the actor must be running to answer.
3388        // PO.2: attach the boundary tracer so the actor emits a trace record per
3389        // guest call (a no-op when unwired).
3390        let actor = actor.with_tracer(self.env.tracer.clone());
3391        let task = runtime.spawn(actor.run());
3392        let source = WasmCompletionSource::connect(client).await?;
3393        let id = source.plugin_id();
3394        let source_id = source.id().to_string();
3395
3396        let contribution = CompletionSourceContribution {
3397            // OR.7: carried from the guest's own spec. Hardcoding `false`
3398            // here would make the flag unreachable from WASM, which is the
3399            // only place it is needed.
3400            accepts_non_word_query: source.accepts_non_word_query(),
3401            id: SourceId::new(&source_id),
3402            default_priority: PLUGIN_COMPLETION_DEFAULT_PRIORITY,
3403            auto_trigger: true,
3404            trigger_chars: Vec::new(),
3405            popup_filter_chord: None,
3406            kind: CompletionSourceKind::Async(Arc::new(source)),
3407        };
3408
3409        // RCU-register the carrier mode (load → clone → register → store; B2 made
3410        // ModeRegistry an ArcSwap handle + Clone). The `-mode` suffix satisfies
3411        // the registry's naming gate.
3412        let mode_id = format!("{}-completion-mode", manifest.id);
3413        let mode = PluginCompletionMode {
3414            id: ModeId::new(&mode_id),
3415            source: contribution,
3416        };
3417        let carrier_id = ModeId::new(&mode_id);
3418        let mut next = (**mode_registry.load()).clone();
3419        match next.register(mode) {
3420            Ok(_) => mode_registry.store(Arc::new(next)),
3421            Err(error) => {
3422                // An id collision (a mode already owns `<id>-completion-mode`)
3423                // leaves the source unreachable — abort the actor and fail loudly
3424                // rather than leak a dangling task or claim a phantom load.
3425                task.abort();
3426                tracing::warn!(
3427                    plugin = %manifest.id,
3428                    mode = %mode_id,
3429                    %error,
3430                    "completion carrier mode id collision; source unreachable, skipped"
3431                );
3432                return Err(PluginLoaderError::NothingLoaded);
3433            }
3434        }
3435
3436        record.tasks.push(task);
3437        // Teardown token: unregistering the carrier mode drops the source; the
3438        // actor task is aborted separately from `record.tasks`.
3439        record.teardown.modes.push(carrier_id);
3440        tracing::debug!(
3441            plugin = %manifest.id,
3442            source = %source_id,
3443            mode = %mode_id,
3444            "completion plugin registered its source on a universal carrier mode"
3445        );
3446        Ok(id)
3447    }
3448
3449    /// Drain the keymap seam (PL8.D): bind the plugin's user keybindings into
3450    /// `KeymapLayer::User` and record them as teardown tokens (unbound on unload).
3451    ///
3452    /// Direct registration, like config — the seam binds into the shared,
3453    /// interior-mutable [`KeymapHandle`] during `register-keymap`, so there is no
3454    /// RCU and no actor task. The command-registry snapshot resolves each
3455    /// binding's command name at bind time; an unregistered command / unparseable
3456    /// chord / withheld `KeymapCapability::User` binds nothing (logged, no trap).
3457    /// The first consumer is the user's `init.rs` (PL8.D.3).
3458    async fn drain_keymap(
3459        &self,
3460        component: &lattice_plugin_host::Component,
3461        manifest: &PluginManifest,
3462        tier: TrustTier,
3463        record: &mut LoadedRecord,
3464    ) -> Result<PluginId, PluginLoaderError> {
3465        let keymap = self
3466            .env
3467            .keymap
3468            .as_ref()
3469            .ok_or(PluginLoaderError::NotWired("keymap"))?;
3470        let commands = self
3471            .env
3472            .command_registry
3473            .as_ref()
3474            .ok_or(PluginLoaderError::NotWired("keymap"))?;
3475
3476        // Owned command snapshot for bind-time command-name resolution.
3477        let commands_snapshot = commands.load_full();
3478        let (id, tokens) = self
3479            .host
3480            .spawn_keymap_plugin(
3481                component,
3482                manifest,
3483                tier,
3484                PluginBudget::default(),
3485                keymap,
3486                &commands_snapshot,
3487            )
3488            .await?;
3489
3490        tracing::debug!(
3491            plugin = %manifest.id,
3492            bindings = tokens.len(),
3493            "keymap plugin bound user keybindings into KeymapLayer::User"
3494        );
3495        // Teardown tokens: each binding is unbound from `KeymapLayer::User` on
3496        // unload / reload.
3497        record.teardown.keymap_bindings = tokens;
3498        Ok(id)
3499    }
3500
3501    /// Drain the decorations seam (PL8.E): spawn the producer actor, wrap the
3502    /// plugin's [`WasmDecorationSource`] as a native
3503    /// [`AsyncGutterDecorationSource`], and RCU-register it into the
3504    /// runtime-mutable [`GutterDecorationSourceRegistryHandle`] the host's
3505    /// per-tick refresh drives.
3506    ///
3507    /// The decoration producer is the one hot-path-sensitive seam: it must NEVER
3508    /// run at paint time (a per-frame WASM call would violate paramount #1). The
3509    /// host caches its output per buffer and the renderer reads only the cache;
3510    /// the async `gutter_decorations` runs on the spawned actor (off the
3511    /// keystroke path), mirroring the picker / completion actor seams — driven on
3512    /// the runtime, recorded on `record` for unload. A missing service degrades
3513    /// the seam to a logged skip (`NotWired`); a spawn trap maps to
3514    /// TC.2 — drain the context seam: spawn the producer actor, register the
3515    /// `WasmContextSource` into the context registry by copy-on-write RCU, and
3516    /// spawn the actor's `run` loop on the runtime. Records the actor task +
3517    /// source id on `record` for teardown. Mirror of [`Self::drain_decorations`]
3518    /// — a context source, like a decoration source, carries no id/doc metadata,
3519
3520    /// TC.4 — drain the theme seam: instantiate the component, drive its
3521    /// `register-theme-elements` export once, and record the namespaced element
3522    /// names for teardown. No actor and no registry RCU: elements are declared
3523    /// synchronously into the shared registry, like config options.
3524    /// PM.7b: run a config guest's `register-plugins` export and record the
3525    /// plugins it declared.
3526    ///
3527    /// The specs are *declarations*. Nothing is resolved, cloned, built or
3528    /// downloaded here — see `plugin_manager_host` for why that split is
3529    /// load-bearing. The boot task drains them via
3530    /// [`PluginLoader::take_required`] and runs the pipeline off-thread.
3531    ///
3532    /// A guest that declares the seam and requires nothing is fine: the drain
3533    /// is empty and the load still counts (the plugin loaded, it just asked
3534    /// for no company).
3535    async fn drain_require(
3536        &self,
3537        component: &lattice_plugin_host::Component,
3538        manifest: &PluginManifest,
3539        tier: TrustTier,
3540        _record: &mut LoadedRecord,
3541    ) -> Result<PluginId, PluginLoaderError> {
3542        let (id, specs) = self
3543            .host
3544            .spawn_plugin_manager_plugin(component, manifest, PluginBudget::default(), tier)
3545            .await?;
3546        tracing::debug!(
3547            plugin = %manifest.id,
3548            count = specs.len(),
3549            "config guest declared plugins via require"
3550        );
3551        if !specs.is_empty()
3552            && let Ok(mut queue) = self.required.lock()
3553        {
3554            queue.extend(specs.into_iter().map(pipeline::to_required_spec));
3555        }
3556        // The seam contributes no registry entries, so there is nothing for
3557        // teardown to reverse — unloading the guest cannot un-install the
3558        // plugins it asked for, any more than removing a package list
3559        // uninstalls the packages.
3560        Ok(id)
3561    }
3562
3563    async fn drain_theme(
3564        &self,
3565        component: &lattice_plugin_host::Component,
3566        manifest: &PluginManifest,
3567        tier: TrustTier,
3568        record: &mut LoadedRecord,
3569    ) -> Result<PluginId, PluginLoaderError> {
3570        let registry = self
3571            .env
3572            .theme_registry
3573            .as_ref()
3574            .ok_or(PluginLoaderError::NotWired("theme"))?;
3575        let (id, elements) = self
3576            .host
3577            .spawn_theme_plugin(component, manifest, tier, PluginBudget::default(), registry)
3578            .await?;
3579        tracing::debug!(
3580            plugin = %manifest.id,
3581            id = id.0,
3582            elements = elements.len(),
3583            "theme plugin registered its elements"
3584        );
3585        record.teardown.theme_elements = elements;
3586        Ok(id)
3587    }
3588
3589    /// SG.3a — drain the sign seam: instantiate the component, drive its
3590    /// `register-signs` export once, and record the namespaced sign names for
3591    /// teardown. No actor and no registry RCU beyond the declaration itself:
3592    /// signs are declared synchronously into the shared registry, like theme
3593    /// elements and config options.
3594    ///
3595    /// Drains at rank 2, after `theme`, so a plugin that registers the element
3596    /// its signs name has already done so — its signs then paint in their own
3597    /// colours on the first frame rather than falling back to `gutter.sign`
3598    /// until something republishes.
3599    async fn drain_signs(
3600        &self,
3601        component: &lattice_plugin_host::Component,
3602        manifest: &PluginManifest,
3603        tier: TrustTier,
3604        record: &mut LoadedRecord,
3605    ) -> Result<PluginId, PluginLoaderError> {
3606        let registry = self
3607            .env
3608            .sign_registry
3609            .as_ref()
3610            .ok_or(PluginLoaderError::NotWired("signs"))?;
3611        let (id, signs) = self
3612            .host
3613            .spawn_sign_plugin(component, manifest, tier, PluginBudget::default(), registry)
3614            .await?;
3615        tracing::debug!(
3616            plugin = %manifest.id,
3617            id = id.0,
3618            signs = signs.len(),
3619            "sign plugin declared its signs"
3620        );
3621        record.teardown.signs = signs;
3622        Ok(id)
3623    }
3624
3625    /// CM.6b — the `error-parser` seam's drain.
3626    ///
3627    /// Mints the factory (which instantiates once, so a component that
3628    /// cannot start fails the load rather than silently contributing
3629    /// nothing to every build) and RCU-registers it into the compilation
3630    /// parser-factory registry.
3631    ///
3632    /// Takes no `record`: teardown is by **provenance**, not by token —
3633    /// `PluginTeardown` removes every factory carrying this plugin's
3634    /// host-issued id, exactly as it does for commands. There is no list
3635    /// to record and therefore none to forget.
3636    fn drain_error_parser(
3637        &self,
3638        component: &lattice_plugin_host::Component,
3639        manifest: &PluginManifest,
3640        tier: TrustTier,
3641    ) -> Result<PluginId, PluginLoaderError> {
3642        let registry = self
3643            .env
3644            .parser_factories
3645            .as_ref()
3646            .ok_or(PluginLoaderError::NotWired("error-parser"))?;
3647
3648        // The Reflex-class budget, not the lifecycle default: `feed` runs
3649        // once per captured line on a fast producer's critical path.
3650        let (id, factory) =
3651            self.host
3652                .error_parser_factory(component, manifest, tier, PluginBudget::grammar())?;
3653
3654        let factory: Arc<dyn lattice_compilation::CompilationParserFactory> = Arc::new(factory);
3655        registry.rcu(|current| {
3656            let mut next = (**current).clone();
3657            next.register(factory.clone());
3658            Arc::new(next)
3659        });
3660        tracing::debug!(
3661            plugin = %manifest.id,
3662            id = id.0,
3663            "error-parser plugin registered its parser factory"
3664        );
3665        Ok(id)
3666    }
3667
3668    /// CR.3 — the `help` seam's drain.
3669    ///
3670    /// Drives `register-help-topics` once, then RCU-registers what the guest
3671    /// declared into the help registry, each topic stamped with this plugin's
3672    /// host-issued id. Teardown is by that provenance
3673    /// (`HelpTopicRegistry::unregister_plugin`), so — like `error-parser` —
3674    /// there is no list to record on `record` and therefore none to forget.
3675    ///
3676    /// The guest is dropped when `spawn_help_plugin` returns: the bodies are
3677    /// already across as owned `String`s, so nothing about the plugin needs to
3678    /// be alive for `:help` to render its pages.
3679    ///
3680    /// A plugin that declared no topics still loads. It is a strange plugin,
3681    /// not a broken one, and failing the load would be a worse answer than a
3682    /// debug line.
3683    async fn drain_help(
3684        &self,
3685        component: &lattice_plugin_host::Component,
3686        manifest: &PluginManifest,
3687        tier: TrustTier,
3688    ) -> Result<PluginId, PluginLoaderError> {
3689        let registry = self
3690            .env
3691            .help_topics
3692            .as_ref()
3693            .ok_or(PluginLoaderError::NotWired("help"))?;
3694
3695        let (id, specs) = self
3696            .host
3697            .spawn_help_plugin(component, manifest, tier, PluginBudget::default())
3698            .await?;
3699
3700        let count = specs.len();
3701        if count > 0 {
3702            registry.rcu(|current| {
3703                let mut next = (**current).clone();
3704                for spec in &specs {
3705                    next.register(lattice_help::topics::HelpTopic {
3706                        name: spec.name.clone(),
3707                        summary: spec.summary.clone(),
3708                        // `Static` needs a `&'static str` and a plugin's body
3709                        // is runtime data, so the owned-`String` variant is
3710                        // the one that fits. `Dynamic` would be the wrong
3711                        // shape twice over: it re-invokes a closure on every
3712                        // open, and the guest it would have to call is gone.
3713                        body: lattice_help::topics::HelpTopicBody::Owned(spec.body.clone()),
3714                        related_command_patterns: spec.related_command_patterns.clone(),
3715                        plugin_id: Some(id.0 as u64),
3716                    });
3717                }
3718                Arc::new(next)
3719            });
3720        }
3721        tracing::debug!(
3722            plugin = %manifest.id,
3723            id = id.0,
3724            topics = count,
3725            "help plugin registered its topics"
3726        );
3727        Ok(id)
3728    }
3729
3730    /// LG.3c — the `language` seam's drain.
3731    ///
3732    /// Drives `register-languages` once, then compiles each declared grammar
3733    /// and registers it, stamped with this plugin's host-issued id. Teardown
3734    /// is by that provenance, so — like `error-parser` and `help` — there is
3735    /// no list to record on `record` and therefore none to forget.
3736    ///
3737    /// **The grammar is compiled HERE, after the guest's store is gone.**
3738    /// `spawn_language_plugin` returns plain bytes; turning them into a
3739    /// `tree_sitter::Language` costs ~100 ms of Cranelift, and doing it inside
3740    /// the guest call would hold a `wasmtime::Store` alive across it for no
3741    /// reason. This runs on the loader's off-boot-thread task, which is the
3742    /// only place that cost is acceptable — it is emphatically not on the
3743    /// keystroke or frame path.
3744    ///
3745    /// **One bad language costs only itself.** A grammar that fails to load or
3746    /// a query that fails to compile is logged with the offending language and
3747    /// reason named, and the plugin's other languages — and its other
3748    /// contributions — still register. A plugin that declared no languages
3749    /// still loads: strange, not broken.
3750    async fn drain_language(
3751        &self,
3752        component: &lattice_plugin_host::Component,
3753        manifest: &PluginManifest,
3754        tier: TrustTier,
3755    ) -> Result<PluginId, PluginLoaderError> {
3756        let (id, specs) = self
3757            .host
3758            .spawn_language_plugin(component, manifest, tier, PluginBudget::default())
3759            .await?;
3760
3761        let mut registered = 0usize;
3762        for spec in &specs {
3763            // Loaded by the GRAMMAR's export name, registered under the
3764            // LANGUAGE's name — they differ whenever a grammar's upstream
3765            // name is not the filetype's (`sequel` vs `sql`).
3766            let grammar =
3767                match lattice_syntax::wasm_grammar::load(&spec.grammar_name, &spec.grammar) {
3768                    Ok(g) => g,
3769                    Err(err) => {
3770                        tracing::warn!(
3771                            plugin = %manifest.id,
3772                            language = %spec.name,
3773                            %err,
3774                            "plugin language rejected: grammar failed to load"
3775                        );
3776                        continue;
3777                    }
3778                };
3779            let grammar_spec = lattice_syntax::GrammarSpec {
3780                grammar,
3781                highlights: spec.highlights.clone(),
3782                folds: spec.folds.clone(),
3783                injections: spec.injections.clone(),
3784                indents: spec.indents.clone(),
3785                textobjects: spec.textobjects.clone(),
3786                conceal_rules: spec.conceal_rules.clone(),
3787            };
3788            let exts: Vec<&str> = spec.extensions.iter().map(String::as_str).collect();
3789            // TK.1: hand over the theme registry so a query capture may
3790            // name a registered element. The `theme` seam drains before
3791            // this one, so a plugin's own elements already exist —
3792            // without that order a capture would resolve to
3793            // `Style::Default` and render unstyled, silently.
3794            match lattice_syntax::plugin_lang::register_with_grammar_themed(
3795                &spec.name,
3796                &exts,
3797                &grammar_spec,
3798                id.0 as u64,
3799                self.env.theme_registry.as_deref(),
3800            ) {
3801                Ok(_) => registered += 1,
3802                Err(err) => tracing::warn!(
3803                    plugin = %manifest.id,
3804                    language = %spec.name,
3805                    %err,
3806                    "plugin language rejected"
3807                ),
3808            }
3809        }
3810
3811        tracing::debug!(
3812            plugin = %manifest.id,
3813            id = id.0,
3814            declared = specs.len(),
3815            registered,
3816            "language plugin registered its languages"
3817        );
3818        Ok(id)
3819    }
3820
3821    /// CR.4 — the `dashboard` seam's drain.
3822    ///
3823    /// Instantiates one live guest per declared section and RCU-registers
3824    /// them, each stamped with this plugin's host-issued id. Teardown is by
3825    /// that provenance; CR.2's shadow stack means removing a plugin section
3826    /// that replaced a builtin resurfaces the builtin with no restore step.
3827    ///
3828    /// Synchronous, unlike every other declaration drain here: the world
3829    /// instantiates against the sync linker because `render-section` runs
3830    /// inside the compositor and must not suspend.
3831    fn drain_dashboard(
3832        &self,
3833        component: &lattice_plugin_host::Component,
3834        manifest: &PluginManifest,
3835        tier: TrustTier,
3836    ) -> Result<PluginId, PluginLoaderError> {
3837        let registry = self
3838            .env
3839            .dashboard_sections
3840            .as_ref()
3841            .ok_or(PluginLoaderError::NotWired("dashboard"))?;
3842
3843        // The Reflex-class budget, not the lifecycle default: `render-section`
3844        // runs on the actor thread during a Display-class action, and the fuel
3845        // cap is what bounds a pathological guest to a bounded stall.
3846        let (id, sections) = self.host.spawn_dashboard_sections(
3847            component,
3848            manifest,
3849            tier,
3850            PluginBudget::grammar(),
3851        )?;
3852
3853        let count = sections.len();
3854        if count > 0 {
3855            let sections: Vec<Arc<dyn lattice_dashboard::DashboardSection>> = sections
3856                .into_iter()
3857                .map(|s| Arc::new(s) as Arc<dyn lattice_dashboard::DashboardSection>)
3858                .collect();
3859            registry.rcu(|current| {
3860                let mut next = (**current).clone();
3861                for section in &sections {
3862                    next.register(section.clone());
3863                }
3864                Arc::new(next)
3865            });
3866        }
3867        tracing::debug!(
3868            plugin = %manifest.id,
3869            id = id.0,
3870            sections = count,
3871            "dashboard plugin registered its sections"
3872        );
3873        Ok(id)
3874    }
3875    /// so there is no `connect` spec round-trip.
3876    async fn drain_context(
3877        &self,
3878        component: &lattice_plugin_host::Component,
3879        manifest: &PluginManifest,
3880        tier: TrustTier,
3881        record: &mut LoadedRecord,
3882    ) -> Result<PluginId, PluginLoaderError> {
3883        let bus = self
3884            .env
3885            .bus
3886            .as_ref()
3887            .ok_or(PluginLoaderError::NotWired("context"))?;
3888        let runtime = self
3889            .env
3890            .runtime
3891            .as_ref()
3892            .ok_or(PluginLoaderError::NotWired("context"))?;
3893        let registry = self
3894            .env
3895            .context_registry
3896            .as_ref()
3897            .ok_or(PluginLoaderError::NotWired("context"))?;
3898
3899        let (client, actor) = self
3900            .host
3901            .spawn_context_source(
3902                component,
3903                manifest,
3904                tier,
3905                PluginBudget::context(),
3906                bus,
3907                self.env.config_registry.as_ref(),
3908            )
3909            .await?;
3910        // PO.2: attach the boundary tracer so the actor emits a trace record per
3911        // guest call (a no-op when unwired).
3912        let actor = actor.with_tracer(self.env.tracer.clone());
3913        let task = runtime.spawn(actor.run());
3914        let source = WasmContextSource::new(client);
3915        let id = source.plugin_id();
3916
3917        // Copy-on-write RCU into the wait-free registry (load -> clone ->
3918        // register -> store), like the decoration seam. Concurrent host
3919        // refreshes keep reading the prior snapshot until the store lands.
3920        let producer: Arc<dyn AsyncContextSource> = Arc::new(source);
3921        registry.rcu(|current| {
3922            let mut next = (**current).clone();
3923            next.register(producer.clone());
3924            Arc::new(next)
3925        });
3926
3927        record.tasks.push(task);
3928        // Teardown token: the context registry unregisters this producer by id.
3929        record.teardown.context_sources.push(id.0 as u64);
3930        tracing::debug!(
3931            plugin = %manifest.id,
3932            id = id.0,
3933            "context plugin registered its context-scope producer"
3934        );
3935        Ok(id)
3936    }
3937
3938    /// IM.6b: drain the `media` seam — spawn the producer actor and register
3939    /// its source. The twin of [`Self::drain_decorations`]; a media provider
3940    /// carries no id/doc metadata either, so there is no `connect` round-trip.
3941    async fn drain_media(
3942        &self,
3943        component: &lattice_plugin_host::Component,
3944        manifest: &PluginManifest,
3945        tier: TrustTier,
3946        record: &mut LoadedRecord,
3947    ) -> Result<PluginId, PluginLoaderError> {
3948        let bus = self
3949            .env
3950            .bus
3951            .as_ref()
3952            .ok_or(PluginLoaderError::NotWired("media"))?;
3953        let runtime = self
3954            .env
3955            .runtime
3956            .as_ref()
3957            .ok_or(PluginLoaderError::NotWired("media"))?;
3958        let registry = self
3959            .env
3960            .media_registry
3961            .as_ref()
3962            .ok_or(PluginLoaderError::NotWired("media"))?;
3963
3964        let (client, actor) = self
3965            .host
3966            .spawn_media_source(component, manifest, tier, PluginBudget::default(), bus)
3967            .await?;
3968        let actor = actor.with_tracer(self.env.tracer.clone());
3969        let task = runtime.spawn(actor.run());
3970        let source = lattice_plugin_host::WasmMediaSource::new(client);
3971        let id = source.plugin_id();
3972
3973        // Copy-on-write RCU into the wait-free registry, like every other
3974        // producer seam: concurrent host refreshes keep reading the prior
3975        // snapshot until the store lands, so there is no lock on the read path.
3976        let producer: Arc<dyn lattice_mode::AsyncMediaSource> = Arc::new(source);
3977        registry.rcu(|current| {
3978            let mut next = (**current).clone();
3979            next.register(producer.clone());
3980            Arc::new(next)
3981        });
3982
3983        record.tasks.push(task);
3984        // Teardown token: the media registry unregisters this producer by id.
3985        record.teardown.media_sources.push(id.0 as u64);
3986        tracing::debug!(
3987            plugin = %manifest.id,
3988            id = id.0,
3989            "media plugin registered its inline-media producer"
3990        );
3991        Ok(id)
3992    }
3993
3994    /// OM.A1: instantiate the plugin's scanned-excerpt-source, resolve the extensions
3995    /// it claims ONCE, and register it as a producer.
3996    ///
3997    /// The `extensions()` round-trip happens here rather than per file for
3998    /// the reason `WasmScannedExcerptSource::extensions` records: the answer cannot
3999    /// change, and a scan already pays one boundary crossing per file.
4000    ///
4001    /// A source claiming NOTHING is registered anyway and logged at `warn`.
4002    /// Refusing the load would fail a plugin over one empty list while its
4003    /// other seams were fine; registering silently would leave a producer
4004    /// that can never contribute — the `NotWired` shape. The log is the
4005    /// middle answer, and it names the plugin.
4006    async fn drain_agenda(
4007        &self,
4008        component: &lattice_plugin_host::Component,
4009        manifest: &PluginManifest,
4010        tier: TrustTier,
4011        record: &mut LoadedRecord,
4012    ) -> Result<PluginId, PluginLoaderError> {
4013        let bus = self
4014            .env
4015            .bus
4016            .as_ref()
4017            .ok_or(PluginLoaderError::NotWired("scanned-excerpt-source"))?;
4018        let runtime = self
4019            .env
4020            .runtime
4021            .as_ref()
4022            .ok_or(PluginLoaderError::NotWired("scanned-excerpt-source"))?;
4023        let registry = self
4024            .env
4025            .agenda_registry
4026            .as_ref()
4027            .ok_or(PluginLoaderError::NotWired("scanned-excerpt-source"))?;
4028
4029        let (client, actor) = self
4030            .host
4031            .spawn_scan_source(
4032                component,
4033                manifest,
4034                tier,
4035                PluginBudget::default(),
4036                bus,
4037                self.env.config_registry.as_ref(),
4038            )
4039            .await?;
4040        let actor = actor.with_tracer(self.env.tracer.clone());
4041        let task = runtime.spawn(actor.run());
4042
4043        // Ask before registering: the producer is immutable once in the
4044        // registry, and a `claims()` that consults a lock per file would put
4045        // contention on the walk for an answer that never changes.
4046        let declared = client.extensions().await.unwrap_or_else(|e| {
4047            tracing::warn!(
4048                plugin = %manifest.id,
4049                error = %e,
4050                "scan source could not declare its file extensions; it will scan nothing"
4051            );
4052            Vec::new()
4053        });
4054        let extensions = lattice_plugin_host::normalise_extensions(declared);
4055        if extensions.is_empty() {
4056            tracing::warn!(
4057                plugin = %manifest.id,
4058                "scan source claims no file extensions; it will never be offered a file"
4059            );
4060        }
4061
4062        // OM.A3: the minor the provider activates on the agenda view, so the
4063        // source can act on its own rows. Resolved here for the same reason
4064        // `extensions` is — the provider reads it on every open, and it
4065        // cannot change. A `none` (or a failed call) is a source that only
4066        // produces rows, which is the ordinary case.
4067        let view_mode = match client.view_mode().await {
4068            Ok(m) => m.filter(|m| !m.trim().is_empty()),
4069            Err(e) => {
4070                tracing::debug!(
4071                    plugin = %manifest.id,
4072                    error = %e,
4073                    "scan source declared no view mode"
4074                );
4075                None
4076            }
4077        };
4078        // OT.3b: persist this source's rows beside the plugin's own data, so a
4079        // restart does not reparse the whole project. A plugin whose id is not
4080        // a safe directory name gets no cache and simply scans as before.
4081        let source =
4082            lattice_plugin_host::WasmScannedExcerptSource::new(client, extensions, view_mode);
4083        let source = match self.host.plugin_data_dir(&manifest.id) {
4084            Some(dir) => source.with_cache(dir.as_path()),
4085            None => source,
4086        };
4087        let id = source.plugin_id();
4088
4089        // Copy-on-write RCU into the wait-free registry, like every other
4090        // producer seam: a scan already running keeps reading the prior
4091        // snapshot until the store lands, so there is no lock on the read
4092        // path.
4093        let producer: Arc<dyn lattice_mode::ScannedExcerptSource> = Arc::new(source);
4094        registry.rcu(|current| {
4095            let mut next = (**current).clone();
4096            next.register(producer.clone());
4097            Arc::new(next)
4098        });
4099
4100        record.tasks.push(task);
4101        record.teardown.agenda_sources.push(id.0 as u64);
4102        tracing::debug!(
4103            plugin = %manifest.id,
4104            id = id.0,
4105            "scan source registered its row producer"
4106        );
4107        Ok(id)
4108    }
4109
4110    /// `PluginLoaderError::Host` and skips only this plugin.
4111    async fn drain_decorations(
4112        &self,
4113        component: &lattice_plugin_host::Component,
4114        manifest: &PluginManifest,
4115        tier: TrustTier,
4116        record: &mut LoadedRecord,
4117    ) -> Result<PluginId, PluginLoaderError> {
4118        let bus = self
4119            .env
4120            .bus
4121            .as_ref()
4122            .ok_or(PluginLoaderError::NotWired("decorations"))?;
4123        let runtime = self
4124            .env
4125            .runtime
4126            .as_ref()
4127            .ok_or(PluginLoaderError::NotWired("decorations"))?;
4128        let registry = self
4129            .env
4130            .decoration_registry
4131            .as_ref()
4132            .ok_or(PluginLoaderError::NotWired("decorations"))?;
4133
4134        let (client, actor) = self
4135            .host
4136            .spawn_decoration_source(component, manifest, tier, PluginBudget::default(), bus)
4137            .await?;
4138        // Drive the producer actor on the multi-thread runtime (off the keystroke
4139        // path). Unlike picker / completion there is no `connect` spec round-trip
4140        // — a decoration source carries no id/doc metadata; it is a pure producer.
4141        // PO.2: attach the boundary tracer so the actor emits a trace record per
4142        // guest call (a no-op when unwired).
4143        let actor = actor.with_tracer(self.env.tracer.clone());
4144        let task = runtime.spawn(actor.run());
4145        // SG.3b: hand the producer the sign registry so a placement naming
4146        // one of the plugin's own signs resolves. The HANDLE, so a plugin
4147        // loading after this one still has its signs seen.
4148        let source = WasmDecorationSource::new(client, self.env.sign_registry.clone());
4149        let id = source.plugin_id();
4150
4151        // Copy-on-write RCU into the wait-free registry (load → clone → register
4152        // → store), like the picker seam. Concurrent host refreshes keep reading
4153        // the prior snapshot until the store lands — no lock on the read path.
4154        let producer: Arc<dyn AsyncGutterDecorationSource> = Arc::new(source);
4155        registry.rcu(|current| {
4156            let mut next = (**current).clone();
4157            next.register(producer.clone());
4158            Arc::new(next)
4159        });
4160
4161        record.tasks.push(task);
4162        // Teardown token: the decoration registry unregisters this producer by id.
4163        record.teardown.decoration_sources.push(id.0 as u64);
4164        tracing::debug!(
4165            plugin = %manifest.id,
4166            id = id.0,
4167            "decoration plugin registered its gutter-decoration producer"
4168        );
4169        Ok(id)
4170    }
4171}
4172
4173/// MV.1: the WIT view result, converted to what `lattice-multibuffer` consumes.
4174///
4175/// A free function rather than a `WitBoundary` impl because the native type
4176/// lives in `lattice-multibuffer`, which knows nothing about WIT — the
4177/// conversion belongs to whoever depends on both, which is the loader.
4178fn convert_view_result(
4179    wit: lattice_plugin_host::multibuffer_view_task::MultibufferViewResult,
4180    grant: &lattice_plugin_host::CapabilityGrant,
4181) -> lattice_multibuffer::providers::plugin_view::PluginViewResult {
4182    let mut denied = 0usize;
4183    let excerpts: Vec<_> = wit
4184        .excerpts
4185        .into_iter()
4186        .filter_map(|e| {
4187            let path = std::path::PathBuf::from(e.path);
4188            // SECURITY: the capability gate for a guest-named read path.
4189            //
4190            // `grant_permits_read` canonicalises the FILE first, so a symlink
4191            // inside a granted tree that points out of it is refused — the
4192            // ordering `a_symlink_out_of_the_grant_is_denied` pins, and the
4193            // reason gating on the parent alone is not enough.
4194            if !lattice_plugin_host::host_services::grant_permits_read(grant, &path) {
4195                denied += 1;
4196                // info!, not debug!: a plugin denied fs access is
4197                // user-actionable and sends the author to their manifest.
4198                tracing::info!(
4199                    path = %path.display(),
4200                    "multibuffer view excerpt denied: outside the plugin's fs grant"
4201                );
4202                return None;
4203            }
4204            Some(lattice_multibuffer::providers::plugin_view::PluginExcerpt {
4205                path,
4206                start_line: e.start_line,
4207                end_line: e.end_line,
4208                header: e.header,
4209                match_count: e.match_count,
4210            })
4211        })
4212        .collect();
4213    let summary = if denied == 0 {
4214        wit.summary
4215    } else {
4216        // Surfaced rather than silently shortened: a view that is quietly
4217        // missing rows because of a manifest is indistinguishable from a view
4218        // whose data is wrong.
4219        format!("{} ({denied} outside the plugin's fs grant)", wit.summary)
4220    };
4221    lattice_multibuffer::providers::plugin_view::PluginViewResult { excerpts, summary }
4222}
4223
4224#[cfg(test)]
4225mod error_chain_tests {
4226    use super::error_chain;
4227
4228    #[derive(Debug)]
4229    struct Layer(&'static str, Option<Box<Layer>>);
4230
4231    impl std::fmt::Display for Layer {
4232        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
4233            f.write_str(self.0)
4234        }
4235    }
4236
4237    impl std::error::Error for Layer {
4238        fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
4239            self.1
4240                .as_deref()
4241                .map(|l| l as &(dyn std::error::Error + 'static))
4242        }
4243    }
4244
4245    /// The whole point: the CAUSE survives. `to_string()` on the outer error
4246    /// yields only "failed to instantiate the plugin component", which names
4247    /// the category and not the fault — the real
4248    /// `no export \`on-wake\` found` sits one link down and used to be dropped
4249    /// on the floor by both the log and the `:plugins` row.
4250    #[test]
4251    fn the_cause_survives_rather_than_only_the_category() {
4252        let err = Layer(
4253            "failed to instantiate the plugin component",
4254            Some(Box::new(Layer("no export `on-wake` found", None))),
4255        );
4256        assert_eq!(
4257            err.to_string(),
4258            "failed to instantiate the plugin component",
4259            "Display alone is the category — this is what was being reported"
4260        );
4261        assert_eq!(
4262            error_chain(&err),
4263            "failed to instantiate the plugin component: no export `on-wake` found"
4264        );
4265    }
4266
4267    #[test]
4268    fn an_error_with_no_cause_is_unchanged() {
4269        assert_eq!(error_chain(&Layer("just this", None)), "just this");
4270    }
4271
4272    /// A runaway or cyclic `source()` must not hang the loader — this runs on
4273    /// the boot path, where a hang is indistinguishable from a dead editor.
4274    #[test]
4275    fn a_very_long_chain_is_bounded_rather_than_walked_forever() {
4276        let mut err = Layer("innermost", None);
4277        for i in 0..40 {
4278            err = Layer(
4279                match i % 2 {
4280                    0 => "wrap-a",
4281                    _ => "wrap-b",
4282                },
4283                Some(Box::new(err)),
4284            );
4285        }
4286        let out = error_chain(&err);
4287        assert!(
4288            out.ends_with(": …"),
4289            "truncation is marked, not silent: {out}"
4290        );
4291        assert_eq!(
4292            out.matches(": ").count(),
4293            9,
4294            "eight causes plus the ellipsis: {out}"
4295        );
4296    }
4297}
4298
4299/// `update`'s arm table, tested without a loaded plugin behind it — the
4300/// refusal is a property of the source alone.
4301#[cfg(test)]
4302mod update_refusal_tests {
4303    use super::{resolve, update_refusal};
4304
4305    /// The only arm that refuses. A pin is the answer to "which commit", so
4306    /// there is nothing to update TO, and moving past it would discard what
4307    /// the user wrote in `init.rs`.
4308    #[test]
4309    fn update_declines_on_a_pinned_git_source_and_names_the_pin() {
4310        let reason = update_refusal(
4311            "demo",
4312            Some(&resolve::PluginSource::Git {
4313                url: "https://example.invalid/p.git".into(),
4314                rev: Some("abc123".into()),
4315            }),
4316        )
4317        .expect("a pinned source refuses");
4318        assert!(
4319            reason.contains("abc123"),
4320            "the refusal names the pin, so the user knows why nothing moved: {reason}"
4321        );
4322    }
4323
4324    /// The three that proceed, for the three different reasons they do:
4325    /// unpinned git is the case the verb exists for, a local directory IS the
4326    /// source, and a prebuilt artifact is re-downloaded by the resolver on
4327    /// every pass.
4328    #[test]
4329    fn update_proceeds_on_every_other_source() {
4330        for source in [
4331            resolve::PluginSource::Git {
4332                url: "https://example.invalid/p.git".into(),
4333                rev: None,
4334            },
4335            resolve::PluginSource::Local(std::path::PathBuf::from("/tmp/demo")),
4336            resolve::PluginSource::Prebuilt {
4337                url: "https://example.invalid/demo.wasm".into(),
4338            },
4339        ] {
4340            assert_eq!(
4341                update_refusal("demo", Some(&source)),
4342                None,
4343                "{source:?} has somewhere to update from"
4344            );
4345        }
4346    }
4347
4348    /// A record with no recorded source falls through to `rebuild_with`, which
4349    /// owns the "no buildable source" message — two functions must not both
4350    /// answer the same question differently.
4351    #[test]
4352    fn update_leaves_a_sourceless_record_to_the_rebuild_path() {
4353        assert_eq!(update_refusal("demo", None), None);
4354    }
4355}
4356
4357/// [`BulkReport`]'s arithmetic and the sentence it produces. Pure over the
4358/// legs, so the counting is pinned without running a build behind it.
4359#[cfg(test)]
4360mod bulk_report_tests {
4361    use super::{BulkLeg, BulkReport};
4362
4363    fn report(legs: &[(&str, BulkLeg)]) -> BulkReport {
4364        BulkReport {
4365            legs: legs
4366                .iter()
4367                .map(|(n, l)| ((*n).to_string(), l.clone()))
4368                .collect(),
4369        }
4370    }
4371
4372    /// The common run says one number. Zeroes are noise that makes the count
4373    /// that matters harder to find.
4374    #[test]
4375    fn an_all_succeeded_run_reports_only_the_one_count() {
4376        let r = report(&[("a", BulkLeg::Done), ("b", BulkLeg::Done)]);
4377        assert_eq!(r.summary("updated"), "2 updated");
4378    }
4379
4380    /// Skipped is reported and is NOT failed — the distinction is the reason
4381    /// `BulkLeg` is an enum rather than a `Result`. `4 updated, 2 pinned`
4382    /// reads as success; `4 updated, 2 failed` sends someone hunting.
4383    #[test]
4384    fn skipped_is_counted_apart_from_failed() {
4385        let r = report(&[
4386            ("a", BulkLeg::Done),
4387            ("b", BulkLeg::Skipped("pinned to abc123".into())),
4388            ("c", BulkLeg::Failed("build broke".into())),
4389        ]);
4390        assert_eq!(r.done(), 1);
4391        assert_eq!(r.skipped(), 1);
4392        assert_eq!(r.failed(), 1);
4393        assert_eq!(r.summary("updated"), "1 updated, 1 skipped, 1 failed");
4394    }
4395
4396    /// Only failures are worth a `warn!` line each, and each must name its
4397    /// plugin — a summary saying `2 failed` with no names is not actionable.
4398    #[test]
4399    fn failures_name_the_plugin_and_the_reason() {
4400        let r = report(&[
4401            ("a", BulkLeg::Done),
4402            ("b", BulkLeg::Failed("build broke".into())),
4403            ("c", BulkLeg::Skipped("bundled".into())),
4404        ]);
4405        assert_eq!(r.failures(), vec![("b", "build broke")]);
4406    }
4407
4408    /// An editor with no plugins says so rather than `0 updated`, which reads
4409    /// like something went wrong.
4410    #[test]
4411    fn an_empty_run_says_there_was_nothing_to_do() {
4412        assert_eq!(
4413            BulkReport::default().summary("updated"),
4414            "no plugins loaded"
4415        );
4416    }
4417}
4418
4419/// `clean`'s safety rules, against a real directory tree.
4420///
4421/// Every test here is a clause that, if dropped, deletes something the user
4422/// wanted — which is why they are tested one clause at a time rather than as
4423/// one "it works" case.
4424#[cfg(test)]
4425mod removable_tests {
4426    #![allow(clippy::unwrap_used)]
4427    use super::removable_under;
4428    use std::collections::HashSet;
4429    use std::path::{Path, PathBuf};
4430    use std::sync::atomic::{AtomicUsize, Ordering};
4431
4432    static COUNTER: AtomicUsize = AtomicUsize::new(0);
4433
4434    fn tempdir(tag: &str) -> PathBuf {
4435        // A counter as well as the pid: a timestamp alone collides under a
4436        // parallel `cargo test`.
4437        let n = COUNTER.fetch_add(1, Ordering::SeqCst);
4438        let dir =
4439            std::env::temp_dir().join(format!("lattice-clean-{tag}-{}-{n}", std::process::id()));
4440        let _ = std::fs::remove_dir_all(&dir);
4441        std::fs::create_dir_all(&dir).unwrap();
4442        dir
4443    }
4444
4445    /// A staged plugin directory: a marker, and something to delete.
4446    fn staged(root: &Path, name: &str, marker: bool) {
4447        let dir = root.join(name);
4448        std::fs::create_dir_all(&dir).unwrap();
4449        std::fs::write(dir.join(format!("{name}.wasm")), b"\0asm").unwrap();
4450        if marker {
4451            std::fs::write(dir.join(".source"), "kind = local\npath = /tmp/x\n").unwrap();
4452        }
4453    }
4454
4455    fn keep(names: &[&str]) -> HashSet<String> {
4456        names.iter().map(|n| (*n).to_string()).collect()
4457    }
4458
4459    #[test]
4460    fn an_unclaimed_staged_directory_with_provenance_is_removable() {
4461        let root = tempdir("basic");
4462        staged(&root, "ghost", true);
4463        let got = removable_under(&root, &keep(&[]));
4464        assert_eq!(got.len(), 1);
4465        assert_eq!(got[0].0, "ghost");
4466    }
4467
4468    /// Clause 1 + 2. A plugin that FAILED to load is still one the user asked
4469    /// for: cleaning it would turn "my plugin is broken" into "my plugin is
4470    /// gone", and take the error message in the view with it.
4471    #[test]
4472    fn a_loaded_or_failed_plugin_is_never_removable() {
4473        let root = tempdir("claimed");
4474        staged(&root, "loaded", true);
4475        staged(&root, "failed", true);
4476        staged(&root, "ghost", true);
4477        let got = removable_under(&root, &keep(&["loaded", "failed"]));
4478        assert_eq!(
4479            got.iter().map(|(n, _)| n.as_str()).collect::<Vec<_>>(),
4480            vec!["ghost"]
4481        );
4482    }
4483
4484    /// Clause 3. `init` is the user's own configuration, not a plugin, and it
4485    /// never appears in the loaded set under that name — so without naming it
4486    /// explicitly, clean would delete the user's config.
4487    #[test]
4488    fn the_users_init_directory_is_never_removable() {
4489        let root = tempdir("init");
4490        staged(&root, "init", true);
4491        assert!(removable_under(&root, &keep(&["init"])).is_empty());
4492    }
4493
4494    /// Clause 4. Provenance is what makes removal recoverable. A hand-staged
4495    /// directory has no `.source`, and deleting it destroys the only copy.
4496    #[test]
4497    fn a_directory_without_provenance_is_left_alone() {
4498        let root = tempdir("nomarker");
4499        staged(&root, "handmade", false);
4500        assert!(
4501            removable_under(&root, &keep(&[])).is_empty(),
4502            "no `.source` marker ⇒ nothing to re-install from ⇒ never delete"
4503        );
4504    }
4505
4506    /// Files beside the plugin directories are not plugins and are not
4507    /// candidates — only directories are.
4508    #[test]
4509    fn a_stray_file_in_the_root_is_not_a_candidate() {
4510        let root = tempdir("stray");
4511        std::fs::write(root.join("notes.txt"), "hello").unwrap();
4512        assert!(removable_under(&root, &keep(&[])).is_empty());
4513    }
4514
4515    /// A missing plugins root is an empty answer, not an error: a user who has
4516    /// never installed a plugin has no directory, and `:plugin-clean` there
4517    /// should say "nothing to clean".
4518    #[test]
4519    fn a_missing_root_is_empty_rather_than_an_error() {
4520        let root = tempdir("gone");
4521        let missing = root.join("nope");
4522        assert!(removable_under(&missing, &keep(&[])).is_empty());
4523    }
4524
4525    /// The removal itself: what was listed goes, and the directory is really
4526    /// gone rather than emptied.
4527    #[test]
4528    fn cleaning_a_listed_name_removes_its_directory() {
4529        let root = tempdir("remove");
4530        staged(&root, "ghost", true);
4531        let listed = removable_under(&root, &keep(&[]));
4532        let report = super::clean_listed(&listed, &["ghost".to_string()]);
4533        assert_eq!(report.done(), 1);
4534        assert!(!root.join("ghost").exists(), "the directory is gone");
4535    }
4536
4537    /// The re-check. A confirmation left sitting while a plugin loaded must
4538    /// not delete the plugin that just arrived — so a name that has stopped
4539    /// being removable is skipped, and its files are still there afterwards.
4540    #[test]
4541    fn a_name_that_stopped_being_removable_is_skipped_not_deleted() {
4542        let root = tempdir("recheck");
4543        staged(&root, "arrived", true);
4544        // Listed when nothing claimed it...
4545        let listed = removable_under(&root, &keep(&[]));
4546        assert_eq!(listed.len(), 1);
4547        // ...but by the time the user confirms, it has loaded.
4548        let now = removable_under(&root, &keep(&["arrived"]));
4549        let report = super::clean_listed(&now, &["arrived".to_string()]);
4550        assert_eq!(report.skipped(), 1, "skipped, not failed");
4551        assert_eq!(report.done(), 0);
4552        assert!(
4553            root.join("arrived").is_dir(),
4554            "the plugin that just loaded still has its files"
4555        );
4556    }
4557}