Skip to main content

rpi_extensions/
registry.rs

1//! The extension registry — accumulates registrations during a plugin's
2//! `rpi_plugin_register` call, then snapshots for the host session.
3//!
4//! `HostApi` (in [`crate`]) holds an `ExtensionRegistry` behind a `Mutex` while
5//! the plugin's register trampolines run; after register completes the host
6//! [`take`]s it and builds a [`RegistrySnapshot`] the session keeps for its
7//! lifetime. Staleness across a session swap / `/reload` is guarded by a shared
8//! `Arc<AtomicBool>` "active" flag ([`assert_active`]).
9//!
10//! [`take`]: crate::HostApi::take_registry
11
12use std::sync::atomic::{AtomicBool, Ordering};
13use std::sync::Arc;
14
15use rpi_agent::error::AgentError;
16use rpi_ai::types::Tool;
17use rpi_plugin_sdk::{
18    BeforeAgentStartFn, CommandHandlerFn, EventHandlerFn, EventTag, FreeStringFn, OAuthRequestFn,
19    ProviderRequestFn, RenderFn, ResourcesDiscoverFn, EVENT_TAG_COUNT,
20};
21
22use crate::tool::PluginToolHandle;
23
24// ---------------------------------------------------------------------------
25// Registered-item records
26// ---------------------------------------------------------------------------
27
28/// A tool an extension registered: the provider-facing [`Tool`] schema + the
29/// plugin's 4-function handle ([`PluginToolHandle`]) the host drives via
30/// [`PluginToolAdapter`](crate::PluginToolAdapter).
31///
32/// `tool` is public so the host can merge schemas; `handle` is crate-private
33/// (only [`PluginToolAdapter`](crate::PluginToolAdapter) drives it). `Clone`
34/// derives because [`Tool`] is `Clone` and [`PluginToolHandle`] is `Copy`.
35#[derive(Clone)]
36pub struct ExtensionTool {
37    /// The provider-facing tool definition (name/description/parameters).
38    pub tool: Tool,
39    pub(crate) handle: PluginToolHandle,
40}
41
42impl ExtensionTool {
43    pub fn new(tool: Tool, handle: PluginToolHandle) -> Self {
44        Self { tool, handle }
45    }
46
47    /// The plugin-side function handles backing this tool. `Copy` (fn pointers
48    /// + a `FreeStringFn`), so handing it out is free. Public so the host
49    /// (e.g. rpi-cli's session builder) can wrap a registered tool in a
50    /// [`PluginToolAdapter`](crate::PluginToolAdapter) outside this crate.
51    pub fn handle(&self) -> PluginToolHandle {
52        self.handle
53    }
54}
55
56/// A registered slash command and its plugin callback.
57#[derive(Clone)]
58pub struct RegisteredCommand {
59    pub name: String,
60    pub description: String,
61    pub handler: CommandHandlerFn,
62    pub user_data: *mut std::ffi::c_void,
63}
64
65/// A keyboard shortcut declared by a native extension (e.g. `"space"` for
66/// push-to-talk). The host records it so the TUI can route keys to it; the
67/// owning plugin receives the key via its `Input` event handler.
68///
69/// `key` is a normalized key name (`"space"`, `"enter"`, `"c"`, `"f1"`)
70/// matching the editor's keybinding vocabulary.
71#[derive(Clone, Debug, PartialEq, Eq)]
72pub struct RegisteredShortcut {
73    pub key: String,
74    pub description: String,
75    /// The owning extension's display name (host-side only, for diagnostics).
76    pub plugin: String,
77}
78
79// SAFETY: the plugin owns the callback/context and promises they remain valid
80// for the loaded library lifetime, matching the other registered callbacks.
81unsafe impl Send for RegisteredCommand {}
82unsafe impl Sync for RegisteredCommand {}
83
84/// A CLI flag declared by a native extension. Values are supplied by the
85/// host's parsed `Args::unknown_flags` map and read by the plugin through the
86/// `RuntimeActionId::GetCliFlag` action.
87#[derive(Clone, Debug, PartialEq, Eq)]
88pub struct RegisteredFlag {
89    pub name: String,
90    pub description: String,
91}
92
93/// A registered `on(tag)` event handler. `user_data` is the plugin's opaque
94/// context, passed back unchanged on every dispatch. `plugin` is the display
95/// name of the owning extension (host-side only — stamped from the cdylib path
96/// at registration; not part of the ABI), used for lifecycle veto diagnostics
97/// ("extension `foo` aborted `before_tui_start`").
98///
99/// `priority`/`platforms` are the owning plugin's **declaration** (P2): the
100/// snapshot sorts handlers by `priority` (smaller first, stable), and dispatch
101/// skips a handler whose non-empty `platforms` excludes the host platform.
102/// Both default to `100` / empty (all platforms); a plugin sets them via the
103/// unified C ABI `declare` callback.
104///
105/// SAFETY contract: the plugin guarantees `handler` is safe to call from any
106/// thread (the host dispatches from the async emitter thread) and `user_data`
107/// is valid for the registry's lifetime. The host never frees `user_data`
108/// (plugin-owned).
109#[derive(Clone)]
110pub struct RegisteredHandler {
111    /// Display name of the owning extension (from the cdylib file stem).
112    pub plugin: String,
113    /// Dispatch order (smaller runs first). Default `100`.
114    pub priority: i32,
115    /// Platforms this handler's plugin supports (empty = all). Matched against
116    /// [`current_platform`].
117    pub platforms: Vec<String>,
118    pub handler: EventHandlerFn,
119    pub user_data: *mut std::ffi::c_void,
120}
121// SAFETY: fn pointers are Send+Sync; `user_data` is an opaque plugin pointer the
122// plugin warrants is thread-safe to pass to `handler` from any thread. The host
123// only reads it through `handler`.
124unsafe impl Send for RegisteredHandler {}
125unsafe impl Sync for RegisteredHandler {}
126
127/// A registered `resources_discover` handler (B5b). The `out` [`StbString`] the
128/// handler produces is **plugin-owned**, so the host reclaims it via the
129/// plugin's own `plugin_free_string` traveled alongside. `user_data` is the
130/// plugin's opaque context. SAFETY: same as [`RegisteredHandler`] — the plugin
131/// warrants `handler` is callable from any thread and `user_data` is valid for
132/// the registry's lifetime; the host never frees `user_data`.
133#[derive(Clone, Copy)]
134pub struct ResourcesDiscoverHandler {
135    pub handler: ResourcesDiscoverFn,
136    pub plugin_free_string: FreeStringFn,
137    pub user_data: *mut std::ffi::c_void,
138}
139// SAFETY: fn pointers + an opaque plugin pointer the plugin warrants is
140// thread-safe; the host only reads `user_data` through `handler`.
141unsafe impl Send for ResourcesDiscoverHandler {}
142unsafe impl Sync for ResourcesDiscoverHandler {}
143
144/// A registered `before_agent_start` handler (system-prompt transform). Same
145/// ownership shape as [`ResourcesDiscoverHandler`]: the handler produces a
146/// **plugin-owned** `out` [`StbString`] the host reclaims via the plugin's own
147/// `plugin_free_string`, which therefore travels alongside. `user_data` is the
148/// plugin's opaque context.
149///
150/// SAFETY: same as [`RegisteredHandler`] — the plugin warrants `handler` is
151/// callable from any thread and `user_data` is valid for the registry's
152/// lifetime; the host never frees `user_data`.
153#[derive(Clone, Copy)]
154pub struct BeforeAgentStartHandler {
155    pub handler: BeforeAgentStartFn,
156    pub plugin_free_string: FreeStringFn,
157    pub user_data: *mut std::ffi::c_void,
158}
159// SAFETY: fn pointers + an opaque plugin pointer the plugin warrants is
160// thread-safe; the host only reads `user_data` through `handler`.
161unsafe impl Send for BeforeAgentStartHandler {}
162unsafe impl Sync for BeforeAgentStartHandler {}
163
164/// A registered custom provider (B5c). The host wraps `request_fn` in a
165/// [`PluggableProvider`](crate::PluggableProvider) impl of `rpi_ai::Provider`;
166/// its `stream_simple` drives `request_fn` on `spawn_blocking` (the sync fn
167/// can't own a chunked stream), reads the **plugin-owned** `out` JSON (a full
168/// assistant message), reclaims it via `plugin_free_string`, parses it to an
169/// [`AssistantMessage`](rpi_ai::types::AssistantMessage), and emits it as one
170/// terminal `Done` chunk (v1 one-shot, documented divergence from pi's async
171/// streaming). `provider_id`/`base_url`/`api_style` carry the provider's
172/// identity (copied from the borrowed `StbStringRef`s at registration); the fn
173/// pointers + `user_data` live as long as the plugin (keepalive-mapped).
174/// `user_data` is the plugin's opaque context, passed back on every
175/// `request_fn` call.
176///
177/// SAFETY: the plugin warrants `request_fn` is callable from any thread (the
178/// host calls it from a `spawn_blocking` pool thread) and `user_data` is valid
179/// for the plugin's lifetime; the host never frees `user_data`.
180#[derive(Clone)]
181pub struct RegisteredProvider {
182    pub provider_id: String,
183    pub base_url: String,
184    pub api_style: String,
185    pub request_fn: ProviderRequestFn,
186    pub plugin_free_string: FreeStringFn,
187    pub user_data: *mut std::ffi::c_void,
188}
189
190/// A provider-owned OAuth implementation. `manifest_json` is validated JSON
191/// copied from the plugin; the callback receives action envelopes such as
192/// `{\"action\":\"begin\"}` and returns plugin-owned JSON.
193#[derive(Clone)]
194pub struct RegisteredOAuthProvider {
195    /// The owning extension display name, used to scope credentials and
196    /// diagnostics. This is host-owned metadata and is not supplied by the
197    /// plugin.
198    pub plugin: String,
199    pub manifest_json: String,
200    pub request_fn: OAuthRequestFn,
201    pub plugin_free_string: FreeStringFn,
202    pub user_data: *mut std::ffi::c_void,
203}
204unsafe impl Send for RegisteredOAuthProvider {}
205unsafe impl Sync for RegisteredOAuthProvider {}
206// SAFETY: fn pointers + owned `String`s + an opaque plugin pointer the plugin
207// warrants is thread-safe; the host never frees `user_data`.
208unsafe impl Send for RegisteredProvider {}
209unsafe impl Sync for RegisteredProvider {}
210
211/// A registered message/markdown/entry renderer (B5c). The interactive TUI
212/// consumes all three kinds through the JSON component adapter. `render_fn` produces a
213/// **plugin-owned** `out` [`StbString`] the host reclaims via
214/// `plugin_free_string`; `user_data` is passed back on every render call. `name`
215/// is copied from the borrowed `StbStringRef` at registration.
216///
217/// SAFETY: same as [`RegisteredProvider`].
218#[derive(Clone)]
219pub struct RegisteredRenderer {
220    pub name: String,
221    pub kind: RegisteredRendererKind,
222    pub render_fn: RenderFn,
223    pub plugin_free_string: FreeStringFn,
224    pub user_data: *mut std::ffi::c_void,
225}
226
227/// Which render path this renderer targets — mirrors the three distinct
228/// `register_*` slots (`register_message_renderer` /
229/// `register_markdown_transformer` / `register_entry_renderer`).
230#[derive(Clone, Copy, PartialEq, Eq)]
231pub enum RegisteredRendererKind {
232    Message,
233    Markdown,
234    Entry,
235}
236// SAFETY: same as [`RegisteredProvider`] — fn pointers + owned `String` + an
237// opaque plugin pointer the plugin warrants is thread-safe.
238unsafe impl Send for RegisteredRenderer {}
239unsafe impl Sync for RegisteredRenderer {}
240
241/// One flat registration record, for iteration/diagnostics. Built on demand
242/// from the typed vecs in [`RegistrySnapshot`].
243#[derive(Clone)]
244pub enum RegistryEntry {
245    Tool(ExtensionTool),
246    Command(RegisteredCommand),
247    Flag(RegisteredFlag),
248}
249
250// ---------------------------------------------------------------------------
251// P2 — plugin declarations (priority + platforms)
252// ---------------------------------------------------------------------------
253
254/// Default dispatch priority for a plugin that declares none. Handlers run
255/// smallest-priority-first; ties break by registration order.
256pub const DEFAULT_PRIORITY: i32 = 100;
257
258/// The host platform, as matched against a plugin's declared `platforms`.
259/// Platform set used by extension capability declarations.
260pub fn current_platform() -> &'static str {
261    if cfg!(target_os = "android") {
262        "android"
263    } else if cfg!(target_os = "linux") {
264        // Termux reports `target_os = "android"` above; a plain Linux is here.
265        "linux"
266    } else if cfg!(target_os = "macos") {
267        "macos"
268    } else if cfg!(target_os = "windows") {
269        "windows"
270    } else {
271        "unknown"
272    }
273}
274
275/// Whether a handler with the given declared `platforms` should run on this
276/// host. Empty `platforms` = all platforms; a non-empty list requires the host
277/// platform to be present (`"all"` is accepted as a wildcard).
278pub fn platform_allows(platforms: &[String]) -> bool {
279    if platforms.is_empty() {
280        return true;
281    }
282    let here = current_platform();
283    platforms
284        .iter()
285        .any(|p| p.eq_ignore_ascii_case("all") || p.eq_ignore_ascii_case(here))
286}
287
288// ---------------------------------------------------------------------------
289// ExtensionRegistry — accumulated during register, then snapshotted
290// ---------------------------------------------------------------------------
291
292/// Accumulates registrations from one or more plugins' `rpi_plugin_register`
293/// calls. Held by [`HostApi`](crate::HostApi) during register; the host then
294/// [`take_registry`](crate::HostApi::take_registry) and builds a snapshot.
295///
296/// Merge semantics mirror pi: **first-registration-wins** on tool/command name
297/// collision (a later registration for an existing name is dropped, not an
298/// overwrite). Event handlers accumulate (multiple per tag — fan-out).
299pub struct ExtensionRegistry {
300    tools: Vec<ExtensionTool>,
301    commands: Vec<RegisteredCommand>,
302    flags: Vec<RegisteredFlag>,
303    /// Keyboard shortcuts declared by plugins (first-wins by `key`). Recorded
304    /// so the TUI can route a key press/release to the owning plugin's `Input`
305    /// event handler instead of consuming it as editor text.
306    shortcuts: Vec<RegisteredShortcut>,
307    /// `handlers[tag as usize]` — all handlers subscribed to that tag.
308    handlers: [Vec<RegisteredHandler>; EVENT_TAG_COUNT],
309    /// `resources_discover` handlers (B5b). Fan-out on discovery, in registration
310    /// order. Unlike event handlers (which key off a tag in a fixed array), these
311    /// are a single flat list — `resources_discover` has its own out-param
312    /// signature and is never dispatched through the fire-and-forget event path.
313    resources_discover: Vec<ResourcesDiscoverHandler>,
314    /// `before_agent_start` system-prompt transformers. A flat list for the same
315    /// reason as `resources_discover`: this is an out-param handler (the plugin
316    /// returns the prompt to install), not a fire-and-forget event subscription,
317    /// so it is not indexed by a tag. Chained in registration order.
318    before_agent_start: Vec<BeforeAgentStartHandler>,
319    /// Registered custom providers (B5c). The host wraps each in a
320    /// [`PluggableProvider`](crate::PluggableProvider). First-registration-wins on
321    /// `provider_id`.
322    providers: Vec<RegisteredProvider>,
323    /// Provider-owned OAuth implementations. First-registration-wins on the
324    /// manifest `id` field.
325    oauth_providers: Vec<RegisteredOAuthProvider>,
326    /// Registered renderers (B5c), split by kind at registration. First-wins on
327    /// `(kind, name)`. The host records these now; TUI consumption is B5e.
328    renderers: Vec<RegisteredRenderer>,
329    /// P2: the registering plugin's declared dispatch priority. Stamped onto
330    /// every handler it registers; a later `declare` also retro-updates them.
331    priority: i32,
332    /// P2: the registering plugin's declared platforms (empty = all).
333    platforms: Vec<String>,
334    /// A plugin's `{"ownsStdio":true}` claim: this host process's stdin/stdout
335    /// belong to the plugin (an ACP/MCP-style stdio server), so the host must
336    /// defer `read_piped_stdin` until after extension load and run the headless
337    /// `Rpc` lifecycle instead of Print/TUI. Monotonic across `absorb` (any
338    /// claiming plugin projects `true`). The scoped ActionBridge arbitrates
339    /// exclusivity before registries are merged. Default `false`.
340    owns_stdio: bool,
341    /// Shared staleness flag. `true` while the session owning this registry is
342    /// active; set `false` on swap/`/reload`. Tool/event dispatch checks it.
343    active: Arc<AtomicBool>,
344}
345
346impl Default for ExtensionRegistry {
347    fn default() -> Self {
348        Self::new()
349    }
350}
351
352impl ExtensionRegistry {
353    /// Build an empty, active registry.
354    pub fn new() -> Self {
355        let handlers: [Vec<RegisteredHandler>; EVENT_TAG_COUNT] =
356            std::array::from_fn(|_| Vec::new());
357        Self {
358            tools: Vec::new(),
359            commands: Vec::new(),
360            flags: Vec::new(),
361            shortcuts: Vec::new(),
362            handlers,
363            resources_discover: Vec::new(),
364            before_agent_start: Vec::new(),
365            providers: Vec::new(),
366            oauth_providers: Vec::new(),
367            renderers: Vec::new(),
368            priority: DEFAULT_PRIORITY,
369            platforms: Vec::new(),
370            owns_stdio: false,
371            active: Arc::new(AtomicBool::new(true)),
372        }
373    }
374
375    /// Register a tool. Returns `true` if a **prior** tool of the same name was
376    /// kept (first-wins: the new one is dropped). Returns `false` on insert.
377    pub fn register_tool(&mut self, tool: Tool, handle: PluginToolHandle) -> bool {
378        if self.tools.iter().any(|t| t.tool.name == tool.name) {
379            // First-wins: keep the prior, drop the new handle. We must destroy
380            // the dropped handle's allocation? No — `handle` is just fn pointers
381            // + a FreeStringFn (all Copy, no allocation). Dropping it is a no-op.
382            // A real per-tool plugin allocation only exists after `execute`
383            // produces a StepHandle; we never call execute for a dropped
384            // registration, so nothing to free.
385            return true;
386        }
387        self.tools.push(ExtensionTool::new(tool, handle));
388        false
389    }
390
391    /// Register a slash command (first-wins by name). `true` if a prior was kept.
392    pub fn register_command(
393        &mut self,
394        name: String,
395        description: String,
396        handler: CommandHandlerFn,
397        user_data: *mut std::ffi::c_void,
398    ) -> bool {
399        if self.commands.iter().any(|c| c.name == name) {
400            return true;
401        }
402        self.commands.push(RegisteredCommand {
403            name,
404            description,
405            handler,
406            user_data,
407        });
408        false
409    }
410
411    /// Register a CLI flag (first-wins by name). Returns `true` when an
412    /// earlier extension already declared the same flag.
413    pub fn register_flag(&mut self, name: String, description: String) -> bool {
414        if self.flags.iter().any(|flag| flag.name == name) {
415            return true;
416        }
417        self.flags.push(RegisteredFlag { name, description });
418        false
419    }
420
421    /// Declare a keyboard shortcut (first-wins by `key`). Returns `true` when an
422    /// earlier extension already claimed the same key.
423    ///
424    /// Recording a shortcut does **not** bind the key to a callback: the owning
425    /// plugin receives presses/releases through its `Input` event handler and
426    /// decides what to do. The host uses this list only to know which keys are
427    /// claimed, so it can route them to extensions instead of the editor.
428    pub fn register_shortcut(&mut self, plugin: String, key: String, description: String) -> bool {
429        if self.shortcuts.iter().any(|s| s.key == key) {
430            return true;
431        }
432        self.shortcuts.push(RegisteredShortcut {
433            key,
434            description,
435            plugin,
436        });
437        false
438    }
439
440    /// Subscribe a handler to `tag`. Multiple handlers per tag are kept
441    /// (fan-out on dispatch). Always inserts; returns `false`. `plugin` is the
442    /// owning extension's display name (host-side only, for diagnostics).
443    pub fn register_event_handler(
444        &mut self,
445        plugin: String,
446        tag: EventTag,
447        handler: EventHandlerFn,
448        user_data: *mut std::ffi::c_void,
449    ) -> bool {
450        let idx = tag as usize;
451        if idx < EVENT_TAG_COUNT {
452            // Stamp the registering plugin's current declaration (P2). A plugin
453            // typically declares BEFORE registering handlers; `apply_declaration`
454            // also retro-updates any handler registered before the declare call.
455            self.handlers[idx].push(RegisteredHandler {
456                plugin,
457                priority: self.priority,
458                platforms: self.platforms.clone(),
459                handler,
460                user_data,
461            });
462        }
463        false
464    }
465
466    /// Apply a plugin's declaration parsed from the unified C ABI `declare` JSON
467    /// (`{"priority":60,"platforms":["linux","macos"]}`). Unknown keys are
468    /// ignored; malformed values leave the corresponding field unchanged.
469    /// Retro-updates any handlers already registered (so declare order doesn't
470    /// matter). Returns the number of fields applied.
471    pub fn apply_declaration(
472        &mut self,
473        priority: Option<i32>,
474        platforms: Option<Vec<String>>,
475    ) -> usize {
476        let mut applied = 0;
477        if let Some(priority) = priority {
478            self.priority = priority;
479            for tag_handlers in self.handlers.iter_mut() {
480                for h in tag_handlers.iter_mut() {
481                    h.priority = priority;
482                }
483            }
484            applied += 1;
485        }
486        if let Some(platforms) = platforms {
487            self.platforms = platforms.clone();
488            for tag_handlers in self.handlers.iter_mut() {
489                for h in tag_handlers.iter_mut() {
490                    h.platforms = platforms.clone();
491                }
492            }
493            applied += 1;
494        }
495        applied
496    }
497
498    /// Parse a v3 `declare` JSON payload and apply it (P2). Recognised keys:
499    /// `priority` (integer), `platforms` (array of strings) and `ownsStdio`
500    /// (boolean; see [`ExtensionRegistry::declared_owns_stdio`]). Returns `true`
501    /// if the payload parsed as JSON (even with no recognised keys), `false` on
502    /// a parse error.
503    pub fn apply_declaration_json(&mut self, json: &str) -> bool {
504        let Ok(value) = serde_json::from_str::<serde_json::Value>(json) else {
505            return false;
506        };
507        let priority = value
508            .get("priority")
509            .and_then(|v| v.as_i64())
510            .map(|v| v as i32);
511        let platforms = value
512            .get("platforms")
513            .and_then(|v| v.as_array())
514            .map(|arr| {
515                arr.iter()
516                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
517                    .collect::<Vec<String>>()
518            });
519        if let Some(owns) = value.get("ownsStdio").and_then(|v| v.as_bool()) {
520            // A claim, not a toggle: once a plugin owns stdio it stays claimed
521            // (and `absorb` ORs it across plugins).
522            self.owns_stdio = self.owns_stdio || owns;
523        }
524        self.apply_declaration(priority, platforms);
525        true
526    }
527
528    /// Register a `resources_discover` handler (B5b). Multiple handlers are kept
529    /// (fan-out on discovery, registration order). Always inserts; returns `false`.
530    pub fn register_resources_discover(
531        &mut self,
532        handler: ResourcesDiscoverFn,
533        plugin_free_string: FreeStringFn,
534        user_data: *mut std::ffi::c_void,
535    ) -> bool {
536        self.resources_discover.push(ResourcesDiscoverHandler {
537            handler,
538            plugin_free_string,
539            user_data,
540        });
541        false
542    }
543
544    /// Register a `before_agent_start` system-prompt transformer. Multiple
545    /// handlers are kept (chained in registration order: each sees the prompt the
546    /// previous one returned, mirroring pi `runner.ts:1329-1347`). Always
547    /// inserts; returns `false`.
548    pub fn register_before_agent_start(
549        &mut self,
550        handler: BeforeAgentStartFn,
551        plugin_free_string: FreeStringFn,
552        user_data: *mut std::ffi::c_void,
553    ) -> bool {
554        self.before_agent_start.push(BeforeAgentStartHandler {
555            handler,
556            plugin_free_string,
557            user_data,
558        });
559        false
560    }
561
562    /// Register a custom provider (B5c). First-registration-wins on `provider_id`
563    /// (a later registration for an existing id is dropped, mirroring tool/command
564    /// first-wins). Returns `true` if a prior provider of the same id was kept.
565    pub fn register_provider(&mut self, provider: RegisteredProvider) -> bool {
566        if self
567            .providers
568            .iter()
569            .any(|p| p.provider_id == provider.provider_id)
570        {
571            return true;
572        }
573        self.providers.push(provider);
574        false
575    }
576
577    /// Register a provider-owned OAuth implementation. The manifest must be a
578    /// JSON object with a non-empty string `id`; malformed manifests are
579    /// rejected. Returns `true` when an existing provider won.
580    pub fn register_oauth_provider(&mut self, provider: RegisteredOAuthProvider) -> bool {
581        let Ok(manifest) = serde_json::from_str::<serde_json::Value>(&provider.manifest_json)
582        else {
583            return true;
584        };
585        let Some(id) = manifest.get("id").and_then(serde_json::Value::as_str) else {
586            return true;
587        };
588        if id.trim().is_empty()
589            || self.oauth_providers.iter().any(|p| {
590                serde_json::from_str::<serde_json::Value>(&p.manifest_json)
591                    .ok()
592                    .and_then(|v| {
593                        v.get("id")
594                            .and_then(serde_json::Value::as_str)
595                            .map(str::to_owned)
596                    })
597                    .as_deref()
598                    == Some(id)
599            })
600        {
601            return true;
602        }
603        self.oauth_providers.push(provider);
604        false
605    }
606
607    /// Register a renderer (B5c — message/markdown/entry). First-wins on
608    /// `(kind, name)`. Returns `true` if a prior renderer of the same kind+name
609    /// was kept.
610    pub fn register_renderer(&mut self, renderer: RegisteredRenderer) -> bool {
611        if self
612            .renderers
613            .iter()
614            .any(|r| r.kind == renderer.kind && r.name == renderer.name)
615        {
616            return true;
617        }
618        self.renderers.push(renderer);
619        false
620    }
621
622    /// Build a snapshot the host session keeps. The registry's `active` flag is
623    /// shared (Arc) so a later `invalidate` on the registry also invalidates the
624    /// snapshot — important for cross-session staleness.
625    pub fn snapshot(&self) -> RegistrySnapshot {
626        // P2: dispatch order is priority-first (smaller first). `sort_by_key` is
627        // stable, so equal priorities keep registration order (the prior
628        // contract). Sorting per tag here means every snapshot — including the
629        // merged session snapshot built via `absorb` — is pre-ordered.
630        let mut handlers = self.handlers.clone();
631        for tag_handlers in handlers.iter_mut() {
632            tag_handlers.sort_by_key(|h| h.priority);
633        }
634        RegistrySnapshot {
635            tools: self
636                .tools
637                .iter()
638                .map(|t| ExtensionTool {
639                    tool: t.tool.clone(),
640                    handle: t.handle,
641                })
642                .collect(),
643            commands: self.commands.clone(),
644            flags: self.flags.clone(),
645            shortcuts: self.shortcuts.clone(),
646            handlers,
647            resources_discover: self.resources_discover.clone(),
648            before_agent_start: self.before_agent_start.clone(),
649            providers: self.providers.clone(),
650            oauth_providers: self.oauth_providers.clone(),
651            renderers: self.renderers.clone(),
652            owns_stdio: self.owns_stdio,
653            active: Arc::clone(&self.active),
654        }
655    }
656
657    /// Mark this registry (and any snapshot sharing its flag) as stale.
658    pub fn invalidate(&self) {
659        self.active.store(false, Ordering::SeqCst);
660    }
661
662    /// Whether this registry is still active (not invalidated).
663    pub fn is_active(&self) -> bool {
664        self.active.load(Ordering::SeqCst)
665    }
666
667    /// P2: the plugin's declared dispatch priority (default [`DEFAULT_PRIORITY`]).
668    pub fn declared_priority(&self) -> i32 {
669        self.priority
670    }
671
672    /// P2: the plugin's declared platforms (empty = all).
673    pub fn declared_platforms(&self) -> &[String] {
674        &self.platforms
675    }
676
677    /// Whether this plugin claimed ownership of the host process's stdio
678    /// (`declare {"ownsStdio":true}`). Default `false`.
679    pub fn declared_owns_stdio(&self) -> bool {
680        self.owns_stdio
681    }
682
683    /// Absorb another registry's registrations into this one, first-wins on
684    /// name (tools/commands) and appending (event handlers). Used by
685    /// [`load_dir`](crate::loader::load_dir)/`merge_registries` to fold
686    /// per-plugin registries into one session registry in load order. Consumes
687    /// `other` (its `active` flag is discarded — the session registry's flag
688    /// wins, since it owns session lifetime).
689    pub(crate) fn absorb(&mut self, mut other: ExtensionRegistry) {
690        for t in other.tools.drain(..) {
691            if self.tools.iter().any(|x| x.tool.name == t.tool.name) {
692                // first-wins: keep the prior, discard the new. Nothing to free
693                // (handle is fn pointers). Emit no diagnostic here — the loader
694                // may surface collisions if desired; v1 silent first-wins.
695                continue;
696            }
697            self.tools.push(t);
698        }
699        for c in other.commands.drain(..) {
700            if self.commands.iter().any(|x| x.name == c.name) {
701                continue;
702            }
703            self.commands.push(c);
704        }
705        for flag in other.flags.drain(..) {
706            if self.flags.iter().any(|existing| existing.name == flag.name) {
707                continue;
708            }
709            self.flags.push(flag);
710        }
711        for shortcut in other.shortcuts.drain(..) {
712            if self
713                .shortcuts
714                .iter()
715                .any(|existing| existing.key == shortcut.key)
716            {
717                continue;
718            }
719            self.shortcuts.push(shortcut);
720        }
721        for (tag_idx, handlers) in other.handlers.iter_mut().enumerate() {
722            self.handlers[tag_idx].append(handlers);
723        }
724        self.resources_discover
725            .append(&mut other.resources_discover);
726        self.before_agent_start
727            .append(&mut other.before_agent_start);
728        for p in other.providers.drain(..) {
729            if self
730                .providers
731                .iter()
732                .any(|x| x.provider_id == p.provider_id)
733            {
734                continue;
735            }
736            self.providers.push(p);
737        }
738        for oauth in other.oauth_providers.drain(..) {
739            if self.oauth_providers.iter().any(|existing| {
740                let existing_id =
741                    serde_json::from_str::<serde_json::Value>(&existing.manifest_json)
742                        .ok()
743                        .and_then(|value| {
744                            value
745                                .get("id")
746                                .and_then(serde_json::Value::as_str)
747                                .map(str::to_owned)
748                        });
749                let oauth_id = serde_json::from_str::<serde_json::Value>(&oauth.manifest_json)
750                    .ok()
751                    .and_then(|value| {
752                        value
753                            .get("id")
754                            .and_then(serde_json::Value::as_str)
755                            .map(str::to_owned)
756                    });
757                existing_id.is_some() && existing_id == oauth_id
758            }) {
759                continue;
760            }
761            self.oauth_providers.push(oauth);
762        }
763        for r in other.renderers.drain(..) {
764            if self
765                .renderers
766                .iter()
767                .any(|x| x.kind == r.kind && x.name == r.name)
768            {
769                continue;
770            }
771            self.renderers.push(r);
772        }
773        // stdio ownership is a claim: any claiming plugin makes the merged
774        // session own stdio. The live bridge has already checked the exclusive
775        // owner; this flag projects that claim into the immutable snapshot.
776        self.owns_stdio |= other.owns_stdio;
777    }
778}
779
780// ---------------------------------------------------------------------------
781// RegistrySnapshot — the host session's immutable view
782// ---------------------------------------------------------------------------
783
784/// An immutable snapshot of an [`ExtensionRegistry`] the host session keeps for
785/// its lifetime. Tools are wrapped in [`ExtensionTool`] so the host can build
786/// [`PluginToolAdapter`](crate::PluginToolAdapter)s; event handlers are grouped
787/// by tag for the [`ExtensionEmitter`](crate::ExtensionEmitter) to fan out.
788///
789/// The `active` flag is shared with the source registry, so invalidating the
790/// registry (on session swap) invalidates this snapshot too.
791pub struct RegistrySnapshot {
792    tools: Vec<ExtensionTool>,
793    commands: Vec<RegisteredCommand>,
794    flags: Vec<RegisteredFlag>,
795    shortcuts: Vec<RegisteredShortcut>,
796    handlers: [Vec<RegisteredHandler>; EVENT_TAG_COUNT],
797    resources_discover: Vec<ResourcesDiscoverHandler>,
798    before_agent_start: Vec<BeforeAgentStartHandler>,
799    providers: Vec<RegisteredProvider>,
800    oauth_providers: Vec<RegisteredOAuthProvider>,
801    renderers: Vec<RegisteredRenderer>,
802    /// A plugin's `{"ownsStdio":true}` claim (merged OR across plugins). The
803    /// host reads this after `load_session` to defer `read_piped_stdin` and run
804    /// the headless `Rpc` lifecycle. See [`ExtensionRegistry::declared_owns_stdio`].
805    owns_stdio: bool,
806    active: Arc<AtomicBool>,
807}
808
809impl RegistrySnapshot {
810    /// The extension tools (schema + handle), insertion order.
811    pub fn tools(&self) -> &[ExtensionTool] {
812        &self.tools
813    }
814
815    /// Consume the snapshot into the owned tool list (for building adapters).
816    pub fn into_tools(self) -> Vec<ExtensionTool> {
817        self.tools
818    }
819
820    /// The registered slash commands.
821    pub fn commands(&self) -> &[RegisteredCommand] {
822        &self.commands
823    }
824
825    /// The registered CLI flags, insertion order.
826    pub fn flags(&self) -> &[RegisteredFlag] {
827        &self.flags
828    }
829
830    /// Whether any loaded plugin claimed ownership of the host process's stdio
831    /// (`declare {"ownsStdio":true}`). The host uses this to defer
832    /// `read_piped_stdin` and run the headless `Rpc` lifecycle.
833    pub fn owns_stdio(&self) -> bool {
834        self.owns_stdio
835    }
836
837    /// Keyboard shortcuts declared by extensions, insertion order.
838    pub fn shortcuts(&self) -> &[RegisteredShortcut] {
839        &self.shortcuts
840    }
841
842    /// Whether any extension has claimed `key` as a shortcut. Used by the TUI
843    /// to route that key to extension `Input` events instead of the editor.
844    pub fn has_shortcut(&self, key: &str) -> bool {
845        self.shortcuts.iter().any(|s| s.key == key)
846    }
847
848    /// Handlers subscribed to `tag` (empty slice if none).
849    pub fn handlers_for(&self, tag: EventTag) -> &[RegisteredHandler] {
850        let idx = tag as usize;
851        if idx < EVENT_TAG_COUNT {
852            &self.handlers[idx]
853        } else {
854            &[]
855        }
856    }
857
858    /// The `resources_discover` handlers, registration order (B5b). Empty when
859    /// no plugin registered a discovery handler.
860    pub fn resources_discover(&self) -> &[ResourcesDiscoverHandler] {
861        &self.resources_discover
862    }
863
864    /// The `before_agent_start` system-prompt transformers, registration order.
865    /// Empty when no plugin asked to transform the prompt — the common case, and
866    /// the one callers treat as "leave the prompt alone".
867    pub fn before_agent_start(&self) -> &[BeforeAgentStartHandler] {
868        &self.before_agent_start
869    }
870
871    /// The registered custom providers (B5c), registration order. The host wraps
872    /// each in a [`PluggableProvider`](crate::PluggableProvider). Empty when no
873    /// plugin registered a provider.
874    pub fn providers(&self) -> &[RegisteredProvider] {
875        &self.providers
876    }
877
878    /// All registered renderers (B5c), registration order, across all three
879    /// kinds.
880    pub fn renderers(&self) -> &[RegisteredRenderer] {
881        &self.renderers
882    }
883
884    /// OAuth providers registered by extensions, in registration order.
885    pub fn oauth_providers(&self) -> &[RegisteredOAuthProvider] {
886        &self.oauth_providers
887    }
888
889    /// Find an OAuth provider by its manifest `id`.
890    pub fn oauth_provider(&self, provider_id: &str) -> Option<&RegisteredOAuthProvider> {
891        self.oauth_providers.iter().find(|provider| {
892            serde_json::from_str::<serde_json::Value>(&provider.manifest_json)
893                .ok()
894                .and_then(|value| {
895                    value
896                        .get("id")
897                        .and_then(serde_json::Value::as_str)
898                        .map(|id| id == provider_id)
899                })
900                .unwrap_or(false)
901        })
902    }
903
904    /// The registered renderers of a specific kind (B5c). The TUI dispatches
905    /// message and entry renderers through the same JSON adapter used by the
906    /// markdown transformer.
907    pub fn renderers_of(&self, kind: RegisteredRendererKind) -> Vec<RegisteredRenderer> {
908        self.renderers
909            .iter()
910            .filter(|r| r.kind == kind)
911            .cloned()
912            .collect()
913    }
914
915    /// A flat iterator of all registrations (tools + commands), for diagnostics.
916    pub fn entries(&self) -> Vec<RegistryEntry> {
917        let mut v: Vec<RegistryEntry> = Vec::new();
918        for t in &self.tools {
919            v.push(RegistryEntry::Tool(ExtensionTool {
920                tool: t.tool.clone(),
921                handle: t.handle,
922            }));
923        }
924        for c in &self.commands {
925            v.push(RegistryEntry::Command(c.clone()));
926        }
927        for flag in &self.flags {
928            v.push(RegistryEntry::Flag(flag.clone()));
929        }
930        v
931    }
932
933    /// Whether this snapshot's session is still active.
934    pub fn is_active(&self) -> bool {
935        self.active.load(Ordering::SeqCst)
936    }
937
938    /// The shared active flag (for [`assert_active`]).
939    pub fn active_flag(&self) -> &Arc<AtomicBool> {
940        &self.active
941    }
942}
943
944/// Staleness guard: `true` while the owning session is still active. Called
945/// before dispatching an extension event or driving an extension tool so a
946/// stale registry (from a swapped-out session) can't act. Mirrors pi's
947/// `ExtensionRuntimeState` active check.
948pub fn assert_active(active: &Arc<AtomicBool>) -> bool {
949    active.load(Ordering::SeqCst)
950}
951
952/// Build an `AgentError` for a stale-registry access.
953#[allow(dead_code)]
954pub(crate) fn stale_error() -> AgentError {
955    AgentError::State("extensions registry is stale (session swapped/reloaded)".into())
956}
957
958// Suppress unused-fn warning until B3 wires dispatch through the snapshot.
959#[allow(dead_code)]
960fn _ensure_handler_accessor_used(snap: &RegistrySnapshot) {
961    let _ = snap.handlers_for(EventTag::MessageEnd);
962}
963
964#[cfg(test)]
965mod tests {
966    use super::*;
967
968    #[test]
969    fn shortcuts_first_wins_and_merge() {
970        let mut first = ExtensionRegistry::new();
971        assert!(!first.register_shortcut("voice".into(), "space".into(), "talk".into()));
972        // Second claim on the same key is rejected (first-wins).
973        assert!(first.register_shortcut("other".into(), "space".into(), "also".into()));
974        assert!(!first.register_shortcut("voice".into(), "enter".into(), "send".into()));
975
976        let mut second = ExtensionRegistry::new();
977        assert!(!second.register_shortcut("other".into(), "space".into(), "dup".into()));
978        assert!(!second.register_shortcut("other".into(), "f1".into(), "help".into()));
979
980        first.absorb(second);
981        let snap = first.snapshot();
982        assert_eq!(snap.shortcuts().len(), 3);
983        assert_eq!(snap.shortcuts()[0].key, "space");
984        assert_eq!(snap.shortcuts()[0].plugin, "voice");
985        assert_eq!(snap.shortcuts()[1].key, "enter");
986        assert_eq!(snap.shortcuts()[2].key, "f1");
987        // The claimed-key query the TUI routes on.
988        assert!(snap.has_shortcut("space"));
989        assert!(!snap.has_shortcut("tab"));
990    }
991
992    #[test]
993    fn cli_flags_snapshot_and_merge_first_wins() {
994        let mut first = ExtensionRegistry::new();
995        assert!(!first.register_flag("server".into(), "Start server".into()));
996        assert!(first.register_flag("server".into(), "Different description".into()));
997
998        let mut second = ExtensionRegistry::new();
999        assert!(!second.register_flag("server".into(), "Second server".into()));
1000        assert!(!second.register_flag("port".into(), "Listen port".into()));
1001
1002        first.absorb(second);
1003        let snapshot = first.snapshot();
1004        let flags = snapshot.flags();
1005        assert_eq!(flags.len(), 2);
1006        assert_eq!(flags[0].name, "server");
1007        assert_eq!(flags[0].description, "Start server");
1008        assert_eq!(flags[1].name, "port");
1009    }
1010
1011    extern "C" fn noop_handler(
1012        _ev: rpi_plugin_sdk::StablePluginEvent,
1013        _ud: *mut std::ffi::c_void,
1014    ) -> i32 {
1015        0
1016    }
1017
1018    fn handler_of<'a>(
1019        snap: &'a RegistrySnapshot,
1020        tag: EventTag,
1021        i: usize,
1022    ) -> &'a RegisteredHandler {
1023        &snap.handlers_for(tag)[i]
1024    }
1025
1026    /// Two plugins declaring different priorities: the merged snapshot orders
1027    /// the lower-priority plugin's handler first (smaller runs first).
1028    #[test]
1029    fn priority_orders_handlers_after_merge() {
1030        let tag = EventTag::SessionStart;
1031
1032        let mut late = ExtensionRegistry::new();
1033        late.apply_declaration(Some(200), None);
1034        late.register_event_handler("late".into(), tag, noop_handler, std::ptr::null_mut());
1035
1036        let mut early = ExtensionRegistry::new();
1037        early.apply_declaration(Some(50), None);
1038        early.register_event_handler("early".into(), tag, noop_handler, std::ptr::null_mut());
1039
1040        // Absorb in the "wrong" order — snapshot must still sort by priority.
1041        let mut session = ExtensionRegistry::new();
1042        session.absorb(late);
1043        session.absorb(early);
1044        let snap = session.snapshot();
1045        assert_eq!(handler_of(&snap, tag, 0).plugin, "early");
1046        assert_eq!(handler_of(&snap, tag, 1).plugin, "late");
1047    }
1048
1049    /// Equal priorities keep registration order (stable sort).
1050    #[test]
1051    fn equal_priority_is_stable() {
1052        let tag = EventTag::TurnEnd;
1053        let mut reg = ExtensionRegistry::new();
1054        reg.register_event_handler("a".into(), tag, noop_handler, std::ptr::null_mut());
1055        reg.register_event_handler("b".into(), tag, noop_handler, std::ptr::null_mut());
1056        let snap = reg.snapshot();
1057        assert_eq!(handler_of(&snap, tag, 0).plugin, "a");
1058        assert_eq!(handler_of(&snap, tag, 1).plugin, "b");
1059    }
1060
1061    /// A declaration applied AFTER handlers exist retro-updates them (declare
1062    /// order does not matter).
1063    #[test]
1064    fn declaration_retro_updates_existing_handlers() {
1065        let tag = EventTag::SessionStart;
1066        let mut reg = ExtensionRegistry::new();
1067        reg.register_event_handler("p".into(), tag, noop_handler, std::ptr::null_mut());
1068        assert_eq!(
1069            handler_of(&reg.snapshot(), tag, 0).priority,
1070            DEFAULT_PRIORITY
1071        );
1072
1073        assert!(reg.apply_declaration_json(r#"{"priority":7,"platforms":["linux"]}"#));
1074        let snap = reg.snapshot();
1075        assert_eq!(handler_of(&snap, tag, 0).priority, 7);
1076        assert_eq!(
1077            handler_of(&snap, tag, 0).platforms,
1078            vec!["linux".to_string()]
1079        );
1080    }
1081
1082    /// Malformed declaration JSON is rejected (returns `false`); recognised keys
1083    /// apply.
1084    #[test]
1085    fn apply_declaration_json_parses_and_rejects() {
1086        let mut reg = ExtensionRegistry::new();
1087        assert!(!reg.apply_declaration_json("not json"));
1088        assert!(reg.apply_declaration_json("{}"));
1089        assert!(reg.apply_declaration_json(r#"{"priority":3}"#));
1090        assert_eq!(reg.apply_declaration(Some(3), None), 1);
1091    }
1092
1093    /// `declare {"ownsStdio":true}` claims the host process's stdio; it is
1094    /// monotonic (a later `false` does not un-claim) and merges by OR.
1095    #[test]
1096    fn declaration_owns_stdio_is_monotonic_and_merges() {
1097        let mut reg = ExtensionRegistry::new();
1098        assert!(!reg.declared_owns_stdio());
1099        assert!(!reg.snapshot().owns_stdio());
1100
1101        assert!(reg.apply_declaration_json(r#"{"ownsStdio":true}"#));
1102        assert!(reg.declared_owns_stdio());
1103        assert!(reg.snapshot().owns_stdio());
1104
1105        // A later `false` must not release an established claim.
1106        assert!(reg.apply_declaration_json(r#"{"ownsStdio":false}"#));
1107        assert!(reg.declared_owns_stdio());
1108
1109        // `absorb` ORs ownership across plugins (any claiming plugin wins).
1110        let mut plain = ExtensionRegistry::new();
1111        let mut claiming = ExtensionRegistry::new();
1112        assert!(claiming.apply_declaration_json(r#"{"ownsStdio":true}"#));
1113        plain.absorb(claiming);
1114        assert!(plain.declared_owns_stdio());
1115        assert!(plain.snapshot().owns_stdio());
1116    }
1117
1118    #[test]
1119    fn platform_allows_matches_host_and_wildcard() {
1120        assert!(platform_allows(&[]));
1121        assert!(platform_allows(&["all".to_string()]));
1122        assert!(platform_allows(&["ALL".to_string()]));
1123        assert!(platform_allows(&[current_platform().to_string()]));
1124        assert!(!platform_allows(&["nonexistent-os".to_string()]));
1125        // Empty host subset is fine; a non-matching list is not.
1126        assert!(!platform_allows(&["definitely-not-this-host".to_string()]));
1127    }
1128
1129    extern "C" fn noop_oauth(
1130        _request: rpi_plugin_sdk::StbStringRef,
1131        _out: *mut rpi_plugin_sdk::StbString,
1132        _user_data: *mut std::ffi::c_void,
1133    ) -> i32 {
1134        0
1135    }
1136
1137    extern "C" fn noop_free(_value: rpi_plugin_sdk::StbString) {}
1138
1139    #[test]
1140    fn oauth_manifest_registers_and_deduplicates() {
1141        let mut reg = ExtensionRegistry::new();
1142        let make = |manifest: &str| RegisteredOAuthProvider {
1143            plugin: "test-plugin".to_string(),
1144            manifest_json: manifest.to_string(),
1145            request_fn: noop_oauth,
1146            plugin_free_string: noop_free,
1147            user_data: std::ptr::null_mut(),
1148        };
1149        assert!(!reg.register_oauth_provider(make(r#"{"id":"acme","displayName":"Acme"}"#)));
1150        assert!(reg.register_oauth_provider(make(r#"{"id":"acme"}"#)));
1151        assert!(reg.register_oauth_provider(make(r#"{"displayName":"missing-id"}"#)));
1152        assert_eq!(reg.snapshot().oauth_providers().len(), 1);
1153    }
1154}