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.
§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 unloads the plugin (do not drop while any of its tool drivers 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.
- 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. - 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
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 - The host’s
free_stringforStbStrings the host produces and hands to the plugin (event payloads, action outputs, execute params when the host owns them). The plugin frees what it receives via this; the host frees what it receives via the plugin’sfree_string(stored per-tool). - 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).