rpi-plugin-sdk 0.3.23

Stable #[repr(C)] ABI contract for rpi Rust-native (cdylib) plugins — no rpi-* deps
Documentation

rpi-plugin-sdk

crates.io docs.rs

The stable #[repr(C)] ABI contract for rpi plugins. Depend on this crate, and nothing else, to build a Rust cdylib that extends the rpi agent with tools, providers, slash commands, event handlers, renderers and discovered resources.

This crate has no dependency on the rpi runtime on purpose. A plugin must not link the host — the host links the plugin. Both sides share only the types defined here.

Install

[package]
name = "my-rpi-extension"

[lib]
crate-type = ["cdylib"]

[dependencies]
rpi-plugin-sdk = "0.3"
serde_json = "1"

Minimal plugin

use rpi_plugin_sdk::{export_plugin, StbStringRef};

export_plugin!(|api| {
    // Optional: declare a load priority and the platforms this plugin supports.
    if let Some(declare) = api.declare {
        declare(StbStringRef::from_str(r#"{"priority":60,"platforms":["linux"]}"#));
    }

    // ... register tools, providers, commands and handlers through `api` ...

    0 // status code: 0 = success
});

The macro emits the rpi_plugin_register symbol. The version lives inside the PluginApi struct (abi_version, struct_size) rather than in the symbol name, so the entrypoint signature does not change when the struct grows.

Safe plugin helpers

handles::ToolHandles<T> keeps tool state inside the plugin and returns opaque numeric StepHandle tokens. get retains an Arc, lock serializes state updates, and cancel sets a separate atomic flag without waiting for a poll. remove cancels and retires the token; repeated removal and unknown handles are no-ops. An in-flight callback keeps its state alive until it returns. Workers can share the flag through insert_with_cancel.

event_handler! defines an event callback with a tag-checked EventView and panic containment. Its accessors return owned strings, so handlers never read the ABI union directly or retain host pointers. Event buffers are borrowed during fan-out and must not be freed by handlers.

rpi_plugin_sdk::event_handler! {
    fn observe_message(event, _user_data) {
        if let Some(message) = event.message_text() {
            println!("{message}");
        }
        rpi_plugin_sdk::EVENT_HANDLER_CONTINUE
    }
}

These helpers preserve the C ABI. They require an SDK release containing the helpers; packages using a local path dependency must publish that SDK before publishing dependent packages.

string_handler! adapts command, prompt and runtime-action callbacks to &str inputs and a lifetime-bound callbacks::Output. The tuple-input form fn discover((cwd, reason), output, user_data) supports resource discovery. Output::write handles null slots and reclaims earlier writes without freeing host-owned input. The SDK's callbacks::free_string is the producer-side ABI deallocator to register for SDK-allocated outputs. The two-argument execute_handler! form adapts borrowed tool call IDs and gives the body an OwnedString parameter, reclaiming it even on refusal or invalid UTF-8 in the call ID. Its three-argument form preserves raw parameters and their deallocator for custom ABI handling. take_step_result! copies and reclaims a plugin-owned result into the pointer-free callbacks::Step enum. It requires an explicit unsafe call site, just like the underlying function; the macro cannot prove an arbitrary result's ownership. callbacks::take_step_result_with uses a specified producer's deallocator when receiving a result from another plugin. These are ABI boundaries: the host must still honor buffer validity and producer allocation ownership.

Raw string readers StbString::to_string_lossy and json::from_stable require an explicit unsafe call: their public ABI pointer/length fields cannot prove that a buffer is still valid. Keep the allocation live and unchanged during the copy, and free owned strings exactly once through their producer afterward. This tightens the Rust source API without changing the C ABI. Code using EventView or the callback trampolines continues to use owned Rust strings in its handler body.

callbacks::OwnedString owns a string together with its producer's deallocator. It cannot be copied or cloned; moving it transfers ownership, and dropping it reclaims the allocation even on early returns or unwinding. from_string safely creates an SDK-owned string. Adopting raw ABI output through from_raw requires one explicit unsafe ownership contract, after which reading and cleanup are safe. into_raw transfers ownership back to an ABI receiver without deallocating it. Raw StbString::free_with, FreeStringFn, and SDK deallocation callbacks now require unsafe calls because their public pointer inputs cannot prove unique ownership or allocator identity. Tool partial callbacks reclaim plugin-produced buffers through the tool's registered plugin deallocator, never the host allocator.

The optional process feature provides process::ProcessTree for terminating descendants and closing inherited pipes. Its Win32/libc calls and owned handles stay in the SDK; packages can enable forbid(unsafe_code) while using the platform-independent interface.

Registered TUI text components

Use RuntimeActionId::UiComponent (22). Each plugin owns its own component IDs. Register once, then send content updates without repeating the layout:

{"op":"register","id":"monitor","type":"text","slot":"footer.after","order":10,"width":240,"maxHeight":1,"border":false,"visible":false}
{"op":"update","id":"monitor","text":"TPS 252 · TTFT 0.42s"}
{"op":"show","id":"monitor"}
{"op":"configure","id":"monitor","slot":"sidebar.right","width":40,"maxHeight":8}
{"op":"hide","id":"monitor"}
{"op":"remove","id":"monitor"}

Registration is idempotent and preserves existing content, visibility and layout. update accepts text and optional title; layout fields are rejected. configure changes layout while preserving text and visibility. Hidden components retain state. Updating an unregistered ID returns an error. Plugin unload removes its components. Use RuntimeActionId::SetStatus with key and value for legacy footer statuses; the old panel payload has been removed.

Slots: footer.after, sidebar.left, sidebar.right, overlay. Components sort by order (default 0), then plugin identity and ID. Layout fields: anchor, offsetX, offsetY, width, maxHeight, minScreenWidth, border. Anchors use the nine conventional positions from top-left to bottom-right, with left-center and right-center. Width is 4–240 columns, maxHeight is 1–80 rows. Content is limited to 64 lines, 2048 UTF-8 bytes per line and 256 bytes per title. Controls are stripped by the renderer and Unicode display widths are respected. Each plugin may register 32 components. Invalid changes preserve the previous state.

Footer rows render below usage in both terminal modes. Sidebars reserve space in fullscreen mode; native scrollback shows their content above the editor. Overlays use the visible transcript viewport. Passive components do not change input focus. Headless hosts store components without rendering them. Older hosts reject action 22; plugins should handle that error. This API supports text components; interactive questions continue to use UiDialog.

Why a hand-written C ABI

Both sides are Rust, but they are compiled separately and may use different compiler versions and different versions of this crate. That rules out passing any Rust type that is not #[repr(C)] or that has a Drop impl. The design rules the SDK enforces:

  1. Every crossing type is #[repr(C)], and enums in unions carry an explicit #[repr(u32)] so the discriminant width is pinned.
  2. No Drop type crosses. No Vec, String, serde_json::Value, Option of those, or Result. Owned strings cross as StbString (pointer + length) together with an explicit free_string function the producer exports, so each allocation is freed exactly once, by the side that received it.
  3. Structured data crosses as JSON in a StbString. serde_json must be configured consistently on host and plugin (preserve_order + arbitrary_precision), or integers beyond u64/i64 lose precision and object key order can change.
  4. Unions hold Copy payloads only, so a variant read is unsafe and the caller discriminates on the tag.
  5. Unwinding never crosses the boundary. Every call is extern "C", and both sides contain panics with catch_unwind; a panic is converted into a status code rather than aborting the process.

These are not stylistic preferences — violating any of them is undefined behaviour at the boundary.

Compatibility

The host loader only resolves rpi_plugin_register, exported with export_plugin!. The host passes a PluginApi containing registration slots, runtime_action and declare (priority and platforms).

The SDK checks abi_version against RPI_PLUGIN_ABI_VERSION_UNIFIED and requires struct_size to cover the PluginApi expected by the plugin before calling its registration body. A mismatch returns a nonzero status and the host reports a load failure. Extensions using retired entrypoints must be updated to export_plugin! and rebuilt with the current SDK.

Versioning

A plugin depends on rpi-plugin-sdk only, so its version is decoupled from the rest of the family. ABI compatibility is checked through abi_version and struct_size; each optional capability must also be checked before use. Rebuild extensions when the SDK contract they require changes.

Examples and docs

License

MIT. See LICENSE and NOTICE.

Exclusive stdio servers

During registration, declare {"ownsStdio":true} requests exclusive ownership of the process's stdin/stdout. Each plugin has its own runtime context; two owners cause a startup error, including when their file stems are identical. Failed registration releases its claim. The host leaves stdio untouched and runs the normal session lifecycle without rendering a TUI.

Use RuntimeActionId::StdioLifecycle (21) with {"op":"status"} to check support and with {"op":"finish","exitCode":0} to signal EOF or server completion. Only the owner can finish, and early completion is retained. The host dispatches SessionShutdown and returns the code. Stop and join plugin workers in that handler; do not terminate the process from a plugin.

The optional stdio feature provides stdio::StdinLines::poll_line. It polls pipe/file input without a background stdin task that can block shutdown. Wait between InputLine::Pending results using the server's own cancellation mechanism. The caller must exclusively own stdin. Windows console input is unsupported by this pipe/file server helper.

Dialog purpose

UiDialog (17) keeps its open/poll/cancel protocol. A presentation kind such as selector does not imply permission. Omitted ui.purpose means question; purpose: "permission" requires a nonempty toolCallId, a flat single-choice confirm/selector and explicit decision metadata on every option:

{
  "op": "open",
  "requestId": "approval-1",
  "toolCallId": "call-1",
  "ui": {
    "purpose": "permission",
    "kind": "confirm",
    "question": "Allow this tool?",
    "options": [
      {"title": "Allow once", "permission": "allow_once"},
      {"title": "Reject", "permission": "reject_once"}
    ]
  }
}

Decisions are allow_once, allow_always, reject_once, or reject_always. Titles are nonempty, unique and have no surrounding whitespace. Permission questions do not accept nested questions, multiple answers or freeform input. dialogs::validate and dialogs::permission_options share these checks between hosts and adapters. Answers retain the selected original title in values. Transport adapters that cannot show ordinary questions cancel them explicitly; they must not reinterpret them as permission choices.