Expand description
Rust-native (cdylib) plugin loader + AgentTool adapter for rpi.
This is Part B1 of the extension-alignment plan. rpi loads extensions as
compiled Rust cdylibs (.dll/.so/.dylib) via libloading — not
TS/jiti — because we control both sides and the plugin is Rust
(动态加载可以加载rs 的代码 没必要是 ts). The ABI contract lives in
rpi_plugin_sdk; this crate is the host side that loads plugins and
bridges their tools/events into rpi’s native async types.
§Crate DAG position
Depends on rpi-plugin-sdk + rpi-ai + rpi-agent only (NOT
rpi-harness). The harness consumes this crate’s adapters as trait objects
via AgentHarnessOptions injection, so rpi-harness never imports
rpi-extensions — cycle-free (verified by adversarial review of the first
draft, which wrongly added a rpi-harness dep).
§The async-across-ABI bridge (load-bearing — corrected vs first draft)
A plugin tool drives an execution through four plugin-exported fns
(execute→handle, poll, cancel, destroy) — see
rpi_plugin_sdk::ToolExecuteFn etc. The host’s PluginToolAdapter
impls AgentTool::execute by:
- NOT owning a runtime. Acquire the ambient runtime
(
tokio::runtime::Handle::try_current()) — the adapter only ever runs inside the agent loop’s runtime. - Set up an unbounded mpsc for
ToolResultPartial(async side drains + forwards toon_update) and a oneshot for the terminalAgentToolResult. handle.spawn_blocking(move || drive(plugin, cancel_flag, partial_tx, done_tx))— the blocking driver loopspolluntilDone/Err, forwardingPendingpartials through the mpsc (via acatch_unwindtrampoline so a panicking partial callback can’t unwind across FFI), then sends the terminal result + callsdestroyexactly once on exit.- The async future
select!s between the oneshot (terminal) andsignal.cancelled()(the child token). On cancel: set anAtomicBoolcancel flag (SeqCst) so the blocking driver observes it; keep awaiting the oneshot (never drop the driver —spawn_blockingtasks run to completion regardless of outer-future drop, so dropping is a thread leak). Drop/drop-guard sets only the cancel flag — never callsdestroysynchronously (the driver may be mid-poll);destroyis called exactly once by the blocking driver.
cancel ≠ destroy: the first draft conflated them → UAF/double-free. Here
cancel is an idempotent thread-safe flag-set; destroy is the single
free, owned by the driver.
Wake-capable tools use an owned async driver with serialized, short
spawn_blocking FFI calls. Wake notifications are retained across polls;
an idle Pending execution occupies no blocking worker.
§Events
ExtensionEmitter impls AgentEmitter by subscribing to the host’s
broadcast::Sender<AgentEvent>, translating each AgentEvent → a
rpi_plugin_sdk::StablePluginEvent, and dispatching to every registered
handler for the event’s tag — all dispatch wrapped in catch_unwind. The
33-category on() surface (B3) is driven through this emitter; the
10 already-emitted AgentEvent variants fold into the matching tags now,
and the remaining tags light up as B3/B4/B5 add the emission points.
Structs§
- Action
Bridge - The host-side bridge carried in [
PluginApi::user_data] sotrampoline_runtime_actioncan recover the harness state from any thread. - Before
Agent Start Handler - A registered
before_agent_starthandler (system-prompt transform). Same ownership shape asResourcesDiscoverHandler: the handler produces a plugin-ownedout[StbString] the host reclaims via the plugin’s ownplugin_free_string, which therefore travels alongside.user_datais the plugin’s opaque context. - Discovered
Resources - The merged
resources_discoverresult across all handlers: bare string arrays for skills, prompt-templates, and themes.theme_pathsis collected for parity but rpi has no theme system yet (accepted, ignored, documented). - Editor
Text Edit - One pending edit to apply to the editor.
- Editor
Text Mailbox - Shared, cloneable handle to the pending-edit queue.
- Event
Logger - A best-effort append-only JSONL sink.
writerisNonewhen logging is disabled (env gate off, or the file could not be opened). - Extension
Emitter - An
AgentEmitterthat fans eachAgentEventout to every plugin handler registered for the event’s tag. Built from aRegistrySnapshot(so it shares the registry’s staleness flag) and installed intoAgentHarnessOptions.agent_emitteralongside the host’s [BroadcastEmitter] — events flow to BOTH the TUI (which drains the broadcast receiver) and the plugin handlers (which receive translatedStablePluginEvents). The host composes the two viaTeeEmitter; this emitter alone only dispatches to plugins. - Extension
Panel - Extension
Provider Hooks - A
ProviderHooksthat dispatches to the registered extension handlers. Keeps the cdylib mappings alive via the keepalive (the handler fn pointers live inside the plugins). - Extension
Registry - Accumulates registrations from one or more plugins’
rpi_plugin_registercalls. Held byHostApiduring register; the host thentake_registryand builds a snapshot. - Extension
Session - The result of loading a session’s worth of extensions: a shared keepalive for
the cdylib handles + a snapshot of the merged registry. Built by
load_session; the host (pi-cli) stashes one per harness build and hands clones of the keepalive to each adapter it constructs from the snapshot. - Extension
Status Mailbox - Shared, cloneable handle to the extension status registry.
- Extension
Tool - A tool an extension registered: the provider-facing
Toolschema + the plugin’s 4-function handle (PluginToolHandle) the host drives viaPluginToolAdapter. - HostApi
- The host state a [
PluginApi] closes over. Held behindArcso the fn pointers (which areextern "C",不好做闭包) can recover the host state via theuser_dataslot — but sinceextern "C" fncannot capture, the host stores per-registration receiver state in theHostApiitself keyed by nothing (single registry per host), and the fns are thin trampolines that read a process-global-attached registry. The host builds oneHostApiper load session; theregister_*trampolines forward into it. - Loaded
Plugin - A successfully loaded + registered plugin. Holds the
Libraryso the cdylib stays mapped for the session. Dropping this schedules final shutdown and unloading (do not drop while any of its FFI calls may still be running). - Null
Diagnostics - A no-op diagnostics sink (the default when the host does not supply one).
- OAuth
Action Response - OAuth
Provider Info - A non-secret view of a registered OAuth provider.
- Panel
Highlight - Pluggable
Provider - A
Providerbacked by a plugin’s syncProviderRequestFn. - Plugin
Keepalive - Owns the loaded
Libraryhandles so the cdylibs stay mapped for as long as any registered tool/handler (whose fn pointers live inside the cdylib) may be called. Shared viaArc: everyPluginToolAdapter(and, in B3, theExtensionEmitter) holds a clone, so the libraries unload only when the last holder drops — which is never before the harness’s tool vec (and thus the last possible tool call) drops. - Plugin
Tool Adapter - An
AgentToolbacked by a plugin’s 4-function handle. One adapter is built per registered tool (schemacopied from the registration) and inserted into the session’s tool set in B2. - Plugin
Tool Handle - The plugin’s per-tool lifecycle bundle the host holds after a successful
register_tool. All fields are fn pointers (Copy), so the handle isCopy: cloning duplicates the pointers, not any allocation. A registered tool is driven by at most onePluginToolAdapterat a time, but the handle is copied through the registry snapshot path, henceCopy. - Provider
Manifest - A provider package’s catalog and authentication policy, supplied through
declare({"providers":[...]}). Credentials are always resolved by the host. - Registered
Flag - A CLI flag declared by a native extension. Values are supplied by the
host’s parsed
Args::unknown_flagsmap and read by the plugin through theRuntimeActionId::GetCliFlagaction. - Registered
Handler - A registered
on(tag)event handler.user_datais the plugin’s opaque context, passed back unchanged on every dispatch.pluginis the display name of the owning extension (host-side only — stamped from the cdylib path at registration; not part of the ABI), used for lifecycle veto diagnostics (“extensionfooabortedbefore_tui_start”). - RegisteredO
Auth Provider - A provider-owned OAuth implementation.
manifest_jsonis validated JSON copied from the plugin; the callback receives action envelopes such as{\"action\":\"begin\"}and returns plugin-owned JSON. - Registered
Provider - A registered custom provider (B5c). The host wraps
request_fnin aPluggableProviderimpl ofrpi_ai::Provider; itsstream_simpledrivesrequest_fnonspawn_blocking(the sync fn can’t own a chunked stream), reads the plugin-ownedoutJSON (a full assistant message), reclaims it viaplugin_free_string, parses it to anAssistantMessage, and emits it as one terminalDonechunk (v1 one-shot, documented divergence from pi’s async streaming).provider_id/base_url/api_stylecarry the provider’s identity (copied from the borrowedStbStringRefs at registration); the fn pointers +user_datalive as long as the plugin (keepalive-mapped).user_datais the plugin’s opaque context, passed back on everyrequest_fncall. - Registered
Renderer - A registered message/markdown/entry renderer (B5c). The interactive TUI
consumes all three kinds through the JSON component adapter.
render_fnproduces a plugin-ownedout[StbString] the host reclaims viaplugin_free_string;user_datais passed back on every render call.nameis copied from the borrowedStbStringRefat registration. - Registered
Shortcut - A keyboard shortcut declared by a native extension (e.g.
"space"for push-to-talk). The host records it so the TUI can route keys to it; the owning plugin receives the key via itsInputevent handler. - Registry
Snapshot - An immutable snapshot of an
ExtensionRegistrythe host session keeps for its lifetime. Tools are wrapped inExtensionToolso the host can buildPluginToolAdapters; event handlers are grouped by tag for theExtensionEmitterto fan out. - Reload
Mailbox - A reload-signal mail slot (B5d). The reload callback (built by
reload_callback_from_mailbox) captures a clone; the TUI installs atokiounbounded sender after it starts. When a plugin callsruntime_action(Reload), the callback signals()(if a TUI is installed) and the TUI performs the reload asynchronously — the plugin’s call returnsOk(null)immediately, so the calling plugin’s cdylib is NOT unmapped while itsruntime_actionframe is still on the stack (the reload, which drops the old keepalive, happens after the call returns). This breaks the self-unmapping race a synchronous plugin-initiated reload would have. - Resources
Discover Handler - A registered
resources_discoverhandler (B5b). Theout[StbString] the handler produces is plugin-owned, so the host reclaims it via the plugin’s ownplugin_free_stringtraveled alongside.user_datais the plugin’s opaque context. SAFETY: same asRegisteredHandler— the plugin warrantshandleris callable from any thread anduser_datais valid for the registry’s lifetime; the host never freesuser_data. - Stdio
Lifecycle - TeeEmitter
- An
AgentEmitterthat forwards every event to each of its children, in registration order. The host builds one around[BroadcastEmitter (→ TUI), ExtensionEmitter (→ plugin handlers)]so a singleAgentHarnessOptions .agent_emitterslot feeds both consumers: the TUI keeps rendering from its broadcast receiver, and pluginon()handlers receive translatedStablePluginEvents. - Tool
Call Context - Per-session context the host injects into plugin tool arguments under
rpi_plugin_sdk::HOST_CONTEXT_KEY. - UiDialog
Mailbox - Thread-safe mailbox connecting plugin runtime actions to an interactive host (normally the TUI). It deliberately carries only JSON and primitives, so the extension crate remains independent of the UI crate.
- UiDialog
Request - A pending host UI request emitted by a plugin runtime action.
Enums§
- Editor
Text Mode - How a pending edit combines with what the editor already holds.
- OAuth
Extension Error - OAuth
Response Kind - Standard response kinds understood by the host. Payload contents remain
provider-defined except for
credential. - Panel
Anchor - Panel
Highlight Color - Theme colors for literal text highlights; terminal escapes are never accepted.
- Panel
Layout - Plugin
Load Error - Error / skip reason from loading one plugin.
Skipvariants are non-fatal (logged via diagnostics);Fatalmeans the load itself failed. - Plugin
Tool Error - Error returned by [
PluginToolAdapter::execute] when the plugin side failed (terminalErrfrompoll, or the drive handle was never produced). - Registered
Renderer Kind - Which render path this renderer targets — mirrors the three distinct
register_*slots (register_message_renderer/register_markdown_transformer/register_entry_renderer). - Registry
Entry - One flat registration record, for iteration/diagnostics. Built on demand
from the typed vecs in
RegistrySnapshot.
Constants§
- DEFAULT_
PRIORITY - Default dispatch priority for a plugin that declares none. Handlers run smallest-priority-first; ties break by registration order.
Traits§
- Plugin
Diagnostics - A diagnostics/event sink the host wires so the loader + adapter can report
plugin-load skips, ABI mismatches, panics caught at the FFI boundary, etc.
Mirrors the
diagnosticchannel pi surfaces for extensions. - Runtime
Action Host - The host-side implementation the bridge delegates to. Defined in
rpi-extensions(NOTrpi-harness) so the crate DAG stays a leaf: this is a trait the host (rpi-cli) implements over the harness —rpi-extensionsonly names the async surface + carries JSON params/results. Norpi-harnesstypes appear in the trait.
Functions§
- assert_
active - Staleness guard:
truewhile the owning session is still active. Called before dispatching an extension event or driving an extension tool so a stale registry (from a swapped-out session) can’t act. Mirrors pi’sExtensionRuntimeStateactive check. - current_
platform - The host platform, as matched against a plugin’s declared
platforms. Platform set used by extension capability declarations. - dispatch_
data_ event - Build + dispatch a generic
dataevent (JSON payload) to the tag’s handlers. Returns whether any handler was invoked. Used by the B4 provider-hook observer path ([crate::provider_hooks]) which has no matchingAgentEventto translate. - dispatch_
data_ event_ claiming - Like
dispatch_data_event, but reports whether a handler claimed the event by returning [EVENT_HANDLER_CLAIMED]. - dispatch_
empty_ event - Build + dispatch a no-payload event (e.g.
session_start,session_shutdown, the rpi-specificbefore_tui_start) to the tag’s handlers. Returns whether any handler was invoked. Mirrorsdispatch_data_eventfor events whose payload isEventEmpty; a handler that subscribes to a no-payload tag receives the event with an empty payload. - dispatch_
lifecycle_ event - Veto-aware, timeout-bounded dispatch for a no-payload lifecycle event
(
BeforeTuiStart/SessionStart/SessionShutdown). - dispatch_
tool_ call_ event - Dispatch a tool approval against a live session. Only a successful continue permits execution; stale sessions, cancellation, timeout and handler errors block it. Blocking callbacks retain the plugin libraries until they return, even when the async caller has already stopped waiting.
- emit_
before_ agent_ start - Fan the
before_agent_startprompt transform out to every registered handler and return the prompt the request should carry. - emit_
resources_ discover - Fan the
resources_discoverevent out to every registered handler in registration order and merge their returned paths. - event_
log_ path - Resolve the event-log path (
$RPI_EVENT_LOG_PATH, else<root>/logs/events.jsonl, where<root>is$RPI_CODING_AGENT_DIR’s parent or~/.rpi). Public sorpi events tailcan find the same file the logger writes. - host_
free_ ⚠string - Producer-side deallocator for strings allocated by the host’s SDK instance. Plugin-produced strings must use the plugin’s registered deallocator. ABI deallocation callback for strings produced by this SDK instance.
- list_
oauth_ providers - List OAuth manifests without exposing callbacks or user data.
- load_
dir - Load every cdylib in
dir(non-recursive). Each load failure is logged viadiagnosticsand skipped (one bad plugin doesn’t abort the rest). Returns the successfully loaded plugins in directory order. - load_
one - Load and register one cdylib plugin. Returns the live plugin + its
registry, or a
PluginLoadError(skip-fatality distinction is on the caller; both are logged viadiagnostics). - load_
session - Load + register every cdylib in the given dirs (in order, non-recursive),
merge their registries first-wins, and return a session with a shared
keepalive over the
Libraryhandles + the merged snapshot. Dirs that don’t exist are skipped silently; individual plugin load failures are logged viadiagnosticsand skipped (one bad plugin doesn’t abort the rest). - load_
session_ mixed - Load plugins from a mix of scanned dirs and explicit cdylib files
(the
--extension/-eCLI paths), assembled into one session. Mirrorsload_sessionbut additionallyload_ones each explicit file. - log_
handler_ invocation - Record one handler invocation on the global logger. No-op when logging is disabled (the common case).
- merge_
registries - Convenience: merge a slice of per-plugin
LoadedPluginregistries into one session registry, first-wins on name (mirrors pi’s cross-extension registration order). Consumes the registries (theLoadedPlugins themselves stay alive — callers keep theLibraryhandles). - platform_
allows - Whether a handler with the given declared
platformsshould run on this host. Emptyplatforms= all platforms; a non-empty list requires the host platform to be present ("all"is accepted as a wildcard). - reload_
callback_ from_ mailbox - Build the reload callback the bridge carries, backed by a
ReloadMailbox. When a plugin callsruntime_action(Reload), the bridge’s spawn site awaits this callback, which signals the TUI (if installed) and returns; the plugin receivesOk(null)and the TUI performs the reload asynchronously. If no TUI is installed, the callback returns without signalling and the host’sRuntimeActionHost::reloadfallback surfaces the “not configured” error. - request_
oauth - Invoke one provider-owned OAuth action.
- trampoline_
runtime_ action - The real
runtime_actiontrampoline — replacesstub_runtime_actionwhen a bridge is present (seeHostApi::build_vtable).
Type Aliases§
- Side
Question Sink - Host-only, owner-bound UI writer. It contains no plugin callbacks or pointers.