Skip to main content

rpi_extensions/
lib.rs

1//! Rust-native (cdylib) plugin loader + `AgentTool` adapter for rpi.
2//!
3//! This is Part B1 of the extension-alignment plan. `rpi` loads extensions as
4//! **compiled Rust cdylibs** (`.dll`/`.so`/`.dylib`) via `libloading` — **not**
5//! TS/jiti — because we control both sides and the plugin is Rust
6//! (`动态加载可以加载rs 的代码 没必要是 ts`). The ABI contract lives in
7//! [`rpi_plugin_sdk`]; this crate is the **host side** that loads plugins and
8//! bridges their tools/events into rpi's native async types.
9//!
10//! ## Crate DAG position
11//!
12//! Depends on **`rpi-plugin-sdk` + `rpi-ai` + `rpi-agent` only** (NOT
13//! `rpi-harness`). The harness consumes this crate's adapters as trait objects
14//! via `AgentHarnessOptions` injection, so `rpi-harness` never imports
15//! `rpi-extensions` — cycle-free (verified by adversarial review of the first
16//! draft, which wrongly added a `rpi-harness` dep).
17//!
18//! ## The async-across-ABI bridge (load-bearing — corrected vs first draft)
19//!
20//! A plugin tool drives an execution through **four** plugin-exported fns
21//! (`execute`→handle, `poll`, `cancel`, `destroy`) — see
22//! [`rpi_plugin_sdk::ToolExecuteFn`] etc. The host's [`PluginToolAdapter`]
23//! impls `AgentTool::execute` by:
24//!
25//! 1. **NOT owning a runtime.** Acquire the ambient runtime
26//!    (`tokio::runtime::Handle::try_current()`) — the adapter only ever runs
27//!    inside the agent loop's runtime.
28//! 2. Set up an **unbounded mpsc** for `ToolResultPartial` (async side drains +
29//!    forwards to `on_update`) and a **oneshot** for the terminal
30//!    `AgentToolResult`.
31//! 3. `handle.spawn_blocking(move || drive(plugin, cancel_flag, partial_tx,
32//!    done_tx))` — the blocking driver loops `poll` until `Done`/`Err`,
33//!    forwarding `Pending` partials through the mpsc (via a `catch_unwind`
34//!    trampoline so a panicking partial callback can't unwind across FFI),
35//!    then sends the terminal result + calls `destroy` **exactly once** on exit.
36//! 4. The async future `select!`s between the oneshot (terminal) and
37//!    `signal.cancelled()` (the child token). On cancel: **set an `AtomicBool`
38//!    cancel flag (SeqCst)** so the blocking driver observes it; **keep
39//!    awaiting the oneshot** (never drop the driver — `spawn_blocking` tasks
40//!    run to completion regardless of outer-future drop, so dropping is a
41//!    thread leak).
42//! 5. `Drop`/drop-guard sets **only the cancel flag** — never calls `destroy`
43//!    synchronously (the driver may be mid-`poll`); `destroy` is called exactly
44//!    once by the blocking driver.
45//!
46//! `cancel` ≠ `destroy`: the first draft conflated them → UAF/double-free. Here
47//! `cancel` is an idempotent thread-safe flag-set; `destroy` is the single
48//! free, owned by the driver.
49//!
50//! ## Events
51//!
52//! [`ExtensionEmitter`] impls `AgentEmitter` by subscribing to the host's
53//! `broadcast::Sender<AgentEvent>`, translating each `AgentEvent` → a
54//! [`rpi_plugin_sdk::StablePluginEvent`], and dispatching to every registered
55//! handler for the event's tag — all dispatch wrapped in `catch_unwind`. The
56//! 33-category `on()` surface (B3) is driven through this emitter; the
57//! 10 already-emitted `AgentEvent` variants fold into the matching tags now,
58//! and the remaining tags light up as B3/B4/B5 add the emission points.
59
60use std::ffi::c_void;
61use std::sync::{Arc, Mutex};
62
63use rpi_plugin_sdk::{
64    EventHandlerFn, EventTag, FreeStringFn, LegacyPluginApiV1, LegacyRuntimeActionFn, PluginApiVt,
65    ProviderRequestFn, RenderFn, ResourcesDiscoverFn, RuntimeActionFn, StablePluginEvent,
66    StableToolSchema, StbString, StbStringRef, ToolCancelFn, ToolDestroyFn, ToolExecuteFn,
67    ToolPollFn,
68};
69use thiserror::Error;
70
71pub use actions::{
72    reload_callback_from_mailbox, trampoline_runtime_action, trampoline_runtime_action_v1,
73    ActionBridge, ReloadMailbox, RuntimeActionHost,
74};
75pub use loader::{
76    load_dir, load_one, load_session, load_session_mixed, merge_registries, ExtensionSession,
77    LoadedPlugin, PluginKeepalive, PluginLoadError,
78};
79pub use provider::PluggableProvider;
80pub use provider_hooks::ExtensionProviderHooks;
81pub use registry::{
82    assert_active, ExtensionRegistry, ExtensionTool, RegisteredFlag, RegisteredHandler,
83    RegisteredProvider, RegisteredRenderer, RegisteredRendererKind, RegistryEntry,
84    RegistrySnapshot, ResourcesDiscoverHandler,
85};
86pub use resources::{emit_resources_discover, DiscoveredResources};
87pub use tool::{PluginToolAdapter, PluginToolHandle};
88pub use translate::{ExtensionEmitter, TeeEmitter};
89
90mod actions;
91mod loader;
92mod provider;
93mod provider_hooks;
94mod registry;
95mod resources;
96mod tool;
97mod translate;
98
99/// A diagnostics/event sink the host wires so the loader + adapter can report
100/// plugin-load skips, ABI mismatches, panics caught at the FFI boundary, etc.
101/// Mirrors the `diagnostic` channel pi surfaces for extensions.
102pub trait PluginDiagnostics: Send + Sync {
103    /// A non-fatal warning (e.g. "skipped plugin foo: ABI version mismatch").
104    fn warn(&self, message: &str);
105    /// A plugin that registered something but a registration slot is not yet
106    /// wired on the host (e.g. an event tag the host does not emit yet).
107    fn unsupported(&self, message: &str);
108}
109
110/// A no-op diagnostics sink (the default when the host does not supply one).
111#[derive(Default)]
112pub struct NullDiagnostics;
113
114impl PluginDiagnostics for NullDiagnostics {
115    fn warn(&self, _message: &str) {}
116    fn unsupported(&self, _message: &str) {}
117}
118
119/// Error returned by [`PluginToolAdapter::execute`] when the plugin side failed
120/// (terminal `Err` from `poll`, or the drive handle was never produced).
121#[derive(Debug, Error)]
122pub enum PluginToolError {
123    #[error("plugin execute returned a null handle (allocation failure)")]
124    NullHandle,
125    #[error("plugin error: {0}")]
126    Plugin(String),
127    #[error("runtime unavailable: {0}")]
128    NoRuntime(String),
129}
130
131/// The host's `free_string` for [`StbString`]s the host *produces* and hands to
132/// the plugin (event payloads, action outputs, execute params when the host
133/// owns them). The plugin frees what it *receives* via this; the host frees
134/// what it *receives* via the plugin's `free_string` (stored per-tool).
135///
136/// Reconstructs the `Box<[u8]>` from ptr+len and drops it. Idempotent on
137/// empty/null.
138pub extern "C" fn host_free_string(s: StbString) {
139    if s.is_empty() || s.ptr.is_null() {
140        return;
141    }
142    // SAFETY: the host produced this StbString via `StbString::from_owned`/
143    // `from_string` (a `Box<[u8]>`), so ptr+len reconstruct the same allocation.
144    unsafe {
145        let slice = std::slice::from_raw_parts(s.ptr as *const u8, s.len);
146        let _ = Box::from_raw(slice as *const [u8] as *mut [u8]);
147    }
148}
149
150// ===========================================================================
151// HostApi — the PluginApiVt the host builds and hands to each plugin's register
152// ===========================================================================
153
154/// The host state a [`PluginApiVt`] closes over. Held behind `Arc` so the fn
155/// pointers (which are `extern "C"`,不好做闭包) can recover the host state via
156/// the `user_data` slot — but since `extern "C" fn` cannot capture, the host
157/// stores per-registration receiver state in the [`HostApi`] itself keyed by
158/// nothing (single registry per host), and the fns are thin trampolines that
159/// read a process-global-attached registry. In v1 we keep it simple: the host
160/// builds one `HostApi` per load session; the `register_*` trampolines forward
161/// into it.
162///
163/// This struct is `Send + Sync` (registry is `Mutex`-guarded).
164pub struct HostApi {
165    registry: Mutex<Option<ExtensionRegistry>>,
166    diagnostics: Arc<dyn PluginDiagnostics>,
167    /// B5a: the plugin→host action bridge, carried in
168    /// [`PluginApiVt::user_data`] so [`trampoline_runtime_action`] can recover
169    /// the harness state from ANY thread a plugin calls from (post-register, no
170    /// thread-local). `None` keeps the no-bridge stub `runtime_action` and the
171    /// register `user_data` (the `HostApi` pointer) — so older call sites that
172    /// don't pass a bridge behave exactly as before.
173    action_bridge: Option<Arc<ActionBridge>>,
174}
175
176impl HostApi {
177    /// Build a fresh host API bound to the given registry + diagnostics. With
178    /// no action bridge, `runtime_action` stays the stub returning `-1`.
179    pub fn new(registry: ExtensionRegistry, diagnostics: Arc<dyn PluginDiagnostics>) -> Arc<Self> {
180        Arc::new(Self {
181            registry: Mutex::new(Some(registry)),
182            diagnostics,
183            action_bridge: None,
184        })
185    }
186
187    /// B5a: build a host API that wires the real [`trampoline_runtime_action`]
188    /// via the bridge. The bridge is cloned into every plugin's vtable
189    /// `user_data` so post-register `runtime_action` calls recover it on any
190    /// thread. `rpi-cli` keeps one master `Arc<ActionBridge>` per session; the
191    /// `HostApi` here holds a clone only for the duration of `load_one` (it is
192    /// dropped after `take_registry`, but the master arc in `rpi-cli` keeps the
193    /// pointer valid).
194    pub fn with_action_bridge(
195        registry: ExtensionRegistry,
196        diagnostics: Arc<dyn PluginDiagnostics>,
197        action_bridge: Arc<ActionBridge>,
198    ) -> Arc<Self> {
199        Arc::new(Self {
200            registry: Mutex::new(Some(registry)),
201            diagnostics,
202            action_bridge: Some(action_bridge),
203        })
204    }
205
206    fn with_registry<R>(&self, f: impl FnOnce(&mut ExtensionRegistry) -> R) -> Option<R> {
207        let mut guard = self.registry.lock().expect("host api registry lock");
208        guard.as_mut().map(f)
209    }
210
211    /// Detach the registry (call after registration completes so the host can
212    /// move it out for use). The `Mutex<Option<...>>` is left `None`.
213    pub fn take_registry(&self) -> Option<ExtensionRegistry> {
214        self.registry.lock().expect("host api registry lock").take()
215    }
216
217    /// Build the ABI v2 C vtable passed to `rpi_plugin_register_v2`.
218    ///
219    /// Every optional registrar slot currently resolves to a real `extern "C"`
220    /// trampoline that forwards into `self`'s registry (so a plugin that calls
221    /// `register_tool` / `register_command` / renderer registration now sees
222    /// its registration land). `runtime_action` resolves
223    /// to the real [`trampoline_runtime_action`] when a bridge is present (B5a),
224    /// else the stub returning `-1`.
225    ///
226    /// **`user_data`**: the register trampolines recover host state via the
227    /// thread-local `CURRENT_HOST_API` (set for the duration of register in
228    /// `load_one`) — they ignore `user_data`. Post-register `runtime_action`
229    /// cannot use the thread-local (plugin calls from foreign threads), so when
230    /// a bridge is present `user_data` is repurposed to point at the
231    /// `ActionBridge` (the SDK-designated "host's opaque context"). The bridge's
232    /// master `Arc` is kept by `rpi-cli` for the harness lifetime, so the
233    /// pointer a plugin stores during register stays valid.
234    pub fn build_vtable(self: &Arc<Self>) -> PluginApiVt {
235        // When a bridge is present, user_data carries it (post-register action
236        // recovery). Otherwise keep the register-path HostApi pointer (harmless
237        // — register trampolines use the thread-local and ignore user_data).
238        // Cast both arms to the `RuntimeActionFn` pointer type — distinct fn
239        // items have unique types even with identical signatures, so the match
240        // needs a common fn-pointer type to unify on (the vtable field is
241        // `RuntimeActionFn`, a bare `extern "C" fn` alias, not an `Option`).
242        let (runtime_action_fn, ud) = match &self.action_bridge {
243            Some(bridge) => (
244                trampoline_runtime_action as RuntimeActionFn,
245                Arc::as_ptr(bridge) as *mut c_void,
246            ),
247            None => (
248                stub_runtime_action as RuntimeActionFn,
249                Arc::as_ptr(self) as *mut c_void,
250            ),
251        };
252        PluginApiVt {
253            free_string: host_free_string,
254            register_tool: Some(trampoline_register_tool),
255            register_command: Some(trampoline_register_command),
256            register_shortcut: Some(trampoline_register_shortcut),
257            register_flag: Some(trampoline_register_flag),
258            register_provider: Some(trampoline_register_provider), // B5c
259            register_message_renderer: Some(trampoline_register_message_renderer), // B5c
260            register_markdown_transformer: Some(trampoline_register_markdown_transformer), // B5c
261            register_entry_renderer: Some(trampoline_register_entry_renderer), // B5c
262            register_event_handler: Some(trampoline_register_event_handler),
263            register_resources_discover: Some(trampoline_register_resources_discover),
264            runtime_action: runtime_action_fn,
265            dispatch_event: Some(trampoline_dispatch_event),
266            user_data: ud,
267        }
268    }
269
270    /// Build the frozen ABI v1 view used only for a plugin exporting the legacy
271    /// `rpi_plugin_register` symbol. Its runtime-action slot rejects ids above
272    /// the v1 range before dispatch.
273    pub fn build_legacy_vtable(self: &Arc<Self>) -> LegacyPluginApiV1 {
274        let v2 = self.build_vtable();
275        let legacy_runtime_action = if self.action_bridge.is_some() {
276            trampoline_runtime_action_v1 as LegacyRuntimeActionFn
277        } else {
278            stub_runtime_action as LegacyRuntimeActionFn
279        };
280        LegacyPluginApiV1 {
281            free_string: v2.free_string,
282            register_tool: v2.register_tool,
283            register_command: v2.register_command,
284            register_shortcut: v2.register_shortcut,
285            register_flag: v2.register_flag,
286            register_provider: v2.register_provider,
287            register_message_renderer: v2.register_message_renderer,
288            register_markdown_transformer: v2.register_markdown_transformer,
289            register_entry_renderer: v2.register_entry_renderer,
290            register_event_handler: v2.register_event_handler,
291            register_resources_discover: v2.register_resources_discover,
292            runtime_action: legacy_runtime_action,
293            dispatch_event: v2.dispatch_event,
294            user_data: v2.user_data,
295        }
296    }
297}
298
299// Thread-local "current host api" for the duration of a plugin register call.
300// Set by [`load_one`] before calling register, cleared after. This is the
301// sound way to let `extern "C" fn` trampolines (which cannot capture) reach the
302// host's registry: the register call is synchronous and single-threaded per
303// plugin, so a thread-local is unambiguous.
304thread_local! {
305    static CURRENT_HOST_API: std::cell::Cell<*const HostApi> = std::cell::Cell::new(std::ptr::null());
306}
307
308/// SAFETY: must be called only while the `Arc<HostApi>` pointed to by `api` is
309/// kept alive (i.e. inside `with_current_api`). Sets the thread-local current
310/// api pointer.
311unsafe fn set_current_api(api: &Arc<HostApi>) {
312    CURRENT_HOST_API.with(|c| c.set(Arc::as_ptr(api) as *const HostApi));
313}
314
315fn clear_current_api() {
316    CURRENT_HOST_API.with(|c| c.set(std::ptr::null()));
317}
318
319/// Run `f` with the current thread-local host api borrowed. No-op (returns
320/// `false`) if no api is current (e.g. a trampoline called outside register).
321fn with_current_api<R>(f: impl FnOnce(&HostApi) -> R) -> Option<R> {
322    let ptr = CURRENT_HOST_API.with(|c| c.get());
323    if ptr.is_null() {
324        return None;
325    }
326    // SAFETY: the `Arc<HostApi>` is alive for the duration of register (kept by
327    // `load_one`'s stack), and this thread set it; borrowing here is sound.
328    let api = unsafe { &*ptr };
329    Some(f(api))
330}
331
332/// True if a current host api is set (cheap check used by trampolines that only
333/// need presence, not the api itself).
334fn current_api_present() -> bool {
335    let ptr = CURRENT_HOST_API.with(|c| c.get());
336    !ptr.is_null()
337}
338
339// --- the registrar trampolines (extern "C", forward into the current api) ---
340
341extern "C" fn trampoline_register_tool(
342    schema: *const StableToolSchema,
343    execute_fn: ToolExecuteFn,
344    poll_fn: ToolPollFn,
345    cancel_fn: ToolCancelFn,
346    destroy_fn: ToolDestroyFn,
347    plugin_free_string: FreeStringFn,
348) -> i32 {
349    if !current_api_present() {
350        return -1;
351    }
352    if schema.is_null() {
353        return 1;
354    }
355    // SAFETY: the plugin guarantees `schema` is valid for the call; we copy the
356    // strings out immediately (under the borrow) and free them via the plugin's
357    // `plugin_free_string` right after, so no retention past the call.
358    let (name, description, parameters_value, schema_owned) = unsafe {
359        let s = &*schema;
360        (
361            s.name.to_string_lossy(),
362            s.description.to_string_lossy(),
363            serde_json::from_str::<serde_json::Value>(&s.parameters.to_string_lossy()).ok(),
364            *s,
365        )
366    };
367    // Free the schema strings via the plugin's free fn (plugin produced them).
368    // `FreeStringFn` is `extern "C" fn(StbString)` — calling a bare fn pointer
369    // is safe (no `unsafe` needed); the idempotent free contract is on the plugin.
370    plugin_free_string(schema_owned.name);
371    plugin_free_string(schema_owned.description);
372    plugin_free_string(schema_owned.parameters);
373    let Some(parameters_value) = parameters_value else {
374        return 2;
375    };
376    if name.trim().is_empty() || !parameters_value.is_object() {
377        return 2;
378    }
379    let tool = rpi_ai::types::Tool {
380        name,
381        description,
382        parameters: rpi_ai::types::Schema::new(parameters_value),
383        constrained_sampling: None,
384    };
385    let handle = PluginToolHandle {
386        execute_fn,
387        poll_fn,
388        cancel_fn,
389        destroy_fn,
390        plugin_free_string,
391    };
392    // `register_tool` returns `bool` (true on overwrite of a prior same-named
393    // tool). For the plugin a name collision is not a hard failure — always
394    // return 0 (success) when the registry accepted it; -1 only if the registry
395    // was already detached.
396    let ok =
397        with_current_api(
398            |api| match api.with_registry(|reg| reg.register_tool(tool, handle)) {
399                Some(_) => true,
400                None => false,
401            },
402        );
403    if ok == Some(true) {
404        0
405    } else {
406        -1
407    }
408}
409
410extern "C" fn trampoline_register_command(
411    name: StbStringRef,
412    description: StbStringRef,
413    handler: rpi_plugin_sdk::CommandHandlerFn,
414) -> i32 {
415    if !current_api_present() {
416        return -1;
417    }
418    // SAFETY: the plugin guarantees the refs are valid for this call.
419    let (name, description) =
420        unsafe { (name.as_str().to_string(), description.as_str().to_string()) };
421    let ok = with_current_api(|api| {
422        // The v1 command ABI predates an explicit context parameter. Keep a
423        // null context for compatibility; plugins that need host state can use
424        // `runtime_action` from their command callback.
425        match api.with_registry(|reg| {
426            reg.register_command(name, description, handler, std::ptr::null_mut())
427        }) {
428            Some(_) => true,
429            None => false,
430        }
431    });
432    if ok == Some(true) {
433        0
434    } else {
435        -1
436    }
437}
438
439extern "C" fn trampoline_register_shortcut(_key: StbStringRef, _description: StbStringRef) -> i32 {
440    // v1: shortcuts are a TUI concern (B5). Record nothing; return ok so the
441    // plugin doesn't error, but note via diagnostics it's unsupported.
442    with_current_api(|api| api.diagnostics.unsupported("register_shortcut (TUI — B5)"));
443    0
444}
445
446extern "C" fn trampoline_register_flag(name: StbStringRef, description: StbStringRef) -> i32 {
447    if !current_api_present() {
448        return -1;
449    }
450    // SAFETY: the plugin guarantees these borrowed refs are valid for the
451    // duration of the registration call; copy them before returning.
452    let (name, description) =
453        unsafe { (name.as_str().to_string(), description.as_str().to_string()) };
454    if name.trim().is_empty() || name.starts_with('-') || name.contains('=') || name.contains(' ') {
455        return 2;
456    }
457    let ok = with_current_api(|api| {
458        match api.with_registry(|reg| reg.register_flag(name, description)) {
459            Some(_) => true,
460            None => false,
461        }
462    });
463    if ok == Some(true) {
464        0
465    } else {
466        -1
467    }
468}
469
470extern "C" fn trampoline_register_event_handler(
471    tag: EventTag,
472    handler: EventHandlerFn,
473    user_data: *mut c_void,
474) -> i32 {
475    if !current_api_present() {
476        return -1;
477    }
478    let ok = with_current_api(|api| {
479        match api.with_registry(|reg| reg.register_event_handler(tag, handler, user_data)) {
480            Some(_) => true,
481            None => false,
482        }
483    });
484    if ok == Some(true) {
485        0
486    } else {
487        -1
488    }
489}
490
491/// B5c: `register_provider` trampoline. Runs synchronously inside a plugin's
492/// `register` call (thread-local `CURRENT_HOST_API` is set). The plugin hands its
493/// `provider_id`/`base_url`/`api_style` (borrowed `StbStringRef`s), its
494/// `request_fn`, its own `plugin_free_string` (the `out` StbString `request_fn`
495/// later produces is plugin-owned — the host reclaims it via this fn), and its
496/// opaque `user_data`. We copy the id/base_url/api_style to owned `String`s
497/// (they're the provider's identity, read when building the
498/// [`PluggableProvider`](crate::PluggableProvider) — we can't keep the borrowed
499/// refs past register), then store the full record in the registry.
500extern "C" fn trampoline_register_provider(
501    provider_id: StbStringRef,
502    base_url: StbStringRef,
503    api_style: StbStringRef,
504    request_fn: ProviderRequestFn,
505    plugin_free_string: FreeStringFn,
506    user_data: *mut c_void,
507) -> i32 {
508    if !current_api_present() {
509        return -1;
510    }
511    // SAFETY: the plugin guarantees the refs are valid for this call.
512    let record = crate::registry::RegisteredProvider {
513        provider_id: unsafe { provider_id.as_str().to_string() },
514        base_url: unsafe { base_url.as_str().to_string() },
515        api_style: unsafe { api_style.as_str().to_string() },
516        request_fn,
517        plugin_free_string,
518        user_data,
519    };
520    let ok = with_current_api(
521        |api| match api.with_registry(|reg| reg.register_provider(record)) {
522            Some(_) => true,
523            None => false,
524        },
525    );
526    if ok == Some(true) {
527        0
528    } else {
529        -1
530    }
531}
532
533/// B5c: the three renderer registrars share a single recording helper; each
534/// trampoline below fixes its [`RegisteredRendererKind`] and forwards here.
535fn register_renderer_common(
536    name: StbStringRef,
537    kind: crate::registry::RegisteredRendererKind,
538    render_fn: RenderFn,
539    plugin_free_string: FreeStringFn,
540    user_data: *mut c_void,
541) -> i32 {
542    if !current_api_present() {
543        return -1;
544    }
545    // SAFETY: the plugin guarantees the ref is valid for this call.
546    let record = crate::registry::RegisteredRenderer {
547        name: unsafe { name.as_str().to_string() },
548        kind,
549        render_fn,
550        plugin_free_string,
551        user_data,
552    };
553    let ok = with_current_api(
554        |api| match api.with_registry(|reg| reg.register_renderer(record)) {
555            Some(_) => true,
556            None => false,
557        },
558    );
559    if ok == Some(true) {
560        0
561    } else {
562        -1
563    }
564}
565
566extern "C" fn trampoline_register_message_renderer(
567    name: StbStringRef,
568    render_fn: RenderFn,
569    plugin_free_string: FreeStringFn,
570    user_data: *mut c_void,
571) -> i32 {
572    register_renderer_common(
573        name,
574        crate::registry::RegisteredRendererKind::Message,
575        render_fn,
576        plugin_free_string,
577        user_data,
578    )
579}
580
581extern "C" fn trampoline_register_markdown_transformer(
582    name: StbStringRef,
583    render_fn: RenderFn,
584    plugin_free_string: FreeStringFn,
585    user_data: *mut c_void,
586) -> i32 {
587    register_renderer_common(
588        name,
589        crate::registry::RegisteredRendererKind::Markdown,
590        render_fn,
591        plugin_free_string,
592        user_data,
593    )
594}
595
596extern "C" fn trampoline_register_entry_renderer(
597    name: StbStringRef,
598    render_fn: RenderFn,
599    plugin_free_string: FreeStringFn,
600    user_data: *mut c_void,
601) -> i32 {
602    register_renderer_common(
603        name,
604        crate::registry::RegisteredRendererKind::Entry,
605        render_fn,
606        plugin_free_string,
607        user_data,
608    )
609}
610
611extern "C" fn trampoline_dispatch_event(_event: StablePluginEvent, _user_data: *mut c_void) -> i32 {
612    // v1: a plugin emitting an event upstream — we accept it (return 0) but do
613    // not yet forward to host subscribers (no upstream channel wired). B5 wires
614    // the reverse-direction event bus.
615    0
616}
617
618/// B5b: `register_resources_discover` trampoline. Runs synchronously inside a
619/// plugin's `register` call (so the thread-local `CURRENT_HOST_API` is set → the
620/// register-path recovery works, same as the other `trampoline_register_*` fns).
621/// The plugin hands its `handler`, its own `plugin_free_string` (the `out`
622/// StbString the handler later produces is plugin-owned — the host must reclaim
623/// it via this fn), and its opaque `user_data`. The host stores all three in the
624/// registry and later fans the discovery event out via `emit_resources_discover`.
625extern "C" fn trampoline_register_resources_discover(
626    handler: ResourcesDiscoverFn,
627    plugin_free_string: FreeStringFn,
628    user_data: *mut c_void,
629) -> i32 {
630    if !current_api_present() {
631        return -1;
632    }
633    let ok = with_current_api(|api| {
634        match api.with_registry(|reg| {
635            reg.register_resources_discover(handler, plugin_free_string, user_data)
636        }) {
637            Some(_) => true,
638            None => false,
639        }
640    });
641    if ok == Some(true) {
642        0
643    } else {
644        -1
645    }
646}
647
648/// Fallback for [`HostApi`]s built without an [`ActionBridge`] (the
649/// `HostApi::new` path). Return -1 (`EPERM`-ish) so a plugin can detect
650/// "unsupported" without crashing. When a bridge is present, `build_vtable`
651/// installs [`trampoline_runtime_action`] instead.
652extern "C" fn stub_runtime_action(
653    _action_id: u32,
654    _args: StbStringRef,
655    _out: *mut StbString,
656    _user_data: *mut c_void,
657) -> i32 {
658    -1
659}
660
661// Keep the `RuntimeActionFn` type name referenced for doc clarity / future wiring.
662#[allow(dead_code)]
663type _RuntimeActionFnDoc = RuntimeActionFn;