Skip to main content

rpi_plugin_sdk/
lib.rs

1//! Stable `#[repr(C)]` ABI contract for rpi **Rust-native (cdylib) plugins**.
2//!
3//! rpi loads extensions as compiled Rust cdylibs (`.dll`/`.so`/`.dylib`) via
4//! `libloading` — **not** TS/jiti. Because we control both sides, the plugin is
5//! Rust, but the *boundary* is still a hand-defined C ABI: the two sides may be
6//! compiled with different Rust versions / crate versions, so no Rust type with
7//! a non-`C` repr or a `Drop` impl may cross. This crate defines exactly those
8//! crossing types and the registration contract.
9//!
10//! ## Soundness rules (load-bearing — verified by an adversarial review)
11//!
12//! 1. **Every crossing type is `#[repr(C)]`.** Enums used in unions carry
13//!    `#[repr(u32)]` so the discriminant width is pinned.
14//! 2. **No `Drop` type crosses.** `Vec`/`String`/`serde_json::Value`/`Option` of
15//!    those / `Result` never appear in the ABI. Owned data crosses as
16//!    [`StbString`] (ptr+len) with an **explicit `free_string`** the producer
17//!    exports. [`StbString`] is `Copy` (raw pointers are `Copy`); copying
18//!    duplicates the *pointer*, not the allocation, so each allocation is freed
19//!    **exactly once** by the side that received it (see [`StbString`] docs).
20//! 3. **Owned → JSON round-trip.** Structured host data ([`StableJsonValue`],
21//!    tool params, `AgentToolResult`, events) crosses as a JSON string in a
22//!    [`StbString`]. `serde_json` with `preserve_order` + `arbitrary_precision`
23//!    must be enabled **consistently on host AND plugin** or integers >
24//!    `u64`/`i64` lose precision and object keys may reorder — documented as a
25//!    an ABI-wide limit. Tool args from the model rarely carry overflow ints, but it is
26//!    never silent.
27//! 4. **Unions are all-`Copy` payloads.** [`EventPayload`] / [`StepResultPayload`]
28//!    variants are `#[repr(C)]` structs of primitives or [`StbString`] only, so
29//!    the union is `Copy`-able and a wrong-variant read is `unsafe` (caller
30//!    discriminates by `tag`).
31//! 5. **Unwinding never crosses the ABI.** Every host→plugin and plugin→host
32//!    call is `extern "C"`; both sides wrap dispatch in `catch_unwind`
33//!    (abort-on-unwind / log-and-drop). A poisoned mutex or panicking emitter
34//!    cannot unwind into the other side.
35//!
36//! ## Lifetime: the 4-function handle
37//!
38//! A registered tool drives an execution through **four** plugin-exported
39//! functions (see [`ToolExecuteFn`] / [`ToolPollFn`] / [`ToolCancelFn`] /
40//! [`ToolDestroyFn`]) — `execute`→[`StepHandle`] (plugin-allocates), `poll`
41//! (non-blocking, **borrows** the handle, returns [`StepResult`]), `cancel`
42//! (sets an internal `AtomicBool` flag; **idempotent; does NOT free;
43//! thread-safe**), `destroy` (frees; **idempotent; called exactly once by the
44//! blocking driver**). `cancel` ≠ `destroy`: conflating them is a UAF /
45//! double-free. The adapter's blocking driver calls `poll` in a loop until
46//! `Done`/`Err`, forwards `Pending` partials, and calls `destroy` once on exit.
47//!
48//! `poll` is **non-blocking** and MUST observe the cancel flag and return
49//! `Done`/`Err` within a bounded number of polls; otherwise a cancelled call
50//! leaks a `spawn_blocking` thread forever (those tasks run to completion
51//! regardless of outer-future drop).
52//!
53//! The crate is `std` (not `no_std`): the ABI *types* are `#[repr(C)]` POD with
54//! no `Drop` — that is what makes the boundary sound — but plugins and the host
55//! are ordinary binaries with `std`, so the constructor/reader helpers and tests
56//! use `String`/`Vec`/`serde_json` directly. The `json` feature keeps
57//! `serde_json` optional for a plugin that wants to skip it.
58
59use core::ffi::c_char;
60use core::ffi::c_void;
61use core::ptr;
62
63pub mod callbacks;
64#[cfg(feature = "json")]
65#[cfg(feature = "json")]
66pub mod dialogs;
67#[cfg(feature = "json")]
68pub mod events;
69pub mod handles;
70#[cfg(feature = "process")]
71pub mod process;
72#[cfg(feature = "stdio")]
73pub mod stdio;
74
75// ---------------------------------------------------------------------------
76// StbString — owned UTF-8 crossing as ptr+len with an explicit free
77// ---------------------------------------------------------------------------
78
79/// An owned UTF-8 string crossing the ABI as a `(ptr, len)` pair.
80///
81/// `Copy` (raw pointers are `Copy`): copying a `StbString` duplicates the
82/// **pointer**, not the allocation. The **receiver** of a `StbString` owns the
83/// allocation and MUST free it **exactly once** by calling the producer's
84/// [`FreeStringFn`] (or [`StbString::free_with`] / [`StbString::free_host`]).
85/// Never `free` a `StbString` you did not receive as an owner (e.g. one built
86/// from a borrow via [`StbString::from_ref`], which is non-owning — its `free`
87/// is a no-op only if the producer guarantees the buffer outlives the call; in
88/// practice inputs cross as [`StbStringRef`] instead).
89///
90/// **Construction ownership contract:**
91/// - [`StbString::from_owned`] — takes a `Box<[u8]>` the caller allocated; the
92///   `StbString` now owns it; `free` deallocates.
93/// - [`StbString::from_boxed_str`] / [`StbString::from_string`] — convenience
94///   over `from_owned` (host side, needs `alloc`).
95/// - [`StbString::empty`] — null/0; `free` is a no-op.
96///
97/// **Null `ptr` ⇒ empty** (`len` MUST be 0). A null pointer is never
98/// dereferenced.
99#[repr(C)]
100#[derive(Clone, Copy)]
101pub struct StbString {
102    /// UTF-8 bytes. Null when `len == 0` (empty string).
103    pub ptr: *mut c_char,
104    /// Byte length (NOT a NUL terminator — the buffer is NOT NUL-terminated).
105    pub len: usize,
106}
107
108// SAFETY: `StbString` is a plain `(ptr, len)` of raw pointers — no ownership
109// transferred across threads by the type itself, and lifetime/aliasing is the
110// caller's contract (documented above). It is `Send`+`Sync` so the host and the
111// blocking driver can pass it across threads; the *allocation* ownership rules
112// above still apply regardless of thread.
113unsafe impl Send for StbString {}
114unsafe impl Sync for StbString {}
115
116impl StbString {
117    /// An empty string: null pointer, zero length. `free` is a no-op.
118    pub const fn empty() -> Self {
119        Self {
120            ptr: ptr::null_mut(),
121            len: 0,
122        }
123    }
124
125    /// Whether this is the empty/null string.
126    pub const fn is_empty(&self) -> bool {
127        self.len == 0
128    }
129}
130
131// Allocating constructors + safe reader need `std` (Box/Vec/String). The ABI
132// type itself (`StbString { ptr, len }`) is POD with no `Drop` — that is what
133// makes the boundary sound. These helpers are available whenever `std` is (the
134// crate is std-using). When the `json` feature is off a plugin still gets these
135// because the crate links std; the gate here keeps `serde_json` truly optional.
136#[cfg(any(feature = "json", test))]
137impl StbString {
138    /// Wrap an allocation the caller already boxed. The `StbString` takes
139    /// ownership: a later `free_with(free_fn)` will deallocate it via the
140    /// producer's `free_string`.
141    ///
142    /// The buffer MUST be UTF-8.
143    pub fn from_owned(buf: Box<[u8]>) -> Self {
144        let len = buf.len();
145        // Stabilize the pointer via `Box::into_raw`; the free fn reconstructs a
146        // slice from ptr+len and drops it. Store the element pointer as
147        // `*mut c_char` (u8 ↔ c_char on every platform rpi targets). Ownership
148        // moves into the `StbString` (we do NOT drop here).
149        let ptr = Box::into_raw(buf) as *mut [u8] as *mut u8 as *mut c_char;
150        let _ = len;
151        Self { ptr, len }
152    }
153
154    /// Convenience: from a `String`, transferring ownership. After this the
155    /// passed `String` is consumed and must not be reused.
156    pub fn from_string(s: String) -> Self {
157        Self::from_vec(s.into_bytes())
158    }
159
160    /// Convenience: from a `Vec<u8>` (UTF-8), transferring ownership.
161    pub fn from_vec(v: Vec<u8>) -> Self {
162        Self::from_owned(v.into_boxed_slice())
163    }
164
165    /// Convenience: from a boxed `str`.
166    pub fn from_boxed_str(s: Box<str>) -> Self {
167        let string: String = s.into();
168        Self::from_vec(string.into_bytes())
169    }
170
171    /// Copy this `StbString`'s bytes into an owned `String` (**without** freeing
172    /// the original — the caller still owns the original allocation). Use this
173    /// to *read* a received `StbString` into safe Rust; then `free` the original.
174    pub fn to_string_lossy(&self) -> String {
175        if self.len == 0 || self.ptr.is_null() {
176            return String::new();
177        }
178        // SAFETY: the producer guarantees `ptr` is valid for `len` bytes and the
179        // bytes are UTF-8. We only read (no free) here.
180        let slice = unsafe { core::slice::from_raw_parts(self.ptr as *const u8, self.len) };
181        String::from_utf8_lossy(slice).into_owned()
182    }
183}
184
185/// A **borrowed**, non-owning view of a string passed as an *input* to an FFI
186/// call. The callee MUST NOT free it and MUST NOT retain it past the call.
187///
188/// Built from a `&str` on the calling side; the buffer is valid for the
189/// duration of the call (the caller's borrow).
190#[repr(C)]
191#[derive(Clone, Copy)]
192pub struct StbStringRef {
193    /// UTF-8 bytes (valid for the call; NUL-terminated not required).
194    pub ptr: *const c_char,
195    /// Byte length.
196    pub len: usize,
197}
198
199unsafe impl Send for StbStringRef {}
200unsafe impl Sync for StbStringRef {}
201
202impl StbStringRef {
203    /// Empty input.
204    pub const fn empty() -> Self {
205        Self {
206            ptr: ptr::null(),
207            len: 0,
208        }
209    }
210
211    /// Borrow a `&str` for the duration of a call. The caller must outlive the
212    /// call (normal borrow rules).
213    pub fn from_str(s: &str) -> Self {
214        Self {
215            ptr: s.as_ptr() as *const c_char,
216            len: s.len(),
217        }
218    }
219
220    /// Read into a safe `&str` for the callee's lifetime `'a`.
221    ///
222    /// # Safety
223    /// The caller guarantees `ptr` is valid for `len` bytes and they are UTF-8,
224    /// and the borrow survives `'a`.
225    pub unsafe fn as_str<'a>(&self) -> &'a str {
226        if self.len == 0 || self.ptr.is_null() {
227            return "";
228        }
229        let slice = unsafe { core::slice::from_raw_parts(self.ptr as *const u8, self.len) };
230        unsafe { core::str::from_utf8_unchecked(slice) }
231    }
232}
233
234/// Function pointer a plugin exports to free a [`StbString`] it produced.
235/// Idempotent: freeing an already-freed or empty `StbString` is a no-op.
236///
237/// The host calls this for every [`StbString`] it receives from the plugin; the
238/// plugin calls the **host's** `free_string` (from [`PluginApi`]) for every
239/// [`StbString`] it receives from the host.
240pub type FreeStringFn = extern "C" fn(s: StbString);
241
242impl StbString {
243    /// Free this `StbString` via the given `free_string` fn, if non-null and
244    /// non-empty. Consumes ownership (the value is `Copy`, but semantically the
245    /// caller relinquishes the allocation).
246    ///
247    /// After this call the bytes are invalid; do not use the `StbString` again.
248    pub fn free_with(self, free_fn: Option<FreeStringFn>) {
249        if let Some(free_fn) = free_fn {
250            if !self.is_empty() {
251                free_fn(self);
252            }
253        }
254    }
255}
256
257// ---------------------------------------------------------------------------
258// StableJsonValue — JSON round-trip helpers (json feature)
259// ---------------------------------------------------------------------------
260
261/// Helpers to cross structured data as JSON-in-`StbString`. See the module docs
262/// for the precision/order limit.
263#[cfg(feature = "json")]
264pub mod json {
265    use super::StbString;
266    use serde_json::Value;
267
268    /// Serialize a `serde_json::Value` into an owning [`StbString`] the receiver
269    /// must `free` via the producer's `free_string`.
270    pub fn to_stable(value: &Value, free_fn: Option<super::FreeStringFn>) -> StbString {
271        let s = serde_json::to_string(value).unwrap_or_else(|_| "null".to_string());
272        let stb = StbString::from_string(s);
273        // `free_fn` is advisory metadata the receiver needs; the StbString
274        // itself carries only ptr+len. Stash nothing — the receiver must know
275        // which free fn to use (host's vs plugin's) by direction.
276        let _ = free_fn;
277        stb
278    }
279
280    /// Parse a received [`StbString`] back into a `Value`. Does **not** free the
281    /// input — the caller still owns it.
282    pub fn from_stable(s: &StbString) -> Value {
283        let text = s.to_string_lossy();
284        if text.is_empty() {
285            return Value::Null;
286        }
287        serde_json::from_str(&text).unwrap_or(Value::Null)
288    }
289}
290
291// ---------------------------------------------------------------------------
292// StableToolSchema — the provider-facing tool definition
293// ---------------------------------------------------------------------------
294
295/// A tool's provider-facing schema crossing the ABI. `name` / `description` are
296/// raw strings; `parameters` is a JSON Schema serialized to a JSON string (the
297/// host parses it into its native `schemars::Schema`).
298///
299/// All three are owning [`StbString`]s the **plugin** produced; the **host**
300/// frees them via the plugin's `free_string` (passed to [`ToolExecuteFn`] /
301/// `register_tool`).
302#[repr(C)]
303#[derive(Clone, Copy)]
304pub struct StableToolSchema {
305    pub name: StbString,
306    pub description: StbString,
307    /// JSON-encoded JSON Schema for the tool's `parameters`.
308    pub parameters: StbString,
309}
310
311unsafe impl Send for StableToolSchema {}
312unsafe impl Sync for StableToolSchema {}
313
314// ---------------------------------------------------------------------------
315// Host context injected into plugin tool arguments
316// ---------------------------------------------------------------------------
317
318/// Reserved key the host adds to a **plugin** tool call's arguments.
319///
320/// The model never writes this field and never sees it: the host injects it
321/// after the model's arguments are parsed and before the plugin's
322/// [`ToolExecuteFn`] runs, so a plugin can tell *which* project and *which*
323/// session it is serving without the model having to supply either. (Built-in
324/// tools do not need it — they receive the host's `ExecutionToolContext`
325/// directly. A plugin tool has no such channel.)
326///
327/// The value is an object; today:
328///
329/// ```json
330/// { "cwd": "D:\\Projects\\pi-rust",
331///   "sessionId": "01adb026-7230-708b-855b-91eab6b056b8" }
332/// ```
333///
334/// Both fields are optional and either may be absent: `cwd` when the host
335/// cannot resolve one, `sessionId` for a session that has no id (ephemeral
336/// sessions) and on hosts that predate this field. The key itself may be absent
337/// entirely. Treat everything under it as best-effort, and prefer a
338/// project-scoped fallback when `sessionId` is missing rather than refusing to
339/// work.
340///
341/// **Consequence for plugin parameter types:** a plugin must tolerate unknown
342/// top-level keys in its arguments. Reading them out of a `serde_json::Value`
343/// does that naturally; a `#[serde(deny_unknown_fields)]` parameter struct
344/// would reject this key, so do not use one for tool parameters.
345///
346/// The host's value wins if the model happens to send the same key: this is
347/// host truth, not a hint to be merged.
348pub const HOST_CONTEXT_KEY: &str = "__rpi";
349
350// ---------------------------------------------------------------------------
351// StepResult — the poll() return: Pending | Done | Err (explicit tag + union)
352// ---------------------------------------------------------------------------
353
354/// Opaque, plugin-allocated handle for one tool execution drive. Produced by
355/// [`ToolExecuteFn`], polled by [`ToolPollFn`], cancelled by [`ToolCancelFn`],
356/// freed by [`ToolDestroyFn`] (exactly once, idempotent).
357///
358/// The handle's interior layout is entirely plugin-private; the host treats it
359/// as an opaque pointer.
360pub type StepHandle = *mut c_void;
361
362/// Discriminant for [`StepResult`]. `#[repr(u32)]` pins the width so the union
363/// payload is sound across compilers.
364#[repr(u32)]
365#[derive(Debug, Clone, Copy, PartialEq, Eq)]
366pub enum StepResultTag {
367    /// `poll` has no terminal result yet; the partial payload may carry progress.
368    Pending = 0,
369    /// Terminal success; `done.result` is the JSON `AgentToolResult`.
370    Done = 1,
371    /// Terminal failure; `err.message` is a UTF-8 error string.
372    Err = 2,
373}
374
375/// A partial/progress result emitted during `Pending`. `progress` is a JSON
376/// `AgentToolResult` (the same shape `on_update` carries) — the host forwards it
377/// to the adapter's `on_update` callback. May be empty.
378#[repr(C)]
379#[derive(Clone, Copy)]
380pub struct StbPending {
381    pub progress: StbString,
382}
383
384/// Terminal success payload. `result` is a JSON `AgentToolResult`.
385#[repr(C)]
386#[derive(Clone, Copy)]
387pub struct StbDone {
388    pub result: StbString,
389}
390
391/// Terminal failure payload. `message` is a UTF-8 error string.
392#[repr(C)]
393#[derive(Clone, Copy)]
394pub struct StbErr {
395    pub message: StbString,
396}
397
398/// The `poll()` return value. Read the `payload` variant matching `tag`.
399///
400/// All payload variants are `#[repr(C)]` structs of [`StbString`] (Copy), so the
401/// union is `Copy`. A wrong-variant read is `unsafe`; always match on `tag`.
402#[repr(C)]
403#[derive(Clone, Copy)]
404pub union StepResultPayload {
405    pub pending: StbPending,
406    pub done: StbDone,
407    pub err: StbErr,
408}
409
410/// Return value of [`ToolPollFn`]. The blocking driver matches on `tag`, reads
411/// the matching payload, and breaks the loop on `Done`/`Err`.
412#[repr(C)]
413#[derive(Clone, Copy)]
414pub struct StepResult {
415    pub tag: StepResultTag,
416    pub payload: StepResultPayload,
417}
418
419impl StepResult {
420    /// Build a `Pending` with a progress JSON string (may be empty).
421    pub fn pending(progress: StbString) -> Self {
422        Self {
423            tag: StepResultTag::Pending,
424            payload: StepResultPayload {
425                pending: StbPending { progress },
426            },
427        }
428    }
429
430    /// Build a `Done` with the terminal JSON `AgentToolResult`.
431    pub fn done(result: StbString) -> Self {
432        Self {
433            tag: StepResultTag::Done,
434            payload: StepResultPayload {
435                done: StbDone { result },
436            },
437        }
438    }
439
440    /// Build an `Err` with an error message.
441    pub fn err(message: StbString) -> Self {
442        Self {
443            tag: StepResultTag::Err,
444            payload: StepResultPayload {
445                err: StbErr { message },
446            },
447        }
448    }
449
450    /// Access the `pending` payload. Caller MUST guarantee `tag == Pending`.
451    ///
452    /// # Safety
453    /// Undefined behavior if `tag != StepResultTag::Pending`.
454    pub unsafe fn pending_payload(&self) -> &StbPending {
455        unsafe { &self.payload.pending }
456    }
457
458    /// Access the `done` payload. Caller MUST guarantee `tag == Done`.
459    ///
460    /// # Safety
461    /// Undefined behavior if `tag != StepResultTag::Done`.
462    pub unsafe fn done_payload(&self) -> &StbDone {
463        unsafe { &self.payload.done }
464    }
465
466    /// Access the `err` payload. Caller MUST guarantee `tag == Err`.
467    ///
468    /// # Safety
469    /// Undefined behavior if `tag != StepResultTag::Err`.
470    pub unsafe fn err_payload(&self) -> &StbErr {
471        unsafe { &self.payload.err }
472    }
473}
474
475// ---------------------------------------------------------------------------
476// The 4-function tool lifecycle fn-pointer types
477// ---------------------------------------------------------------------------
478
479/// Partial-result callback the **blocking driver** passes to `poll`, wrapped in
480/// `catch_unwind` on the host side. The plugin invokes it **synchronously
481/// inside `poll()`** when it has a `Pending` partial — never retained, never
482/// invoked after `Done`/`Err`.
483///
484/// `partial` is a JSON `AgentToolResult`; ownership passes to the callback (the
485/// host frees it via the host's `free_string`).
486pub type ToolPartialCb = extern "C" fn(partial: StbString, user_data: *mut c_void);
487
488/// `execute(tool_call_id, params) -> StepHandle`. Plugin-allocates a drive
489/// handle and begins the work (non-blocking — the real progress comes via
490/// `poll`). `tool_call_id` is a borrowed [`StbStringRef`] (valid for the call);
491/// `params` is an owning JSON string of the tool-call arguments (the plugin
492/// frees it via the host's `free_string`). Returns null on allocation failure.
493pub type ToolExecuteFn = extern "C" fn(
494    tool_call_id: StbStringRef,
495    params: StbString,
496    free_params: Option<FreeStringFn>,
497) -> StepHandle;
498
499/// `poll(handle, partial_cb, user_data) -> StepResult`. **Non-blocking.** Must
500/// observe the cancel flag (set by [`ToolCancelFn`]) and return `Done`/`Err`
501/// within a bounded number of polls. Borrows `handle` (does not free it).
502pub type ToolPollFn = extern "C" fn(
503    handle: StepHandle,
504    partial_cb: Option<ToolPartialCb>,
505    user_data: *mut c_void,
506) -> StepResult;
507
508/// `cancel(handle)`. Sets an internal `AtomicBool` (SeqCst) cancel flag.
509/// **Idempotent, thread-safe, does NOT free.** The poll loop observes it.
510pub type ToolCancelFn = extern "C" fn(handle: StepHandle);
511
512/// `destroy(handle)`. Frees the handle. **Idempotent; called exactly once by the
513/// blocking driver on exit** (after the loop sees `Done`/`Err`, or after cancel
514/// propagated). Null handle is a no-op.
515pub type ToolDestroyFn = extern "C" fn(handle: StepHandle);
516
517// ---------------------------------------------------------------------------
518// StablePluginEvent — 33 on() categories (explicit tag + union)
519// ---------------------------------------------------------------------------
520
521/// Discriminant for [`StablePluginEvent`], one variant per pi `on()` category
522/// (33 total) plus one rpi-specific category (34 total). `#[repr(u32)]` pins
523/// the discriminant width.
524///
525/// The 33 Pi categories (verified against `extensions/types.ts:1203-1244`):
526/// project_trust, resources_discover, session_start, session_info_changed,
527/// session_before_switch, session_before_fork, session_before_compact,
528/// session_compact, session_shutdown, session_before_tree, session_tree,
529/// context, before_provider_request, before_provider_headers,
530/// after_provider_response, before_agent_start, agent_start, agent_end,
531/// agent_settled, turn_start, turn_end, message_start, message_update,
532/// message_end, tool_execution_start, tool_execution_update,
533/// tool_execution_end, model_select, thinking_level_select, tool_call,
534/// tool_result, user_bash, input.
535///
536/// `BeforeTuiStart` is **rpi-specific** (not a Pi category): dispatched once
537/// before the interactive TUI is initialized, so extensions can prepare
538/// (connect a server, load a resource) before the UI starts. Appended last so
539/// existing discriminants keep their values (ABI-safe for already-compiled
540/// plugins).
541#[repr(u32)]
542#[derive(Debug, Clone, Copy, PartialEq, Eq)]
543pub enum EventTag {
544    ProjectTrust = 0,
545    ResourcesDiscover = 1,
546    SessionStart = 2,
547    SessionInfoChanged = 3,
548    SessionBeforeSwitch = 4,
549    SessionBeforeFork = 5,
550    SessionBeforeCompact = 6,
551    SessionCompact = 7,
552    SessionShutdown = 8,
553    SessionBeforeTree = 9,
554    SessionTree = 10,
555    Context = 11,
556    BeforeProviderRequest = 12,
557    BeforeProviderHeaders = 13,
558    AfterProviderResponse = 14,
559    /// The start of a run, carrying the user prompt (`{"prompt", "imageCount",
560    /// "sessionId", "host"}`). Fire-and-forget like every other tag: subscribe to
561    /// *observe* the turn. To **change** the system prompt for the run, register a
562    /// handler with `register_before_agent_start` — that slot carries an `out`
563    /// pointer, which this fire-and-forget signature cannot.
564    BeforeAgentStart = 15,
565    AgentStart = 16,
566    AgentEnd = 17,
567    AgentSettled = 18,
568    TurnStart = 19,
569    TurnEnd = 20,
570    MessageStart = 21,
571    MessageUpdate = 22,
572    MessageEnd = 23,
573    ToolExecutionStart = 24,
574    ToolExecutionUpdate = 25,
575    ToolExecutionEnd = 26,
576    ModelSelect = 27,
577    ThinkingLevelSelect = 28,
578    ToolCall = 29,
579    ToolResult = 30,
580    UserBash = 31,
581    Input = 32,
582    /// rpi-specific: dispatched before the interactive TUI initializes (not a
583    /// Pi `on()` category). Appended last so existing discriminants are stable.
584    BeforeTuiStart = 33,
585    /// Pi `on()` category: dispatched when the TUI shows an interactive prompt
586    /// (selector, dialog, etc.) that blocks agent work.
587    UiPromptStart = 34,
588    /// Pi `on()` category: dispatched when the TUI prompt is dismissed.
589    UiPromptEnd = 35,
590    /// rpi-specific (not a Pi `on()` category): dispatched whenever the
591    /// interactive prompt editor's **text changes** — typing, pasting, deleting,
592    /// or a programmatic rewrite. Appended last so existing discriminants are
593    /// stable. The payload is `{"chars":N,"empty":bool}`; the draft text itself
594    /// is deliberately not shipped, because subscribers (a voice extension
595    /// barging in on playback) only need to know *that* the user is composing.
596    EditorChange = 36,
597}
598
599/// Number of event categories — `37` (33 Pi `on()` categories + 1 rpi-specific
600/// [`EventTag::BeforeTuiStart`] + 2 UI prompt events + 1 rpi-specific
601/// [`EventTag::EditorChange`]). A test asserts
602/// `EVENT_TAG_COUNT == 37` so a future edit that adds/removes a tag is caught.
603pub const EVENT_TAG_COUNT: usize = 37;
604
605/// The application name of the host that loads plugins: `"rpi"`.
606///
607/// This is the **contract's** copy of the host identity, and the host's
608/// `APP_NAME` is defined in terms of it, so the two cannot drift. An extension
609/// that labels its own output (a trace name, a tag, a `service.name`) should
610/// derive from this rather than hardcode a brand — it has no other way to learn
611/// what it is embedded in.
612///
613/// [`HOST_VERSION`] is the matching version. Both are also handed to plugins at
614/// runtime in the `BeforeAgentStart` payload's `host` object, which is what an
615/// extension should prefer: that value always describes the process actually
616/// running, whereas these constants describe the host the plugin was *built*
617/// against.
618pub const HOST_NAME: &str = "rpi";
619
620/// Version of the [`HOST_NAME`] host that this SDK ships with.
621///
622/// Because every workspace crate shares one version, this is the host's version
623/// as of this SDK release — good for a user agent or a fallback label, and
624/// superseded at runtime by the `host.version` field of the `BeforeAgentStart`
625/// payload.
626pub const HOST_VERSION: &str = env!("CARGO_PKG_VERSION");
627
628/// Environment variable carrying the id of the session this process serves.
629///
630/// The host **rewrites** it whenever a session becomes active — at startup and
631/// on every in-process swap (`/import`, fork, switch) — so the value always
632/// names the session currently being served. It is not an embedder override:
633/// a value inherited from a parent process would otherwise make a child `rpi`
634/// report its ancestor's session forever. Embedders that need to pin the id set
635/// it after the session is active rather than before.
636///
637/// The id is a plain UTF-8 string (no NUL), which is what makes writing it with
638/// `std::env::set_var` sound.
639pub const SESSION_ID_ENV: &str = "RPI_SESSION_ID";
640
641/// Environment variable a tracing extension publishes to name the trace a
642/// *nested* `rpi` should attach to, and reads back to decide it is a subagent.
643///
644/// The name is `rpi-langfuse`'s (it owns the `LANGFUSE_` prefix), but the
645/// variable is declared here because **three** parties have to agree on it and
646/// they live in different crates, two of them in different repositories:
647///
648/// - the extension writes it (so a launcher can attach a nested run),
649/// - the host's `bash`/`powershell` must *not* pass it on to a shell (or a plain
650///   `rpi` started from one mistakes itself for a subagent), and
651/// - a launcher may set it deliberately, which is the supported nesting path.
652///
653/// Declaring it once here is what keeps those three in step. The host builds its
654/// exclusion list from these declarations, so dropping or renaming the family
655/// ([`SUBAGENT_PARENT_ENV`]) is a compile error there rather than a silently
656/// re-opened leak, and a newly added member is excluded without the host having
657/// to be told. What the compiler still cannot check is the string itself, since
658/// `rpi-langfuse` lives in another repository and matches on the *value* — that
659/// is pinned by a test. See [`SUBAGENT_PARENT_ENV`] for the whole family.
660pub const SUBAGENT_PARENT_TRACE_ID_ENV: &str = "LANGFUSE_PI_PARENT_TRACE_ID";
661
662/// See [`SUBAGENT_PARENT_TRACE_ID_ENV`]. The parent turn's root observation id.
663pub const SUBAGENT_PARENT_SPAN_ID_ENV: &str = "LANGFUSE_PI_PARENT_SPAN_ID";
664
665/// See [`SUBAGENT_PARENT_TRACE_ID_ENV`]. The parent's session id.
666pub const SUBAGENT_PARENT_SESSION_ID_ENV: &str = "LANGFUSE_PI_PARENT_SESSION_ID";
667
668/// See [`SUBAGENT_PARENT_TRACE_ID_ENV`]. Nesting depth, so a grandchild knows
669/// how far down it is.
670pub const SUBAGENT_PARENT_DEPTH_ENV: &str = "LANGFUSE_PI_PARENT_DEPTH";
671
672/// Every [`SUBAGENT_PARENT_TRACE_ID_ENV`] sibling, as one slice.
673///
674/// A host that excludes this channel from a spawned shell should cover the whole
675/// slice rather than restating the names, and assert it does — a new entry here
676/// that is not excluded is the failure mode this array exists to make testable.
677pub const SUBAGENT_PARENT_ENV: &[&str] = &[
678    SUBAGENT_PARENT_TRACE_ID_ENV,
679    SUBAGENT_PARENT_SPAN_ID_ENV,
680    SUBAGENT_PARENT_SESSION_ID_ENV,
681    SUBAGENT_PARENT_DEPTH_ENV,
682];
683
684/// No-payload marker for events that carry none (e.g. `session_shutdown`).
685/// Carries a dummy byte so the empty-struct isn't flagged FFI-unsafe by
686/// `improper_ctypes` (zero-sized C structs are rejected regardless of `repr(C)`).
687#[repr(C)]
688#[derive(Clone, Copy)]
689pub struct EventEmpty {
690    _opaque: u8,
691}
692
693impl EventEmpty {
694    /// The one no-payload instance.
695    pub const INSTANCE: EventEmpty = EventEmpty { _opaque: 0 };
696}
697
698impl Default for EventEmpty {
699    fn default() -> Self {
700        Self::INSTANCE
701    }
702}
703
704/// A serialized message lifecycle payload (`message_start`/`message_update`/
705/// `message_end`). `message` is JSON. For `message_update`, it follows native
706/// Pi's envelope and carries `assistantMessageEvent`; streaming consumers must
707/// read its real `text_delta.delta` instead of diffing a cumulative snapshot.
708#[repr(C)]
709#[derive(Clone, Copy)]
710pub struct EventMessage {
711    pub message: StbString,
712}
713
714/// A tool-call payload (`tool_call`, `tool_execution_start`/`update`).
715/// `tool_call_id` + `tool_name` are raw strings; `params` is the JSON args.
716#[repr(C)]
717#[derive(Clone, Copy)]
718pub struct EventToolCall {
719    pub tool_call_id: StbString,
720    pub tool_name: StbString,
721    pub params: StbString,
722}
723
724/// A tool-result payload (`tool_result`, `tool_execution_end`).
725#[repr(C)]
726#[derive(Clone, Copy)]
727pub struct EventToolResult {
728    pub tool_call_id: StbString,
729    pub tool_name: StbString,
730    pub result: StbString,
731    pub is_error: u8,
732}
733
734/// An error/failure payload.
735#[repr(C)]
736#[derive(Clone, Copy)]
737pub struct EventError {
738    pub message: StbString,
739}
740
741/// A generic JSON-data payload for the long-tail events whose structured shape
742/// the host serializes wholesale (`context`, `before_provider_request`, model
743/// select, resources_discover response, etc.). The plugin reads the fields it
744/// needs.
745#[repr(C)]
746#[derive(Clone, Copy)]
747pub struct EventData {
748    pub data: StbString,
749}
750
751/// Payload union for [`StablePluginEvent`]. All variants are `#[repr(C)]` structs
752/// of [`StbString`] / primitives (Copy), so the union is Copy. Discriminate by
753/// [`StablePluginEvent::tag`] before reading.
754#[repr(C)]
755#[derive(Clone, Copy)]
756pub union EventPayload {
757    pub empty: EventEmpty,
758    pub message: EventMessage,
759    pub tool_call: EventToolCall,
760    pub tool_result: EventToolResult,
761    pub error: EventError,
762    pub data: EventData,
763}
764
765/// One event dispatched to a plugin handler. The host translates its native
766/// `AgentEvent` / `HarnessEvent` into this and calls every registered handler
767/// for the `tag`. Event buffers are borrowed during fan-out: handlers must not
768/// free them or retain their pointers. The host frees them after all handlers
769/// return. Use `events::EventView` to retain owned payload copies.
770#[repr(C)]
771#[derive(Clone, Copy)]
772pub struct StablePluginEvent {
773    pub tag: EventTag,
774    pub payload: EventPayload,
775}
776
777impl StablePluginEvent {
778    /// Build a no-payload event.
779    pub fn empty(tag: EventTag) -> Self {
780        Self {
781            tag,
782            payload: EventPayload {
783                empty: EventEmpty::INSTANCE,
784            },
785        }
786    }
787
788    /// Build a message event.
789    pub fn message(tag: EventTag, message: StbString) -> Self {
790        debug_assert!(matches!(
791            tag,
792            EventTag::MessageStart | EventTag::MessageUpdate | EventTag::MessageEnd
793        ));
794        Self {
795            tag,
796            payload: EventPayload {
797                message: EventMessage { message },
798            },
799        }
800    }
801
802    /// Build a tool-call event.
803    pub fn tool_call(
804        tag: EventTag,
805        tool_call_id: StbString,
806        tool_name: StbString,
807        params: StbString,
808    ) -> Self {
809        debug_assert!(matches!(
810            tag,
811            EventTag::ToolCall | EventTag::ToolExecutionStart | EventTag::ToolExecutionUpdate
812        ));
813        Self {
814            tag,
815            payload: EventPayload {
816                tool_call: EventToolCall {
817                    tool_call_id,
818                    tool_name,
819                    params,
820                },
821            },
822        }
823    }
824
825    /// Build a tool-result event.
826    pub fn tool_result(
827        tag: EventTag,
828        tool_call_id: StbString,
829        tool_name: StbString,
830        result: StbString,
831        is_error: bool,
832    ) -> Self {
833        debug_assert!(matches!(
834            tag,
835            EventTag::ToolResult | EventTag::ToolExecutionEnd
836        ));
837        Self {
838            tag,
839            payload: EventPayload {
840                tool_result: EventToolResult {
841                    tool_call_id,
842                    tool_name,
843                    result,
844                    is_error: is_error as u8,
845                },
846            },
847        }
848    }
849
850    /// Build an error event.
851    pub fn error(tag: EventTag, message: StbString) -> Self {
852        Self {
853            tag,
854            payload: EventPayload {
855                error: EventError { message },
856            },
857        }
858    }
859
860    /// Build a generic data event (JSON in `data`).
861    pub fn data(tag: EventTag, data: StbString) -> Self {
862        Self {
863            tag,
864            payload: EventPayload {
865                data: EventData { data },
866            },
867        }
868    }
869}
870
871/// Return codes an [`EventHandlerFn`] may return.
872///
873/// `0` means success; the host continues to the next handler. A **nonzero**
874/// return means the handler handled an error — the host logs it and (for the
875/// ordinary agent/observe fan-out) continues to other handlers (one handler's
876/// error does not abort the fan-out, mirroring pi's per-handler try/catch).
877///
878/// [`EVENT_HANDLER_ABORT`] is the **veto** code: the handler wants to stop the
879/// current lifecycle phase. It is honored only by the host's lifecycle
880/// dispatch (e.g. `before_tui_start` / `session_start` / `session_shutdown`);
881/// the ordinary observe fan-out treats it like any other nonzero (log +
882/// continue), because the agent loop must not be vetoed mid-run.
883pub const EVENT_HANDLER_CONTINUE: i32 = 0;
884/// The handler handled an error but the fan-out should continue (default
885/// nonzero semantics — equivalent to any other nonzero code).
886pub const EVENT_HANDLER_ERROR: i32 = 1;
887/// **Veto**: stop the current lifecycle phase. Honored only by the host's
888/// lifecycle dispatch; the observe fan-out logs + continues (see the module
889/// docs on [`EVENT_HANDLER_CONTINUE`]).
890pub const EVENT_HANDLER_ABORT: i32 = 2;
891/// **Claim**: the handler consumed this event and the host should not fall back
892/// to its own default handling. Honored only by dispatch paths that ask
893/// extensions to arbitrate (currently `EventTag::Input` key routing, via
894/// [`EVENT_HANDLER_CLAIMED`]); the ordinary observe fan-out treats it like any
895/// other nonzero (log + continue).
896///
897/// Lets a handler subscribe to a key yet decline it while its feature is off:
898/// return [`EVENT_HANDLER_CONTINUE`] to pass the key through to normal editor
899/// handling, or this to swallow it.
900pub const EVENT_HANDLER_CLAIMED: i32 = 3;
901
902/// Handler fn pointer registered via `register_event_handler(tag, handler)`.
903/// `user_data` is the plugin's opaque context. Return `0` on success; nonzero
904/// signals a handled error (the host logs it; dispatch continues to other
905/// handlers — one handler's error does not abort the fan-out). The one
906/// exception: when the host dispatches a **lifecycle** event through its
907/// veto-aware path, returning [`EVENT_HANDLER_ABORT`] stops the fan-out and
908/// aborts that phase (see the constants above).
909pub type EventHandlerFn = extern "C" fn(event: StablePluginEvent, user_data: *mut c_void) -> i32;
910
911/// `resources_discover` handler signature (B5b). Unlike [`EventHandlerFn`]
912/// (fire-and-forget, `i32` only), this carries an owning `out` so the plugin
913/// can hand `{skillPaths, promptPaths, themePaths}` back to the host. `cwd` and
914/// `reason` are borrowed inputs ([`StbStringRef`]); `out` is plugin-produced
915/// and reclaimed via the `plugin_free_string` the host stored alongside the
916/// handler at registration. `user_data` is the plugin's opaque context. Returns
917/// `0` on success (host reads `out`); nonzero on a handled error (host logs +
918/// skips this handler, fan-out continues — mirrors pi `runner.ts:1179-1188`).
919pub type ResourcesDiscoverFn = extern "C" fn(
920    cwd: StbStringRef,
921    reason: StbStringRef,
922    out: *mut StbString,
923    user_data: *mut c_void,
924) -> i32;
925
926/// `before_agent_start` handler signature. Unlike [`EventHandlerFn`]
927/// (fire-and-forget, `i32` only), this carries an owning `out` so the plugin can
928/// hand back a system prompt to install for the run — pi's
929/// `BeforeAgentStartEventResult` (`extensions/types.ts:1394`), whose
930/// `systemPrompt` becomes `forceSystemPrompt` (`runner.ts:1347`).
931///
932/// `event_json` is a borrowed envelope with the same fields the
933/// [`EventTag::BeforeAgentStart`] broadcast carries (`prompt`, `imageCount`,
934/// `sessionId`, `host`) plus the live `systemPrompt` the run would otherwise
935/// use. `out` is plugin-produced JSON `{"systemPrompt": "..."}` (or an empty
936/// object for "no change"), reclaimed via the `plugin_free_string` the host
937/// stored at registration.
938///
939/// Returns `0` on success (host reads `out`); nonzero on a handled error (host
940/// logs the plugin's message and leaves the prompt untouched, then continues the
941/// fan-out — mirrors pi, which isolates a throwing handler).
942///
943/// **Replacement, not append**: the returned text becomes the system prompt. A
944/// plugin that wants to append reads `event_json.systemPrompt` and returns the
945/// two concatenated, which is what pi requires of its extensions too.
946/// SAFETY: the plugin warrants `user_data` is valid for the registry's lifetime
947/// and the handler is callable from any thread.
948pub type BeforeAgentStartFn =
949    extern "C" fn(event_json: StbStringRef, out: *mut StbString, user_data: *mut c_void) -> i32;
950
951// ---------------------------------------------------------------------------
952// Runtime actions — uniform JSON-RPC dispatch by RuntimeActionId
953// ---------------------------------------------------------------------------
954
955/// Identifier for a host runtime action the plugin may invoke via
956/// `PluginApi::runtime_action`. One slot dispatches all actions; the numeric
957/// id crosses the FFI boundary as a `u32` and the host validates it with
958/// [`TryFrom<u32>`] before constructing this enum. Args/results cross as JSON
959/// strings.
960#[repr(u32)]
961#[derive(Debug, Clone, Copy, PartialEq, Eq)]
962pub enum RuntimeActionId {
963    SendMessage = 0,
964    SendUserMessage = 1,
965    AppendEntry = 2,
966    SetSessionName = 3,
967    GetActiveTools = 4,
968    SetActiveTools = 5,
969    SetModel = 6,
970    GetThinkingLevel = 7,
971    SetThinkingLevel = 8,
972    Compact = 9,
973    GetSystemPrompt = 10,
974    NewSession = 11,
975    Fork = 12,
976    NavigateTree = 13,
977    SwitchSession = 14,
978    Reload = 15,
979    /// Read a parsed CLI flag by name. Args: `{"name":"flag"}`; result:
980    /// `{"value": <bool|string|null>}`.
981    GetCliFlag = 16,
982    /// Open/poll/cancel a host-provided interactive UI request. The payload is
983    /// a JSON object with `op` (`open`, `poll`, or `cancel`) and a unique
984    /// `requestId`; hosts without an attached TUI return an explicit error.
985    UiDialog = 17,
986    /// Publish a short status string for the host's UI (TUI footer).
987    /// Args: `{"key":"langfuse","value":"langfuse ✓ (trace sent)"}`; an
988    /// empty/absent `value` clears that key. Result: `{"ok":true,"changed":bool}`.
989    /// Headless hosts accept the write but have nothing to render.
990    /// Legacy footer text (`key`, `value`). Use UiComponent for registered UI.
991    SetStatus = 18,
992    /// Put text into the interactive editor (the prompt input box) instead of
993    /// sending it. Args: `{"text":"...", "mode":"append"|"replace",
994    /// "autoSendMs": 3000}` (omit/`0` for manual send only). Result:
995    /// `{"ok":true,"chars":N}`. A headless host has no editor and accepts the
996    /// write without rendering it, mirroring [`RuntimeActionId::SetStatus`].
997    SetEditorText = 19,
998    /// Return the current session branch entries oldest-first as JSON.
999    /// Args: `{}`; result: `{"entries":[...]}`.
1000    GetSessionBranch = 20,
1001    /// Inspect or finish a plugin-owned stdio server lifecycle. Args:
1002    /// `{"op":"status"}` or `{"op":"finish","exitCode":0}`. Only the
1003    /// declaring owner may finish; the host performs normal session shutdown.
1004    StdioLifecycle = 21,
1005    /// Plugin-owned text component lifecycle and content updates.
1006    UiComponent = 22,
1007}
1008
1009/// Error returned when a plugin passes a numeric runtime-action id that this
1010/// ABI does not define.
1011#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1012pub struct UnknownRuntimeActionId(pub u32);
1013
1014impl core::fmt::Display for UnknownRuntimeActionId {
1015    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
1016        write!(f, "unknown runtime action id {}", self.0)
1017    }
1018}
1019
1020impl std::error::Error for UnknownRuntimeActionId {}
1021
1022impl TryFrom<u32> for RuntimeActionId {
1023    type Error = UnknownRuntimeActionId;
1024
1025    fn try_from(value: u32) -> Result<Self, Self::Error> {
1026        match value {
1027            0 => Ok(Self::SendMessage),
1028            1 => Ok(Self::SendUserMessage),
1029            2 => Ok(Self::AppendEntry),
1030            3 => Ok(Self::SetSessionName),
1031            4 => Ok(Self::GetActiveTools),
1032            5 => Ok(Self::SetActiveTools),
1033            6 => Ok(Self::SetModel),
1034            7 => Ok(Self::GetThinkingLevel),
1035            8 => Ok(Self::SetThinkingLevel),
1036            9 => Ok(Self::Compact),
1037            10 => Ok(Self::GetSystemPrompt),
1038            11 => Ok(Self::NewSession),
1039            12 => Ok(Self::Fork),
1040            13 => Ok(Self::NavigateTree),
1041            14 => Ok(Self::SwitchSession),
1042            15 => Ok(Self::Reload),
1043            16 => Ok(Self::GetCliFlag),
1044            17 => Ok(Self::UiDialog),
1045            18 => Ok(Self::SetStatus),
1046            19 => Ok(Self::SetEditorText),
1047            20 => Ok(Self::GetSessionBranch),
1048            21 => Ok(Self::StdioLifecycle),
1049            22 => Ok(Self::UiComponent),
1050            other => Err(UnknownRuntimeActionId(other)),
1051        }
1052    }
1053}
1054
1055impl From<RuntimeActionId> for u32 {
1056    fn from(value: RuntimeActionId) -> Self {
1057        value as u32
1058    }
1059}
1060
1061/// Runtime-action signature: `runtime_action(action_id, args_json, out,
1062/// user_data) -> i32`. `args_json` is a borrowed input ([`StbStringRef`]); `out`
1063/// is an owning output ([`StbString`]) the host produces and the plugin frees
1064/// via the host's `free_string`. `action_id` is deliberately a raw `u32`, not a
1065/// Rust enum: an unknown value must be rejected as a normal protocol error
1066/// rather than materializing an invalid enum discriminant. Returns `0` on
1067/// success, nonzero on error.
1068pub type RuntimeActionFn = extern "C" fn(
1069    action_id: u32,
1070    args_json: StbStringRef,
1071    out: *mut StbString,
1072    user_data: *mut c_void,
1073) -> i32;
1074
1075// ---------------------------------------------------------------------------
1076// PluginApi — host-provided API struct the plugin calls
1077// ---------------------------------------------------------------------------
1078
1079/// A generic command-handler fn (for `register_command`). `args_json` is a
1080/// borrowed `{"args":"...","command":"/..."}` envelope; `out` is owning
1081/// JSON output reclaimed with the host `free_string`. The TUI understands
1082/// `{kind:"message",text}`, `{kind:"selector",items:[...]}`,
1083/// `{kind:"editor",initialText}`, and `{kind:"input",title,placeholder}`
1084/// responses; selector/editor/input submissions call the same handler with an
1085/// `action` field in `args`.
1086pub type CommandHandlerFn =
1087    extern "C" fn(args_json: StbStringRef, out: *mut StbString, user_data: *mut c_void) -> i32;
1088
1089/// A render/transform fn (for the renderer registrars). `input_json` is borrowed;
1090/// `out` is owning output the plugin frees via host `free_string`. Markdown
1091/// handlers return `{markdown:"..."}`; message/entry handlers return
1092/// `{text:"...",markdown?:true}` or `{lines:["..."]}` for terminal UI.
1093pub type RenderFn =
1094    extern "C" fn(input_json: StbStringRef, out: *mut StbString, user_data: *mut c_void) -> i32;
1095
1096/// A provider-injection factory fn (for `register_provider`). `req_json` is a
1097/// borrowed request envelope; `out` is an owning response the plugin frees.
1098/// The host wraps this into a `Provider` impl (B4/B5).
1099pub type ProviderRequestFn =
1100    extern "C" fn(req_json: StbStringRef, out: *mut StbString, user_data: *mut c_void) -> i32;
1101
1102/// OAuth provider callback. The host sends a JSON action envelope and the
1103/// plugin returns a JSON response. Actions are `begin`, `exchange`,
1104/// `refresh`, and `revoke`; token material remains plugin-owned and is never
1105/// placed in normal agent events.
1106pub type OAuthRequestFn =
1107    extern "C" fn(req_json: StbStringRef, out: *mut StbString, user_data: *mut c_void) -> i32;
1108
1109/// Register an OAuth provider using a JSON manifest plus one action callback.
1110/// The manifest is copied by the host and should include `id`, `displayName`,
1111/// `scopes`, `supportsPkce`, and optional `capabilities`.
1112pub type OAuthRegisterFn = OAuthRequestFn;
1113
1114// ---------------------------------------------------------------------------
1115// Unified ABI — single struct, version inside
1116// ---------------------------------------------------------------------------
1117
1118/// The unified ABI version.
1119pub const RPI_PLUGIN_ABI_VERSION_UNIFIED: u32 = 4;
1120
1121/// The unversioned symbol for the unified ABI.
1122pub const REGISTER_SYMBOL_UNIFIED: &[u8] = b"rpi_plugin_register\0";
1123
1124/// The host-provided API struct. The first two fields (`abi_version`
1125/// and `struct_size`) are contract-identity markers that survive future
1126/// signature changes: a plugin reads them from the pointer before anything
1127/// else, so even if the entrypoint arity changes, the version check remains
1128/// robust.
1129#[repr(C)]
1130#[derive(Clone, Copy)]
1131pub struct PluginApi {
1132    /// Contract identity. First field so a plugin can validate before reading
1133    /// anything else, independent of the entrypoint arity.
1134    pub abi_version: u32,
1135    /// `sizeof(PluginApi)` as the host built it. Lets a plugin detect a host
1136    /// that predates slots it wants (host_struct_size < plugin's expected
1137    /// offset). Future hosts may grow this struct; old plugins check
1138    /// `struct_size` before accessing new fields.
1139    pub struct_size: u32,
1140
1141    // --- Host API ---
1142    /// Host's `free_string` — the plugin calls this for every [`StbString`] it
1143    /// *receives* from the host (outputs of actions, event payloads, inputs to
1144    /// execute). Never null.
1145    pub free_string: FreeStringFn,
1146
1147    // --- registrars (plugin → host "register X into the host") ---
1148    pub register_tool: Option<
1149        extern "C" fn(
1150            schema: *const StableToolSchema,
1151            execute_fn: ToolExecuteFn,
1152            poll_fn: ToolPollFn,
1153            cancel_fn: ToolCancelFn,
1154            destroy_fn: ToolDestroyFn,
1155            plugin_free_string: FreeStringFn,
1156        ) -> i32,
1157    >,
1158    pub register_command: Option<
1159        extern "C" fn(
1160            name: StbStringRef,
1161            description: StbStringRef,
1162            handler: CommandHandlerFn,
1163        ) -> i32,
1164    >,
1165    pub register_shortcut:
1166        Option<extern "C" fn(key: StbStringRef, description: StbStringRef) -> i32>,
1167    pub register_flag: Option<extern "C" fn(name: StbStringRef, description: StbStringRef) -> i32>,
1168    pub register_provider: Option<
1169        extern "C" fn(
1170            provider_id: StbStringRef,
1171            base_url: StbStringRef,
1172            api_style: StbStringRef,
1173            request_fn: ProviderRequestFn,
1174            plugin_free_string: FreeStringFn,
1175            user_data: *mut c_void,
1176        ) -> i32,
1177    >,
1178    pub register_message_renderer: Option<
1179        extern "C" fn(
1180            name: StbStringRef,
1181            render_fn: RenderFn,
1182            plugin_free_string: FreeStringFn,
1183            user_data: *mut c_void,
1184        ) -> i32,
1185    >,
1186    pub register_markdown_transformer: Option<
1187        extern "C" fn(
1188            name: StbStringRef,
1189            render_fn: RenderFn,
1190            plugin_free_string: FreeStringFn,
1191            user_data: *mut c_void,
1192        ) -> i32,
1193    >,
1194    pub register_entry_renderer: Option<
1195        extern "C" fn(
1196            name: StbStringRef,
1197            render_fn: RenderFn,
1198            plugin_free_string: FreeStringFn,
1199            user_data: *mut c_void,
1200        ) -> i32,
1201    >,
1202    pub register_event_handler: Option<
1203        extern "C" fn(tag: EventTag, handler: EventHandlerFn, user_data: *mut c_void) -> i32,
1204    >,
1205    pub register_resources_discover: Option<
1206        extern "C" fn(
1207            handler: ResourcesDiscoverFn,
1208            plugin_free_string: FreeStringFn,
1209            user_data: *mut c_void,
1210        ) -> i32,
1211    >,
1212    pub runtime_action: RuntimeActionFn,
1213    pub dispatch_event:
1214        Option<extern "C" fn(event: StablePluginEvent, user_data: *mut c_void) -> i32>,
1215    pub user_data: *mut c_void,
1216
1217    // --- declarations ---
1218    /// Declare this plugin's host-side preferences as a JSON object, e.g.
1219    /// `{"priority":60,"platforms":["linux","macos"]}`. Nullable: host does
1220    /// not support declarations yet (plugin skips the call; host applies
1221    /// defaults: priority `100`, all platforms).
1222    pub declare: Option<extern "C" fn(json: StbStringRef) -> i32>,
1223
1224    /// Register a system-prompt transformer for the run (pi's
1225    /// `before_agent_start` returning `systemPrompt`).
1226    ///
1227    /// **Appended last on purpose.** A plugin compiled against a shorter
1228    /// `PluginApi` indexes every earlier field by offset, so a slot inserted in
1229    /// the middle would make an old plugin read the new host's fields at the
1230    /// wrong offsets. Appending keeps every existing offset valid, which is what
1231    /// lets this ship without an ABI version bump; a plugin checks
1232    /// `struct_size` (or the slot for `None`) before using it.
1233    ///
1234    /// Nullable: a host that predates this slot leaves it `None`, the plugin
1235    /// skips the call, and the prompt is left alone — exactly that host's
1236    /// behavior before this slot existed.
1237    pub register_before_agent_start: Option<
1238        extern "C" fn(
1239            handler: BeforeAgentStartFn,
1240            plugin_free_string: FreeStringFn,
1241            user_data: *mut c_void,
1242        ) -> i32,
1243    >,
1244
1245    /// Register a provider-owned OAuth flow. Appended for ABI compatibility;
1246    /// older plugins compiled against a shorter struct remain valid.
1247    pub register_oauth_provider: Option<
1248        extern "C" fn(
1249            manifest_json: StbStringRef,
1250            request_fn: OAuthRequestFn,
1251            plugin_free_string: FreeStringFn,
1252            user_data: *mut c_void,
1253        ) -> i32,
1254    >,
1255}
1256unsafe impl Send for PluginApi {}
1257unsafe impl Sync for PluginApi {}
1258
1259/// Unified ABI plugin entrypoint signature: `(api)`. The version is read from
1260/// `api->abi_version`.
1261pub type RpiPluginRegisterUnified = extern "C" fn(api: *const PluginApi) -> i32;
1262
1263// ===========================================================================
1264// Panic containment
1265// ===========================================================================
1266
1267/// Status code a plugin's register entrypoint returns when its body panicked.
1268///
1269/// A panic must never unwind past the plugin's `extern "C"` entrypoint: Rust
1270/// turns such an unwind into a **process abort** (`panic in a function that
1271/// cannot unwind`), which would take down the whole host. The SDK catches the
1272/// panic and returns this code instead, so the host can skip the plugin with a
1273/// diagnostic.
1274pub const REGISTER_PANIC_STATUS: i32 = 70;
1275
1276/// Run `body`, converting a panic into [`Err`] instead of letting it unwind.
1277///
1278/// Plugin authors must wrap the body of **every** `extern "C"` entrypoint they
1279/// export (tool `execute`/`poll`/`cancel`, event handlers, provider/render
1280/// callbacks, runtime actions) with this (or an equivalent `catch_unwind`).
1281/// Unwinding out of an `extern "C"` function aborts the process.
1282pub fn guard<R>(body: impl FnOnce() -> R) -> Result<R, Box<dyn std::any::Any + Send>> {
1283    std::panic::catch_unwind(std::panic::AssertUnwindSafe(body))
1284}
1285
1286/// Like [`guard`], but returns `fallback` on panic. Handy for entrypoints that
1287/// must return a value rather than a `Result`.
1288pub fn guard_or<R>(fallback: R, body: impl FnOnce() -> R) -> R {
1289    guard(body).unwrap_or(fallback)
1290}
1291
1292/// Export a unified ABI plugin entrypoint under `rpi_plugin_register`.
1293///
1294/// The unified ABI has no version suffix in the symbol name; the version lives
1295/// inside the [`PluginApi`] struct (first field `abi_version`).
1296///
1297/// The expression receives `&PluginApi` and returns the plugin-defined
1298/// registration status code.
1299///
1300/// ```ignore
1301/// rpi_plugin_sdk::export_plugin!(|api| {
1302///     if let Some(declare) = api.declare {
1303///         declare(rpi_plugin_sdk::StbStringRef::from_str(
1304///             r#"{"priority":60,"platforms":["linux"]}"#,
1305///         ));
1306///     }
1307///     // ... register tools and handlers through `api` ...
1308///     0
1309/// });
1310/// ```
1311#[macro_export]
1312macro_rules! export_plugin {
1313    ($body:expr) => {
1314        #[no_mangle]
1315        pub extern "C" fn rpi_plugin_register(api: *const $crate::PluginApi) -> i32 {
1316            // SAFETY: host guarantees `api` is valid for this call.
1317            unsafe { $crate::register_entrypoint(api, $body) }
1318        }
1319    };
1320}
1321
1322/// Unified ABI entrypoint helper: reads `abi_version` and `struct_size` from
1323/// the struct pointer before dereferencing anything else. This survives future
1324/// signature changes because the pointer is always the first argument.
1325///
1326/// # Safety
1327///
1328/// `api` must be a valid, properly aligned pointer to a [`PluginApi`] that
1329/// remains valid for the duration of `body`.
1330pub unsafe fn register_entrypoint(
1331    api: *const PluginApi,
1332    body: impl FnOnce(&PluginApi) -> i32,
1333) -> i32 {
1334    if api.is_null() {
1335        return 2;
1336    }
1337    // SAFETY: caller guarantees `api` is valid; we check null above.
1338    // Read the version and size fields first — they're at known offsets
1339    // regardless of future struct growth.
1340    let abi_version = (*api).abi_version;
1341    let struct_size = (*api).struct_size;
1342    if abi_version != RPI_PLUGIN_ABI_VERSION_UNIFIED {
1343        // Mismatch: refuse to register. The host logs "ABI version mismatch"
1344        // and skips loading this plugin.
1345        return 1;
1346    }
1347    if (struct_size as usize) < core::mem::size_of::<PluginApi>() {
1348        // Host predates a field the plugin expects; degrade gracefully.
1349        return 3;
1350    }
1351    let api = &*api;
1352    // Contain panics: unwinding out of the plugin's `extern "C"` shim aborts
1353    // the process, so convert a panic into a status code the host can report.
1354    match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| body(api))) {
1355        Ok(code) => code,
1356        Err(_) => REGISTER_PANIC_STATUS,
1357    }
1358}
1359
1360// ===========================================================================
1361// Tests (need std + serde_json)
1362// ===========================================================================
1363
1364#[cfg(test)]
1365mod tests {
1366    use super::*;
1367
1368    /// A panicking `register` body must NOT unwind out of the entrypoint shim;
1369    /// it must come back as `REGISTER_PANIC_STATUS` so the host can skip the
1370    /// plugin instead of the process aborting.
1371    #[test]
1372    fn register_entrypoint_contains_panics() {
1373        let mut api = std::mem::MaybeUninit::<PluginApi>::uninit();
1374        let api_ptr = api.as_mut_ptr();
1375        // Set the version/size fields so the entrypoint gets past the checks
1376        // and into the body (which panics).
1377        unsafe {
1378            (*api_ptr).abi_version = RPI_PLUGIN_ABI_VERSION_UNIFIED;
1379            (*api_ptr).struct_size = core::mem::size_of::<PluginApi>() as u32;
1380        }
1381        let rc = unsafe {
1382            register_entrypoint(api_ptr, |_api| {
1383                panic!("plugin register exploded");
1384            })
1385        };
1386        assert_eq!(rc, REGISTER_PANIC_STATUS);
1387    }
1388
1389    #[test]
1390    fn guard_returns_value_and_contains_panic() {
1391        assert_eq!(guard(|| 41 + 1).ok(), Some(42));
1392        assert!(guard(|| -> i32 { panic!("nope") }).is_err());
1393        assert_eq!(guard_or(-1, || -> i32 { panic!("nope") }), -1);
1394    }
1395
1396    // A test allocator + free fn so we can verify the own/free contract
1397    // without a real plugin's free_string.
1398    std::thread_local! {
1399        static FREED: std::cell::Cell<usize> = std::cell::Cell::new(0);
1400    }
1401
1402    extern "C" fn test_free(s: StbString) {
1403        if s.is_empty() || s.ptr.is_null() {
1404            return;
1405        }
1406        // Reconstruct the boxed slice and drop it.
1407        unsafe {
1408            let slice = core::slice::from_raw_parts_mut(s.ptr as *mut u8, s.len);
1409            let _ = Box::from_raw(slice as *mut [u8]);
1410        }
1411        FREED.with(|freed| freed.set(freed.get() + 1));
1412    }
1413
1414    fn reset_freed() -> usize {
1415        FREED.with(|freed| freed.replace(0))
1416    }
1417
1418    fn freed_count() -> usize {
1419        FREED.with(std::cell::Cell::get)
1420    }
1421
1422    // A no-op `runtime_action` impl for the vtable-construction tests (closures
1423    // can't coerce to `extern "C" fn`, so we use a real fn).
1424    extern "C" fn noop_runtime_action(
1425        _action_id: u32,
1426        _args: StbStringRef,
1427        _out: *mut StbString,
1428        _user_data: *mut c_void,
1429    ) -> i32 {
1430        0
1431    }
1432
1433    #[test]
1434    fn stbstring_round_trip_and_free_once() {
1435        let prev = reset_freed();
1436        let _ = prev;
1437        let s = StbString::from_string("hello, pi".to_string());
1438        assert_eq!(s.len, 9);
1439        assert_eq!(s.to_string_lossy(), "hello, pi");
1440        s.free_with(Some(test_free));
1441        assert_eq!(freed_count(), 1);
1442    }
1443
1444    #[test]
1445    fn empty_stbstring_free_is_noop() {
1446        let _ = reset_freed();
1447        StbString::empty().free_with(Some(test_free));
1448        assert_eq!(freed_count(), 0);
1449    }
1450
1451    #[cfg(feature = "json")]
1452    #[test]
1453    fn json_round_trip_preserves_structure() {
1454        let val = serde_json::json!({ "name": "echo", "args": [1, 2, 3], "ok": true });
1455        let stb = json::to_stable(&val, None);
1456        let back = json::from_stable(&stb);
1457        assert_eq!(val, back);
1458        stb.free_with(Some(test_free));
1459        let _ = reset_freed();
1460    }
1461
1462    #[test]
1463    fn step_result_done_round_trip() {
1464        let result_json = StbString::from_string(r#"{"content":[{"text":"hi"}]}"#.to_string());
1465        let sr = StepResult::done(result_json);
1466        assert_eq!(sr.tag, StepResultTag::Done);
1467        // SAFETY: tag == Done.
1468        let done = unsafe { sr.done_payload() };
1469        assert_eq!(
1470            done.result.to_string_lossy(),
1471            r#"{"content":[{"text":"hi"}]}"#
1472        );
1473        done.result.free_with(Some(test_free));
1474        let _ = reset_freed();
1475    }
1476
1477    #[test]
1478    fn step_result_pending_and_err() {
1479        let prog = StbString::from_string("...".to_string());
1480        let srp = StepResult::pending(prog);
1481        assert_eq!(srp.tag, StepResultTag::Pending);
1482        // SAFETY: tag == Pending.
1483        unsafe {
1484            assert_eq!(srp.pending_payload().progress.to_string_lossy(), "...");
1485        }
1486        unsafe { srp.pending_payload().progress.free_with(Some(test_free)) };
1487
1488        let msg = StbString::from_string("boom".to_string());
1489        let sre = StepResult::err(msg);
1490        assert_eq!(sre.tag, StepResultTag::Err);
1491        // SAFETY: tag == Err.
1492        unsafe {
1493            assert_eq!(sre.err_payload().message.to_string_lossy(), "boom");
1494            sre.err_payload().message.free_with(Some(test_free));
1495        }
1496        let _ = reset_freed();
1497    }
1498
1499    #[test]
1500    fn event_tag_count_is_37() {
1501        // Enumerate every tag; a compile-time + runtime guarantee that the
1502        // 37-category surface is intact (33 Pi on() categories + the
1503        // rpi-specific BeforeTuiStart + 2 UI prompt events + EditorChange).
1504        let tags = [
1505            EventTag::ProjectTrust,
1506            EventTag::ResourcesDiscover,
1507            EventTag::SessionStart,
1508            EventTag::SessionInfoChanged,
1509            EventTag::SessionBeforeSwitch,
1510            EventTag::SessionBeforeFork,
1511            EventTag::SessionBeforeCompact,
1512            EventTag::SessionCompact,
1513            EventTag::SessionShutdown,
1514            EventTag::SessionBeforeTree,
1515            EventTag::SessionTree,
1516            EventTag::Context,
1517            EventTag::BeforeProviderRequest,
1518            EventTag::BeforeProviderHeaders,
1519            EventTag::AfterProviderResponse,
1520            EventTag::BeforeAgentStart,
1521            EventTag::AgentStart,
1522            EventTag::AgentEnd,
1523            EventTag::AgentSettled,
1524            EventTag::TurnStart,
1525            EventTag::TurnEnd,
1526            EventTag::MessageStart,
1527            EventTag::MessageUpdate,
1528            EventTag::MessageEnd,
1529            EventTag::ToolExecutionStart,
1530            EventTag::ToolExecutionUpdate,
1531            EventTag::ToolExecutionEnd,
1532            EventTag::ModelSelect,
1533            EventTag::ThinkingLevelSelect,
1534            EventTag::ToolCall,
1535            EventTag::ToolResult,
1536            EventTag::UserBash,
1537            EventTag::Input,
1538            EventTag::BeforeTuiStart,
1539            EventTag::UiPromptStart,
1540            EventTag::UiPromptEnd,
1541            EventTag::EditorChange,
1542        ];
1543        assert_eq!(tags.len(), EVENT_TAG_COUNT);
1544        assert_eq!(EVENT_TAG_COUNT, 37);
1545        // Distinct discriminants 0..36.
1546        let mut discs: Vec<u32> = tags.iter().map(|t| *t as u32).collect();
1547        discs.sort();
1548        assert_eq!(discs, (0..37).collect::<Vec<u32>>());
1549    }
1550
1551    #[test]
1552    fn event_payloads_construct_and_free() {
1553        let m = StbString::from_string("msg".to_string());
1554        let ev = StablePluginEvent::message(EventTag::MessageEnd, m);
1555        assert_eq!(ev.tag, EventTag::MessageEnd);
1556        // SAFETY: tag == MessageEnd (message variant).
1557        unsafe {
1558            assert_eq!(ev.payload.message.message.to_string_lossy(), "msg");
1559            ev.payload.message.message.free_with(Some(test_free));
1560        }
1561
1562        let tc = StablePluginEvent::tool_call(
1563            EventTag::ToolCall,
1564            StbString::from_string("call_1".to_string()),
1565            StbString::from_string("echo".to_string()),
1566            StbString::from_string("{}".to_string()),
1567        );
1568        // SAFETY: tag == ToolCall (tool_call variant).
1569        unsafe {
1570            assert_eq!(tc.payload.tool_call.tool_name.to_string_lossy(), "echo");
1571            tc.payload.tool_call.tool_call_id.free_with(Some(test_free));
1572            tc.payload.tool_call.tool_name.free_with(Some(test_free));
1573            tc.payload.tool_call.params.free_with(Some(test_free));
1574        }
1575        let _ = reset_freed();
1576    }
1577
1578    #[test]
1579    fn plugin_api_is_pod_and_sized() {
1580        // The API struct is a plain old data struct: every fn pointer is
1581        // non-Drop, the struct has no Drop impl.
1582        let api = PluginApi {
1583            abi_version: RPI_PLUGIN_ABI_VERSION_UNIFIED,
1584            struct_size: core::mem::size_of::<PluginApi>() as u32,
1585            free_string: test_free,
1586            register_tool: None,
1587            register_command: None,
1588            register_shortcut: None,
1589            register_flag: None,
1590            register_provider: None,
1591            register_message_renderer: None,
1592            register_markdown_transformer: None,
1593            register_entry_renderer: None,
1594            register_event_handler: None,
1595            register_resources_discover: None,
1596            runtime_action: noop_runtime_action,
1597            dispatch_event: None,
1598            user_data: core::ptr::null_mut(),
1599            declare: None,
1600            register_before_agent_start: None,
1601            register_oauth_provider: None,
1602        };
1603        // All optional slots are null → plugin must degrade.
1604        assert!(api.register_tool.is_none());
1605        assert!(api.register_event_handler.is_none());
1606        assert!(api.register_resources_discover.is_none());
1607        assert!(api.register_before_agent_start.is_none());
1608        // Copy (POD) — no UB from a plain copy.
1609        let _copy = api;
1610        assert!(!core::mem::needs_drop::<PluginApi>());
1611        assert!(!core::mem::needs_drop::<StbString>());
1612        assert!(!core::mem::needs_drop::<StepResult>());
1613        assert!(!core::mem::needs_drop::<StablePluginEvent>());
1614        assert!(!core::mem::needs_drop::<StableToolSchema>());
1615    }
1616
1617    /// New vtable slots must be **appended**, never inserted.
1618    ///
1619    /// A plugin compiled against a shorter `PluginApi` reads every field by
1620    /// offset. Inserting a slot in the middle would make that plugin read the
1621    /// new host's fields at the wrong offsets — silently, since both sides just
1622    /// see pointers. Appending is what lets a slot ship without an ABI version
1623    /// bump. This pins the three fields at the end and the identity markers at
1624    /// the front, so a future edit that inserts in the middle fails here.
1625    #[test]
1626    fn vtable_grows_only_at_the_end() {
1627        use core::mem::{align_of, size_of};
1628
1629        // Identity markers stay first: a plugin reads these before anything
1630        // else, independent of the entrypoint arity.
1631        assert_eq!(core::mem::offset_of!(PluginApi, abi_version), 0);
1632        assert_eq!(
1633            core::mem::offset_of!(PluginApi, struct_size),
1634            size_of::<u32>()
1635        );
1636
1637        // The most recently added slot is last, and the two before it are
1638        // adjacent — i.e. nothing was wedged in between them.
1639        let declare = core::mem::offset_of!(PluginApi, declare);
1640        let new_slot = core::mem::offset_of!(PluginApi, register_before_agent_start);
1641        assert_eq!(
1642            new_slot,
1643            declare + size_of::<Option<extern "C" fn(StbStringRef) -> i32>>(),
1644            "register_before_agent_start must be appended right after `declare`; \
1645             inserting a slot earlier shifts every later offset and breaks \
1646             plugins built against the previous layout"
1647        );
1648        assert_eq!(align_of::<PluginApi>(), align_of::<*const ()>());
1649    }
1650
1651    #[test]
1652    fn runtime_action_ids_are_explicitly_validated() {
1653        let ids = [
1654            RuntimeActionId::SendMessage,
1655            RuntimeActionId::SendUserMessage,
1656            RuntimeActionId::AppendEntry,
1657            RuntimeActionId::SetSessionName,
1658            RuntimeActionId::GetActiveTools,
1659            RuntimeActionId::SetActiveTools,
1660            RuntimeActionId::SetModel,
1661            RuntimeActionId::GetThinkingLevel,
1662            RuntimeActionId::SetThinkingLevel,
1663            RuntimeActionId::Compact,
1664            RuntimeActionId::GetSystemPrompt,
1665            RuntimeActionId::NewSession,
1666            RuntimeActionId::Fork,
1667            RuntimeActionId::NavigateTree,
1668            RuntimeActionId::SwitchSession,
1669            RuntimeActionId::Reload,
1670            RuntimeActionId::GetCliFlag,
1671            RuntimeActionId::UiDialog,
1672            RuntimeActionId::SetStatus,
1673            RuntimeActionId::SetEditorText,
1674            RuntimeActionId::GetSessionBranch,
1675            RuntimeActionId::StdioLifecycle,
1676            RuntimeActionId::UiComponent,
1677        ];
1678
1679        for (raw, expected) in ids.into_iter().enumerate() {
1680            assert_eq!(RuntimeActionId::try_from(raw as u32), Ok(expected));
1681            assert_eq!(u32::from(expected), raw as u32);
1682        }
1683        // One past the highest defined id is rejected (nothing is silently
1684        // accepted), and so is the u32 ceiling.
1685        assert_eq!(
1686            RuntimeActionId::try_from(23),
1687            Err(UnknownRuntimeActionId(23))
1688        );
1689        assert_eq!(
1690            RuntimeActionId::try_from(u32::MAX),
1691            Err(UnknownRuntimeActionId(u32::MAX))
1692        );
1693    }
1694
1695    #[test]
1696    fn register_entrypoint_version_mismatch_refuses() {
1697        let api = PluginApi {
1698            abi_version: RPI_PLUGIN_ABI_VERSION_UNIFIED,
1699            struct_size: core::mem::size_of::<PluginApi>() as u32,
1700            free_string: test_free,
1701            register_tool: None,
1702            register_command: None,
1703            register_shortcut: None,
1704            register_flag: None,
1705            register_provider: None,
1706            register_message_renderer: None,
1707            register_markdown_transformer: None,
1708            register_entry_renderer: None,
1709            register_event_handler: None,
1710            register_resources_discover: None,
1711            runtime_action: noop_runtime_action,
1712            dispatch_event: None,
1713            user_data: core::ptr::null_mut(),
1714            declare: None,
1715            register_before_agent_start: None,
1716            register_oauth_provider: None,
1717        };
1718        assert_eq!(RPI_PLUGIN_ABI_VERSION_UNIFIED, 4);
1719
1720        // A future version is rejected by the pre-dereference check.
1721        let mut future_api = api;
1722        future_api.abi_version = RPI_PLUGIN_ABI_VERSION_UNIFIED + 1;
1723        let rc = unsafe {
1724            register_entrypoint(&future_api, |_| {
1725                panic!("body must not run on version mismatch");
1726            })
1727        };
1728        assert_eq!(rc, 1);
1729
1730        // Right version → body runs, rc propagated.
1731        let rc = unsafe { register_entrypoint(&api, |_| 0) };
1732        assert_eq!(rc, 0);
1733        let rc = unsafe { register_entrypoint(&api, |_| 42) };
1734        assert_eq!(rc, 42);
1735
1736        // Null api → refuse.
1737        let rc = unsafe { register_entrypoint(core::ptr::null(), |_| 0) };
1738        assert_ne!(rc, 0);
1739        let _ = reset_freed();
1740    }
1741}