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
63// ---------------------------------------------------------------------------
64// StbString — owned UTF-8 crossing as ptr+len with an explicit free
65// ---------------------------------------------------------------------------
66
67/// An owned UTF-8 string crossing the ABI as a `(ptr, len)` pair.
68///
69/// `Copy` (raw pointers are `Copy`): copying a `StbString` duplicates the
70/// **pointer**, not the allocation. The **receiver** of a `StbString` owns the
71/// allocation and MUST free it **exactly once** by calling the producer's
72/// [`FreeStringFn`] (or [`StbString::free_with`] / [`StbString::free_host`]).
73/// Never `free` a `StbString` you did not receive as an owner (e.g. one built
74/// from a borrow via [`StbString::from_ref`], which is non-owning — its `free`
75/// is a no-op only if the producer guarantees the buffer outlives the call; in
76/// practice inputs cross as [`StbStringRef`] instead).
77///
78/// **Construction ownership contract:**
79/// - [`StbString::from_owned`] — takes a `Box<[u8]>` the caller allocated; the
80///   `StbString` now owns it; `free` deallocates.
81/// - [`StbString::from_boxed_str`] / [`StbString::from_string`] — convenience
82///   over `from_owned` (host side, needs `alloc`).
83/// - [`StbString::empty`] — null/0; `free` is a no-op.
84///
85/// **Null `ptr` ⇒ empty** (`len` MUST be 0). A null pointer is never
86/// dereferenced.
87#[repr(C)]
88#[derive(Clone, Copy)]
89pub struct StbString {
90    /// UTF-8 bytes. Null when `len == 0` (empty string).
91    pub ptr: *mut c_char,
92    /// Byte length (NOT a NUL terminator — the buffer is NOT NUL-terminated).
93    pub len: usize,
94}
95
96// SAFETY: `StbString` is a plain `(ptr, len)` of raw pointers — no ownership
97// transferred across threads by the type itself, and lifetime/aliasing is the
98// caller's contract (documented above). It is `Send`+`Sync` so the host and the
99// blocking driver can pass it across threads; the *allocation* ownership rules
100// above still apply regardless of thread.
101unsafe impl Send for StbString {}
102unsafe impl Sync for StbString {}
103
104impl StbString {
105    /// An empty string: null pointer, zero length. `free` is a no-op.
106    pub const fn empty() -> Self {
107        Self {
108            ptr: ptr::null_mut(),
109            len: 0,
110        }
111    }
112
113    /// Whether this is the empty/null string.
114    pub const fn is_empty(&self) -> bool {
115        self.len == 0
116    }
117}
118
119// Allocating constructors + safe reader need `std` (Box/Vec/String). The ABI
120// type itself (`StbString { ptr, len }`) is POD with no `Drop` — that is what
121// makes the boundary sound. These helpers are available whenever `std` is (the
122// crate is std-using). When the `json` feature is off a plugin still gets these
123// because the crate links std; the gate here keeps `serde_json` truly optional.
124#[cfg(any(feature = "json", test))]
125impl StbString {
126    /// Wrap an allocation the caller already boxed. The `StbString` takes
127    /// ownership: a later `free_with(free_fn)` will deallocate it via the
128    /// producer's `free_string`.
129    ///
130    /// The buffer MUST be UTF-8.
131    pub fn from_owned(buf: Box<[u8]>) -> Self {
132        let len = buf.len();
133        // Stabilize the pointer via `Box::into_raw`; the free fn reconstructs a
134        // slice from ptr+len and drops it. Store the element pointer as
135        // `*mut c_char` (u8 ↔ c_char on every platform rpi targets). Ownership
136        // moves into the `StbString` (we do NOT drop here).
137        let ptr = Box::into_raw(buf) as *mut [u8] as *mut u8 as *mut c_char;
138        let _ = len;
139        Self { ptr, len }
140    }
141
142    /// Convenience: from a `String`, transferring ownership. After this the
143    /// passed `String` is consumed and must not be reused.
144    pub fn from_string(s: String) -> Self {
145        Self::from_vec(s.into_bytes())
146    }
147
148    /// Convenience: from a `Vec<u8>` (UTF-8), transferring ownership.
149    pub fn from_vec(v: Vec<u8>) -> Self {
150        Self::from_owned(v.into_boxed_slice())
151    }
152
153    /// Convenience: from a boxed `str`.
154    pub fn from_boxed_str(s: Box<str>) -> Self {
155        let string: String = s.into();
156        Self::from_vec(string.into_bytes())
157    }
158
159    /// Copy this `StbString`'s bytes into an owned `String` (**without** freeing
160    /// the original — the caller still owns the original allocation). Use this
161    /// to *read* a received `StbString` into safe Rust; then `free` the original.
162    pub fn to_string_lossy(&self) -> String {
163        if self.len == 0 || self.ptr.is_null() {
164            return String::new();
165        }
166        // SAFETY: the producer guarantees `ptr` is valid for `len` bytes and the
167        // bytes are UTF-8. We only read (no free) here.
168        let slice = unsafe { core::slice::from_raw_parts(self.ptr as *const u8, self.len) };
169        String::from_utf8_lossy(slice).into_owned()
170    }
171}
172
173/// A **borrowed**, non-owning view of a string passed as an *input* to an FFI
174/// call. The callee MUST NOT free it and MUST NOT retain it past the call.
175///
176/// Built from a `&str` on the calling side; the buffer is valid for the
177/// duration of the call (the caller's borrow).
178#[repr(C)]
179#[derive(Clone, Copy)]
180pub struct StbStringRef {
181    /// UTF-8 bytes (valid for the call; NUL-terminated not required).
182    pub ptr: *const c_char,
183    /// Byte length.
184    pub len: usize,
185}
186
187unsafe impl Send for StbStringRef {}
188unsafe impl Sync for StbStringRef {}
189
190impl StbStringRef {
191    /// Empty input.
192    pub const fn empty() -> Self {
193        Self {
194            ptr: ptr::null(),
195            len: 0,
196        }
197    }
198
199    /// Borrow a `&str` for the duration of a call. The caller must outlive the
200    /// call (normal borrow rules).
201    pub fn from_str(s: &str) -> Self {
202        Self {
203            ptr: s.as_ptr() as *const c_char,
204            len: s.len(),
205        }
206    }
207
208    /// Read into a safe `&str` for the callee's lifetime `'a`.
209    ///
210    /// # Safety
211    /// The caller guarantees `ptr` is valid for `len` bytes and they are UTF-8,
212    /// and the borrow survives `'a`.
213    pub unsafe fn as_str<'a>(&self) -> &'a str {
214        if self.len == 0 || self.ptr.is_null() {
215            return "";
216        }
217        let slice = unsafe { core::slice::from_raw_parts(self.ptr as *const u8, self.len) };
218        unsafe { core::str::from_utf8_unchecked(slice) }
219    }
220}
221
222/// Function pointer a plugin exports to free a [`StbString`] it produced.
223/// Idempotent: freeing an already-freed or empty `StbString` is a no-op.
224///
225/// The host calls this for every [`StbString`] it receives from the plugin; the
226/// plugin calls the **host's** `free_string` (from [`PluginApiVt`]) for every
227/// [`StbString`] it receives from the host.
228pub type FreeStringFn = extern "C" fn(s: StbString);
229
230impl StbString {
231    /// Free this `StbString` via the given `free_string` fn, if non-null and
232    /// non-empty. Consumes ownership (the value is `Copy`, but semantically the
233    /// caller relinquishes the allocation).
234    ///
235    /// After this call the bytes are invalid; do not use the `StbString` again.
236    pub fn free_with(self, free_fn: Option<FreeStringFn>) {
237        if let Some(free_fn) = free_fn {
238            if !self.is_empty() {
239                free_fn(self);
240            }
241        }
242    }
243}
244
245// ---------------------------------------------------------------------------
246// StableJsonValue — JSON round-trip helpers (json feature)
247// ---------------------------------------------------------------------------
248
249/// Helpers to cross structured data as JSON-in-`StbString`. See the module docs
250/// for the precision/order limit.
251#[cfg(any(feature = "json", doc))]
252pub mod json {
253    use super::StbString;
254    use serde_json::Value;
255
256    /// Serialize a `serde_json::Value` into an owning [`StbString`] the receiver
257    /// must `free` via the producer's `free_string`.
258    pub fn to_stable(value: &Value, free_fn: Option<super::FreeStringFn>) -> StbString {
259        let s = serde_json::to_string(value).unwrap_or_else(|_| "null".to_string());
260        let stb = StbString::from_string(s);
261        // `free_fn` is advisory metadata the receiver needs; the StbString
262        // itself carries only ptr+len. Stash nothing — the receiver must know
263        // which free fn to use (host's vs plugin's) by direction.
264        let _ = free_fn;
265        stb
266    }
267
268    /// Parse a received [`StbString`] back into a `Value`. Does **not** free the
269    /// input — the caller still owns it.
270    pub fn from_stable(s: &StbString) -> Value {
271        let text = s.to_string_lossy();
272        if text.is_empty() {
273            return Value::Null;
274        }
275        serde_json::from_str(&text).unwrap_or(Value::Null)
276    }
277}
278
279// ---------------------------------------------------------------------------
280// StableToolSchema — the provider-facing tool definition
281// ---------------------------------------------------------------------------
282
283/// A tool's provider-facing schema crossing the ABI. `name` / `description` are
284/// raw strings; `parameters` is a JSON Schema serialized to a JSON string (the
285/// host parses it into its native `schemars::Schema`).
286///
287/// All three are owning [`StbString`]s the **plugin** produced; the **host**
288/// frees them via the plugin's `free_string` (passed to [`ToolExecuteFn`] /
289/// `register_tool`).
290#[repr(C)]
291#[derive(Clone, Copy)]
292pub struct StableToolSchema {
293    pub name: StbString,
294    pub description: StbString,
295    /// JSON-encoded JSON Schema for the tool's `parameters`.
296    pub parameters: StbString,
297}
298
299unsafe impl Send for StableToolSchema {}
300unsafe impl Sync for StableToolSchema {}
301
302// ---------------------------------------------------------------------------
303// StepResult — the poll() return: Pending | Done | Err (explicit tag + union)
304// ---------------------------------------------------------------------------
305
306/// Opaque, plugin-allocated handle for one tool execution drive. Produced by
307/// [`ToolExecuteFn`], polled by [`ToolPollFn`], cancelled by [`ToolCancelFn`],
308/// freed by [`ToolDestroyFn`] (exactly once, idempotent).
309///
310/// The handle's interior layout is entirely plugin-private; the host treats it
311/// as an opaque pointer.
312pub type StepHandle = *mut c_void;
313
314/// Discriminant for [`StepResult`]. `#[repr(u32)]` pins the width so the union
315/// payload is sound across compilers.
316#[repr(u32)]
317#[derive(Debug, Clone, Copy, PartialEq, Eq)]
318pub enum StepResultTag {
319    /// `poll` has no terminal result yet; the partial payload may carry progress.
320    Pending = 0,
321    /// Terminal success; `done.result` is the JSON `AgentToolResult`.
322    Done = 1,
323    /// Terminal failure; `err.message` is a UTF-8 error string.
324    Err = 2,
325}
326
327/// A partial/progress result emitted during `Pending`. `progress` is a JSON
328/// `AgentToolResult` (the same shape `on_update` carries) — the host forwards it
329/// to the adapter's `on_update` callback. May be empty.
330#[repr(C)]
331#[derive(Clone, Copy)]
332pub struct StbPending {
333    pub progress: StbString,
334}
335
336/// Terminal success payload. `result` is a JSON `AgentToolResult`.
337#[repr(C)]
338#[derive(Clone, Copy)]
339pub struct StbDone {
340    pub result: StbString,
341}
342
343/// Terminal failure payload. `message` is a UTF-8 error string.
344#[repr(C)]
345#[derive(Clone, Copy)]
346pub struct StbErr {
347    pub message: StbString,
348}
349
350/// The `poll()` return value. Read the `payload` variant matching `tag`.
351///
352/// All payload variants are `#[repr(C)]` structs of [`StbString`] (Copy), so the
353/// union is `Copy`. A wrong-variant read is `unsafe`; always match on `tag`.
354#[repr(C)]
355#[derive(Clone, Copy)]
356pub union StepResultPayload {
357    pub pending: StbPending,
358    pub done: StbDone,
359    pub err: StbErr,
360}
361
362/// Return value of [`ToolPollFn`]. The blocking driver matches on `tag`, reads
363/// the matching payload, and breaks the loop on `Done`/`Err`.
364#[repr(C)]
365#[derive(Clone, Copy)]
366pub struct StepResult {
367    pub tag: StepResultTag,
368    pub payload: StepResultPayload,
369}
370
371impl StepResult {
372    /// Build a `Pending` with a progress JSON string (may be empty).
373    pub fn pending(progress: StbString) -> Self {
374        Self {
375            tag: StepResultTag::Pending,
376            payload: StepResultPayload {
377                pending: StbPending { progress },
378            },
379        }
380    }
381
382    /// Build a `Done` with the terminal JSON `AgentToolResult`.
383    pub fn done(result: StbString) -> Self {
384        Self {
385            tag: StepResultTag::Done,
386            payload: StepResultPayload {
387                done: StbDone { result },
388            },
389        }
390    }
391
392    /// Build an `Err` with an error message.
393    pub fn err(message: StbString) -> Self {
394        Self {
395            tag: StepResultTag::Err,
396            payload: StepResultPayload {
397                err: StbErr { message },
398            },
399        }
400    }
401
402    /// Access the `pending` payload. Caller MUST guarantee `tag == Pending`.
403    ///
404    /// # Safety
405    /// Undefined behavior if `tag != StepResultTag::Pending`.
406    pub unsafe fn pending_payload(&self) -> &StbPending {
407        unsafe { &self.payload.pending }
408    }
409
410    /// Access the `done` payload. Caller MUST guarantee `tag == Done`.
411    ///
412    /// # Safety
413    /// Undefined behavior if `tag != StepResultTag::Done`.
414    pub unsafe fn done_payload(&self) -> &StbDone {
415        unsafe { &self.payload.done }
416    }
417
418    /// Access the `err` payload. Caller MUST guarantee `tag == Err`.
419    ///
420    /// # Safety
421    /// Undefined behavior if `tag != StepResultTag::Err`.
422    pub unsafe fn err_payload(&self) -> &StbErr {
423        unsafe { &self.payload.err }
424    }
425}
426
427// ---------------------------------------------------------------------------
428// The 4-function tool lifecycle fn-pointer types
429// ---------------------------------------------------------------------------
430
431/// Partial-result callback the **blocking driver** passes to `poll`, wrapped in
432/// `catch_unwind` on the host side. The plugin invokes it **synchronously
433/// inside `poll()`** when it has a `Pending` partial — never retained, never
434/// invoked after `Done`/`Err`.
435///
436/// `partial` is a JSON `AgentToolResult`; ownership passes to the callback (the
437/// host frees it via the host's `free_string`).
438pub type ToolPartialCb = extern "C" fn(partial: StbString, user_data: *mut c_void);
439
440/// `execute(tool_call_id, params) -> StepHandle`. Plugin-allocates a drive
441/// handle and begins the work (non-blocking — the real progress comes via
442/// `poll`). `tool_call_id` is a borrowed [`StbStringRef`] (valid for the call);
443/// `params` is an owning JSON string of the tool-call arguments (the plugin
444/// frees it via the host's `free_string`). Returns null on allocation failure.
445pub type ToolExecuteFn = extern "C" fn(
446    tool_call_id: StbStringRef,
447    params: StbString,
448    free_params: Option<FreeStringFn>,
449) -> StepHandle;
450
451/// `poll(handle, partial_cb, user_data) -> StepResult`. **Non-blocking.** Must
452/// observe the cancel flag (set by [`ToolCancelFn`]) and return `Done`/`Err`
453/// within a bounded number of polls. Borrows `handle` (does not free it).
454pub type ToolPollFn = extern "C" fn(
455    handle: StepHandle,
456    partial_cb: Option<ToolPartialCb>,
457    user_data: *mut c_void,
458) -> StepResult;
459
460/// `cancel(handle)`. Sets an internal `AtomicBool` (SeqCst) cancel flag.
461/// **Idempotent, thread-safe, does NOT free.** The poll loop observes it.
462pub type ToolCancelFn = extern "C" fn(handle: StepHandle);
463
464/// `destroy(handle)`. Frees the handle. **Idempotent; called exactly once by the
465/// blocking driver on exit** (after the loop sees `Done`/`Err`, or after cancel
466/// propagated). Null handle is a no-op.
467pub type ToolDestroyFn = extern "C" fn(handle: StepHandle);
468
469// ---------------------------------------------------------------------------
470// StablePluginEvent — 33 on() categories (explicit tag + union)
471// ---------------------------------------------------------------------------
472
473/// Discriminant for [`StablePluginEvent`], one variant per pi `on()` category
474/// (33 total). `#[repr(u32)]` pins the discriminant width.
475///
476/// The 33 categories (verified against `extensions/types.ts:1203-1244`):
477/// project_trust, resources_discover, session_start, session_info_changed,
478/// session_before_switch, session_before_fork, session_before_compact,
479/// session_compact, session_shutdown, session_before_tree, session_tree,
480/// context, before_provider_request, before_provider_headers,
481/// after_provider_response, before_agent_start, agent_start, agent_end,
482/// agent_settled, turn_start, turn_end, message_start, message_update,
483/// message_end, tool_execution_start, tool_execution_update,
484/// tool_execution_end, model_select, thinking_level_select, tool_call,
485/// tool_result, user_bash, input.
486#[repr(u32)]
487#[derive(Debug, Clone, Copy, PartialEq, Eq)]
488pub enum EventTag {
489    ProjectTrust = 0,
490    ResourcesDiscover = 1,
491    SessionStart = 2,
492    SessionInfoChanged = 3,
493    SessionBeforeSwitch = 4,
494    SessionBeforeFork = 5,
495    SessionBeforeCompact = 6,
496    SessionCompact = 7,
497    SessionShutdown = 8,
498    SessionBeforeTree = 9,
499    SessionTree = 10,
500    Context = 11,
501    BeforeProviderRequest = 12,
502    BeforeProviderHeaders = 13,
503    AfterProviderResponse = 14,
504    BeforeAgentStart = 15,
505    AgentStart = 16,
506    AgentEnd = 17,
507    AgentSettled = 18,
508    TurnStart = 19,
509    TurnEnd = 20,
510    MessageStart = 21,
511    MessageUpdate = 22,
512    MessageEnd = 23,
513    ToolExecutionStart = 24,
514    ToolExecutionUpdate = 25,
515    ToolExecutionEnd = 26,
516    ModelSelect = 27,
517    ThinkingLevelSelect = 28,
518    ToolCall = 29,
519    ToolResult = 30,
520    UserBash = 31,
521    Input = 32,
522}
523
524/// Number of `on()` event categories — `33`. A test asserts
525/// `EVENT_TAG_COUNT == 33` so a future edit that adds/removes a tag is caught.
526pub const EVENT_TAG_COUNT: usize = 33;
527
528/// No-payload marker for events that carry none (e.g. `session_shutdown`).
529/// Carries a dummy byte so the empty-struct isn't flagged FFI-unsafe by
530/// `improper_ctypes` (zero-sized C structs are rejected regardless of `repr(C)`).
531#[repr(C)]
532#[derive(Clone, Copy)]
533pub struct EventEmpty {
534    _opaque: u8,
535}
536
537impl EventEmpty {
538    /// The one no-payload instance.
539    pub const INSTANCE: EventEmpty = EventEmpty { _opaque: 0 };
540}
541
542impl Default for EventEmpty {
543    fn default() -> Self {
544        Self::INSTANCE
545    }
546}
547
548/// A serialized message payload (`message_start`/`update`/`end`, tool-result
549/// messages). `message` is a JSON `AgentMessage`.
550#[repr(C)]
551#[derive(Clone, Copy)]
552pub struct EventMessage {
553    pub message: StbString,
554}
555
556/// A tool-call payload (`tool_call`, `tool_execution_start`/`update`).
557/// `tool_call_id` + `tool_name` are raw strings; `params` is the JSON args.
558#[repr(C)]
559#[derive(Clone, Copy)]
560pub struct EventToolCall {
561    pub tool_call_id: StbString,
562    pub tool_name: StbString,
563    pub params: StbString,
564}
565
566/// A tool-result payload (`tool_result`, `tool_execution_end`).
567#[repr(C)]
568#[derive(Clone, Copy)]
569pub struct EventToolResult {
570    pub tool_call_id: StbString,
571    pub tool_name: StbString,
572    pub result: StbString,
573    pub is_error: u8,
574}
575
576/// An error/failure payload.
577#[repr(C)]
578#[derive(Clone, Copy)]
579pub struct EventError {
580    pub message: StbString,
581}
582
583/// A generic JSON-data payload for the long-tail events whose structured shape
584/// the host serializes wholesale (`context`, `before_provider_request`, model
585/// select, resources_discover response, etc.). The plugin reads the fields it
586/// needs.
587#[repr(C)]
588#[derive(Clone, Copy)]
589pub struct EventData {
590    pub data: StbString,
591}
592
593/// Payload union for [`StablePluginEvent`]. All variants are `#[repr(C)]` structs
594/// of [`StbString`] / primitives (Copy), so the union is Copy. Discriminate by
595/// [`StablePluginEvent::tag`] before reading.
596#[repr(C)]
597#[derive(Clone, Copy)]
598pub union EventPayload {
599    pub empty: EventEmpty,
600    pub message: EventMessage,
601    pub tool_call: EventToolCall,
602    pub tool_result: EventToolResult,
603    pub error: EventError,
604    pub data: EventData,
605}
606
607/// One event dispatched to a plugin handler. The host translates its native
608/// `AgentEvent` / `HarnessEvent` into this and calls every registered handler
609/// for the `tag` (dispatch wrapped in `catch_unwind`). Ownership of the
610/// [`StbString`]s passes to the handler; the handler frees them via the host's
611/// `free_string`.
612#[repr(C)]
613#[derive(Clone, Copy)]
614pub struct StablePluginEvent {
615    pub tag: EventTag,
616    pub payload: EventPayload,
617}
618
619impl StablePluginEvent {
620    /// Build a no-payload event.
621    pub fn empty(tag: EventTag) -> Self {
622        Self {
623            tag,
624            payload: EventPayload {
625                empty: EventEmpty::INSTANCE,
626            },
627        }
628    }
629
630    /// Build a message event.
631    pub fn message(tag: EventTag, message: StbString) -> Self {
632        debug_assert!(matches!(
633            tag,
634            EventTag::MessageStart | EventTag::MessageUpdate | EventTag::MessageEnd
635        ));
636        Self {
637            tag,
638            payload: EventPayload {
639                message: EventMessage { message },
640            },
641        }
642    }
643
644    /// Build a tool-call event.
645    pub fn tool_call(
646        tag: EventTag,
647        tool_call_id: StbString,
648        tool_name: StbString,
649        params: StbString,
650    ) -> Self {
651        debug_assert!(matches!(
652            tag,
653            EventTag::ToolCall | EventTag::ToolExecutionStart | EventTag::ToolExecutionUpdate
654        ));
655        Self {
656            tag,
657            payload: EventPayload {
658                tool_call: EventToolCall {
659                    tool_call_id,
660                    tool_name,
661                    params,
662                },
663            },
664        }
665    }
666
667    /// Build a tool-result event.
668    pub fn tool_result(
669        tag: EventTag,
670        tool_call_id: StbString,
671        tool_name: StbString,
672        result: StbString,
673        is_error: bool,
674    ) -> Self {
675        debug_assert!(matches!(
676            tag,
677            EventTag::ToolResult | EventTag::ToolExecutionEnd
678        ));
679        Self {
680            tag,
681            payload: EventPayload {
682                tool_result: EventToolResult {
683                    tool_call_id,
684                    tool_name,
685                    result,
686                    is_error: is_error as u8,
687                },
688            },
689        }
690    }
691
692    /// Build an error event.
693    pub fn error(tag: EventTag, message: StbString) -> Self {
694        Self {
695            tag,
696            payload: EventPayload {
697                error: EventError { message },
698            },
699        }
700    }
701
702    /// Build a generic data event (JSON in `data`).
703    pub fn data(tag: EventTag, data: StbString) -> Self {
704        Self {
705            tag,
706            payload: EventPayload {
707                data: EventData { data },
708            },
709        }
710    }
711}
712
713/// Handler fn pointer registered via `register_event_handler(tag, handler)`.
714/// `user_data` is the plugin's opaque context. Return `0` on success; nonzero
715/// signals a handled error (the host logs it; dispatch continues to other
716/// handlers — one handler's error does not abort the fan-out).
717pub type EventHandlerFn = extern "C" fn(event: StablePluginEvent, user_data: *mut c_void) -> i32;
718
719/// `resources_discover` handler signature (B5b). Unlike [`EventHandlerFn`]
720/// (fire-and-forget, `i32` only), this carries an owning `out` so the plugin
721/// can hand `{skillPaths, promptPaths, themePaths}` back to the host. `cwd` and
722/// `reason` are borrowed inputs ([`StbStringRef`]); `out` is plugin-produced
723/// and reclaimed via the `plugin_free_string` the host stored alongside the
724/// handler at registration. `user_data` is the plugin's opaque context. Returns
725/// `0` on success (host reads `out`); nonzero on a handled error (host logs +
726/// skips this handler, fan-out continues — mirrors pi `runner.ts:1179-1188`).
727pub type ResourcesDiscoverFn = extern "C" fn(
728    cwd: StbStringRef,
729    reason: StbStringRef,
730    out: *mut StbString,
731    user_data: *mut c_void,
732) -> i32;
733
734// ---------------------------------------------------------------------------
735// Runtime actions — uniform JSON-RPC dispatch by RuntimeActionId
736// ---------------------------------------------------------------------------
737
738/// Identifier for a host runtime action the plugin may invoke via
739/// `PluginApiVt::runtime_action`. One slot dispatches all actions; the numeric
740/// id crosses the FFI boundary as a `u32` and the host validates it with
741/// [`TryFrom<u32>`] before constructing this enum. Args/results cross as JSON
742/// strings.
743#[repr(u32)]
744#[derive(Debug, Clone, Copy, PartialEq, Eq)]
745pub enum RuntimeActionId {
746    SendMessage = 0,
747    SendUserMessage = 1,
748    AppendEntry = 2,
749    SetSessionName = 3,
750    GetActiveTools = 4,
751    SetActiveTools = 5,
752    SetModel = 6,
753    GetThinkingLevel = 7,
754    SetThinkingLevel = 8,
755    Compact = 9,
756    GetSystemPrompt = 10,
757    NewSession = 11,
758    Fork = 12,
759    NavigateTree = 13,
760    SwitchSession = 14,
761    Reload = 15,
762    /// Read a parsed CLI flag by name. Args: `{"name":"flag"}`; result:
763    /// `{"value": <bool|string|null>}`.
764    GetCliFlag = 16,
765}
766
767/// Error returned when a plugin passes a numeric runtime-action id that this
768/// ABI does not define.
769#[derive(Debug, Clone, Copy, PartialEq, Eq)]
770pub struct UnknownRuntimeActionId(pub u32);
771
772impl core::fmt::Display for UnknownRuntimeActionId {
773    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
774        write!(f, "unknown runtime action id {}", self.0)
775    }
776}
777
778impl std::error::Error for UnknownRuntimeActionId {}
779
780impl TryFrom<u32> for RuntimeActionId {
781    type Error = UnknownRuntimeActionId;
782
783    fn try_from(value: u32) -> Result<Self, Self::Error> {
784        match value {
785            0 => Ok(Self::SendMessage),
786            1 => Ok(Self::SendUserMessage),
787            2 => Ok(Self::AppendEntry),
788            3 => Ok(Self::SetSessionName),
789            4 => Ok(Self::GetActiveTools),
790            5 => Ok(Self::SetActiveTools),
791            6 => Ok(Self::SetModel),
792            7 => Ok(Self::GetThinkingLevel),
793            8 => Ok(Self::SetThinkingLevel),
794            9 => Ok(Self::Compact),
795            10 => Ok(Self::GetSystemPrompt),
796            11 => Ok(Self::NewSession),
797            12 => Ok(Self::Fork),
798            13 => Ok(Self::NavigateTree),
799            14 => Ok(Self::SwitchSession),
800            15 => Ok(Self::Reload),
801            16 => Ok(Self::GetCliFlag),
802            other => Err(UnknownRuntimeActionId(other)),
803        }
804    }
805}
806
807impl From<RuntimeActionId> for u32 {
808    fn from(value: RuntimeActionId) -> Self {
809        value as u32
810    }
811}
812
813/// Runtime-action signature: `runtime_action(action_id, args_json, out,
814/// user_data) -> i32`. `args_json` is a borrowed input ([`StbStringRef`]); `out`
815/// is an owning output ([`StbString`]) the host produces and the plugin frees
816/// via the host's `free_string`. `action_id` is deliberately a raw `u32`, not a
817/// Rust enum: an unknown value must be rejected as a normal protocol error
818/// rather than materializing an invalid enum discriminant. Returns `0` on
819/// success, nonzero on error.
820pub type RuntimeActionFn = extern "C" fn(
821    action_id: u32,
822    args_json: StbStringRef,
823    out: *mut StbString,
824    user_data: *mut c_void,
825) -> i32;
826
827// ---------------------------------------------------------------------------
828// PluginApiVt — host-provided vtable of fn pointers the plugin calls
829// ---------------------------------------------------------------------------
830
831/// A generic command-handler fn (for `register_command`). `args_json` is a
832/// borrowed `{"args":"...","command":"/..."}` envelope; `out` is owning
833/// JSON output reclaimed with the host `free_string`. The TUI understands
834/// `{kind:"message",text}`, `{kind:"selector",items:[...]}`,
835/// `{kind:"editor",initialText}`, and `{kind:"input",title,placeholder}`
836/// responses; selector/editor/input submissions call the same handler with an
837/// `action` field in `args`.
838pub type CommandHandlerFn =
839    extern "C" fn(args_json: StbStringRef, out: *mut StbString, user_data: *mut c_void) -> i32;
840
841/// A render/transform fn (for the renderer registrars). `input_json` is borrowed;
842/// `out` is owning output the plugin frees via host `free_string`. Markdown
843/// handlers return `{markdown:"..."}`; message/entry handlers return
844/// `{text:"...",markdown?:true}` or `{lines:["..."]}` for terminal UI.
845pub type RenderFn =
846    extern "C" fn(input_json: StbStringRef, out: *mut StbString, user_data: *mut c_void) -> i32;
847
848/// A provider-injection factory fn (for `register_provider`). `req_json` is a
849/// borrowed request envelope; `out` is an owning response the plugin frees.
850/// The host wraps this into a `Provider` impl (B4/B5).
851pub type ProviderRequestFn =
852    extern "C" fn(req_json: StbStringRef, out: *mut StbString, user_data: *mut c_void) -> i32;
853
854/// ABI v1 runtime-action signature. The original SDK exposed a `#[repr(u32)]`
855/// enum at this position; the legacy host view uses the ABI-equivalent raw
856/// integer so it can reject unknown values before constructing an enum.
857pub type LegacyRuntimeActionFn = extern "C" fn(
858    action_id: u32,
859    args_json: StbStringRef,
860    out: *mut StbString,
861    user_data: *mut c_void,
862) -> i32;
863
864/// Frozen host vtable layout used by plugins exporting
865/// [`LEGACY_REGISTER_SYMBOL`]. Do not add, remove, reorder, or retype fields in
866/// this struct. New plugins use [`PluginApiVt`] and [`REGISTER_SYMBOL_V2`].
867///
868/// The v1 action slot accepts only the historical ids `0..=15`. Hosts must
869/// validate the raw `u32` before dispatching it.
870#[repr(C)]
871pub struct LegacyPluginApiV1 {
872    pub free_string: FreeStringFn,
873    pub register_tool: Option<
874        extern "C" fn(
875            schema: *const StableToolSchema,
876            execute_fn: ToolExecuteFn,
877            poll_fn: ToolPollFn,
878            cancel_fn: ToolCancelFn,
879            destroy_fn: ToolDestroyFn,
880            plugin_free_string: FreeStringFn,
881        ) -> i32,
882    >,
883    pub register_command: Option<
884        extern "C" fn(
885            name: StbStringRef,
886            description: StbStringRef,
887            handler: CommandHandlerFn,
888        ) -> i32,
889    >,
890    pub register_shortcut:
891        Option<extern "C" fn(key: StbStringRef, description: StbStringRef) -> i32>,
892    pub register_flag: Option<extern "C" fn(name: StbStringRef, description: StbStringRef) -> i32>,
893    pub register_provider: Option<
894        extern "C" fn(
895            provider_id: StbStringRef,
896            base_url: StbStringRef,
897            api_style: StbStringRef,
898            request_fn: ProviderRequestFn,
899            plugin_free_string: FreeStringFn,
900            user_data: *mut c_void,
901        ) -> i32,
902    >,
903    pub register_message_renderer: Option<
904        extern "C" fn(
905            name: StbStringRef,
906            render_fn: RenderFn,
907            plugin_free_string: FreeStringFn,
908            user_data: *mut c_void,
909        ) -> i32,
910    >,
911    pub register_markdown_transformer: Option<
912        extern "C" fn(
913            name: StbStringRef,
914            render_fn: RenderFn,
915            plugin_free_string: FreeStringFn,
916            user_data: *mut c_void,
917        ) -> i32,
918    >,
919    pub register_entry_renderer: Option<
920        extern "C" fn(
921            name: StbStringRef,
922            render_fn: RenderFn,
923            plugin_free_string: FreeStringFn,
924            user_data: *mut c_void,
925        ) -> i32,
926    >,
927    pub register_event_handler: Option<
928        extern "C" fn(tag: EventTag, handler: EventHandlerFn, user_data: *mut c_void) -> i32,
929    >,
930    pub register_resources_discover: Option<
931        extern "C" fn(
932            handler: ResourcesDiscoverFn,
933            plugin_free_string: FreeStringFn,
934            user_data: *mut c_void,
935        ) -> i32,
936    >,
937    pub runtime_action: LegacyRuntimeActionFn,
938    pub dispatch_event:
939        Option<extern "C" fn(event: StablePluginEvent, user_data: *mut c_void) -> i32>,
940    pub user_data: *mut c_void,
941}
942
943unsafe impl Send for LegacyPluginApiV1 {}
944unsafe impl Sync for LegacyPluginApiV1 {}
945
946/// The ABI v2 host-provided vtable, passed to `rpi_plugin_register_v2` as a
947/// `*const`.
948///
949/// The plugin reads it during `register` and may copy fn pointers it needs (the
950/// struct is POD/Copy). **Every slot is nullable**: a null fn pointer means the
951/// host does not support that capability yet — the plugin MUST null-check
952/// before calling and degrade gracefully. This keeps the vtable forward-
953/// compatible across rpi versions without re-ABI bumps within one
954/// `RPI_PLUGIN_ABI_VERSION`.
955///
956/// `user_data` is the host's opaque context, passed back to every host-provided
957/// fn (so the host can recover its session/harness state). The plugin stores it
958/// and passes it through unchanged.
959#[repr(C)]
960pub struct PluginApiVt {
961    /// Host's `free_string` — the plugin calls this for every [`StbString`] it
962    /// *receives* from the host (outputs of actions, event payloads, inputs to
963    /// execute). Never null.
964    pub free_string: FreeStringFn,
965
966    // --- 8 registrars (plugin → host "register X into the host") ---
967    /// Register a tool. `schema` + the four lifecycle fns + the plugin's own
968    /// `free_string` (for the [`StbString`]s in `schema`). Returns `0` on
969    /// success. Nullable: host not yet wired for tool registration.
970    pub register_tool: Option<
971        extern "C" fn(
972            schema: *const StableToolSchema,
973            execute_fn: ToolExecuteFn,
974            poll_fn: ToolPollFn,
975            cancel_fn: ToolCancelFn,
976            destroy_fn: ToolDestroyFn,
977            plugin_free_string: FreeStringFn,
978        ) -> i32,
979    >,
980
981    /// Register a slash command. Nullable.
982    pub register_command: Option<
983        extern "C" fn(
984            name: StbStringRef,
985            description: StbStringRef,
986            handler: CommandHandlerFn,
987        ) -> i32,
988    >,
989
990    /// Register a keyboard shortcut. Nullable.
991    pub register_shortcut:
992        Option<extern "C" fn(key: StbStringRef, description: StbStringRef) -> i32>,
993
994    /// Register a CLI flag using its bare name (without leading `--`). The
995    /// parsed value is read later with [`RuntimeActionId::GetCliFlag`].
996    /// Nullable.
997    pub register_flag: Option<extern "C" fn(name: StbStringRef, description: StbStringRef) -> i32>,
998
999    /// Register a custom provider. The host stores `provider_id`/`base_url`/
1000    /// `api_style` + the plugin's `request_fn` + the plugin's own
1001    /// `plugin_free_string` (the `out` [`StbString`] `request_fn` *produces* is
1002    /// plugin-owned and the host must reclaim it — same ownership rule as
1003    /// `register_resources_discover`) + the plugin's `user_data` (which
1004    /// `request_fn` receives back unmodified on every call). Nullable (B5c).
1005    pub register_provider: Option<
1006        extern "C" fn(
1007            provider_id: StbStringRef,
1008            base_url: StbStringRef,
1009            api_style: StbStringRef,
1010            request_fn: ProviderRequestFn,
1011            plugin_free_string: FreeStringFn,
1012            user_data: *mut c_void,
1013        ) -> i32,
1014    >,
1015
1016    /// Register a message renderer. `plugin_free_string` reclaims the `out`
1017    /// [`StbString`] `render_fn` produces; `user_data` is passed back to it on
1018    /// every render call. Nullable; interactive TUI consumption accepts the
1019    /// host JSON component envelope (`text`/`lines`).
1020    pub register_message_renderer: Option<
1021        extern "C" fn(
1022            name: StbStringRef,
1023            render_fn: RenderFn,
1024            plugin_free_string: FreeStringFn,
1025            user_data: *mut c_void,
1026        ) -> i32,
1027    >,
1028
1029    /// Register a markdown transformer. Same ownership shape as
1030    /// `register_message_renderer`. Nullable; markdown output is chained in
1031    /// assistant rendering.
1032    pub register_markdown_transformer: Option<
1033        extern "C" fn(
1034            name: StbStringRef,
1035            render_fn: RenderFn,
1036            plugin_free_string: FreeStringFn,
1037            user_data: *mut c_void,
1038        ) -> i32,
1039    >,
1040
1041    /// Register an entry renderer. Same ownership shape as
1042    /// `register_message_renderer`. Nullable; interactive TUI consumption
1043    /// accepts the host JSON component envelope (`text`/`lines`).
1044    pub register_entry_renderer: Option<
1045        extern "C" fn(
1046            name: StbStringRef,
1047            render_fn: RenderFn,
1048            plugin_free_string: FreeStringFn,
1049            user_data: *mut c_void,
1050        ) -> i32,
1051    >,
1052
1053    // --- on() event handler registration (the 33-category subscription) ---
1054    /// Subscribe a handler to one event `tag`. Nullable: host not yet wiring
1055    /// events. The host dispatches [`StablePluginEvent`]s of that tag to the
1056    /// handler (`catch_unwind`-wrapped).
1057    pub register_event_handler: Option<
1058        extern "C" fn(tag: EventTag, handler: EventHandlerFn, user_data: *mut c_void) -> i32,
1059    >,
1060
1061    /// Register a `resources_discover` handler (B5b). The host stores `handler`
1062    /// + the plugin's own `plugin_free_string` (the `out` [`StbString`] the
1063    /// handler produces is plugin-owned and the host must reclaim it) +
1064    /// `user_data`. On discovery (`startup`/`reload`) the host fans the event to
1065    /// every registered handler in order, concatenating their returned
1066    /// `{skillPaths, promptPaths, themePaths}` (errors per-handler do NOT abort
1067    /// the fan-out). Nullable: a host without the resources-discover path leaves
1068    /// this null and the plugin must degrade (no dynamic resource contribution).
1069    pub register_resources_discover: Option<
1070        extern "C" fn(
1071            handler: ResourcesDiscoverFn,
1072            plugin_free_string: FreeStringFn,
1073            user_data: *mut c_void,
1074        ) -> i32,
1075    >,
1076
1077    // --- runtime actions (17, uniform dispatch) ---
1078    /// Invoke a host runtime action. See [`RuntimeActionId`] / [`RuntimeActionFn`].
1079    /// Always present; a host without an action bridge installs a nonzero-returning
1080    /// stub so plugins can degrade gracefully.
1081    pub runtime_action: RuntimeActionFn,
1082
1083    // --- event dispatch (plugin → host "emit an event upstream") ---
1084    /// Emit an event upstream (e.g. a tool announcing a custom UI event). The
1085    /// host forwards to interested subscribers. Ownership of the event's
1086    /// [`StbString`]s passes to the host (freed via `free_string`). Nullable.
1087    pub dispatch_event:
1088        Option<extern "C" fn(event: StablePluginEvent, user_data: *mut c_void) -> i32>,
1089
1090    /// The host's opaque context, passed through to every host-provided fn.
1091    /// The plugin stores this and hands it back unmodified on each call.
1092    pub user_data: *mut c_void,
1093}
1094
1095// SAFETY: the vtable is a POD struct of fn pointers + one raw `user_data`
1096// pointer. It is `Send`+`Sync` so the host can hand it to the plugin's register
1097// thread and the plugin can call its fns from the blocking driver thread; the
1098// host guarantees the `user_data` is valid across those calls.
1099unsafe impl Send for PluginApiVt {}
1100unsafe impl Sync for PluginApiVt {}
1101
1102// ---------------------------------------------------------------------------
1103// Register contract
1104// ---------------------------------------------------------------------------
1105
1106/// The ABI version this SDK publishes. ABI v2 adds the `GetCliFlag` runtime
1107/// action and makes [`RuntimeActionFn`]'s action parameter an explicitly
1108/// validated `u32`.
1109///
1110/// A host and plugin built for different ABI versions must never use each
1111/// other's [`PluginApiVt`]. [`register_entrypoint`] compares the scalar version
1112/// before dereferencing `api`, so an ABI v1 plugin presented to a v2 host (or a
1113/// v2 plugin presented to a v1 host) returns nonzero and is safely skipped
1114/// rather than reading a differently defined vtable.
1115pub const RPI_PLUGIN_ABI_VERSION: u32 = 2;
1116
1117/// ABI version passed to the legacy `rpi_plugin_register` entrypoint.
1118pub const LEGACY_PLUGIN_ABI_VERSION: u32 = 1;
1119
1120/// The ABI v2 symbol the host looks up first in each cdylib.
1121/// Must be an `extern "C" fn(*const PluginApiVt, u32) -> i32`.
1122pub const REGISTER_SYMBOL_V2: &[u8] = b"rpi_plugin_register_v2\0";
1123
1124/// Alias for the current SDK's register symbol.
1125pub const REGISTER_SYMBOL: &[u8] = REGISTER_SYMBOL_V2;
1126
1127/// The ABI v1 symbol used only when [`REGISTER_SYMBOL_V2`] is absent.
1128pub const LEGACY_REGISTER_SYMBOL: &[u8] = b"rpi_plugin_register\0";
1129
1130/// Plugin entrypoint signature. The host loads the cdylib, looks up
1131/// `rpi_plugin_register_v2`, and calls it with the host `PluginApiVt` and the
1132/// host's current `RPI_PLUGIN_ABI_VERSION`.
1133///
1134/// Return `0` on successful registration; nonzero is a plugin-defined error
1135/// code (the host logs it and skips the plugin). The plugin must compare
1136/// `abi_version` before reading `api`; [`register_entrypoint`] implements this
1137/// ordering and safely rejects old/new ABI mixtures. A plugin should copy any
1138/// needed function pointers only inside the callback this helper invokes.
1139pub type RpiPluginRegister = extern "C" fn(api: *const PluginApiVt, abi_version: u32) -> i32;
1140
1141/// ABI v1 plugin entrypoint signature.
1142pub type LegacyRpiPluginRegister =
1143    extern "C" fn(api: *const LegacyPluginApiV1, abi_version: u32) -> i32;
1144
1145/// Export an ABI v2 plugin entrypoint under `rpi_plugin_register_v2`.
1146///
1147/// The expression receives `&PluginApiVt` and returns the plugin-defined
1148/// registration status code.
1149///
1150/// ```ignore
1151/// rpi_plugin_sdk::export_plugin_v2!(|api| {
1152///     // register tools and handlers through `api`
1153///     0
1154/// });
1155/// ```
1156#[macro_export]
1157macro_rules! export_plugin_v2 {
1158    ($body:expr) => {
1159        #[no_mangle]
1160        pub extern "C" fn rpi_plugin_register_v2(
1161            api: *const $crate::PluginApiVt,
1162            abi_version: u32,
1163        ) -> i32 {
1164            $crate::register_entrypoint(api, abi_version, $body)
1165        }
1166    };
1167}
1168
1169/// Convenience for host + plugin: declare the register entrypoint.
1170///
1171/// A plugin crate writes:
1172/// ```ignore
1173/// #[no_mangle]
1174/// pub extern "C" fn rpi_plugin_register_v2(api: *const PluginApiVt, abi_version: u32) -> i32 {
1175///     rpi_plugin_sdk::register_entrypoint(api, abi_version, |api| {
1176///         // ... register tools / handlers using `api` ...
1177///         0
1178///     })
1179/// }
1180/// ```
1181/// The helper performs the version check (return nonzero on mismatch) and
1182/// null-checks `api` before invoking the plugin body.
1183pub fn register_entrypoint(
1184    api: *const PluginApiVt,
1185    abi_version: u32,
1186    body: impl FnOnce(&PluginApiVt) -> i32,
1187) -> i32 {
1188    if abi_version != RPI_PLUGIN_ABI_VERSION {
1189        // Mismatch: refuse to register. The host logs "ABI version mismatch"
1190        // and skips loading this plugin.
1191        return 1;
1192    }
1193    if api.is_null() {
1194        return 2;
1195    }
1196    // SAFETY: the host guarantees `api` is valid for the register call and the
1197    // plugin does not retain the borrow past `body` (it copies the fn pointers
1198    // it needs).
1199    let api = unsafe { &*api };
1200    body(api)
1201}
1202
1203// ===========================================================================
1204// Tests (need std + serde_json)
1205// ===========================================================================
1206
1207#[cfg(test)]
1208mod tests {
1209    use super::*;
1210
1211    // A test allocator + free fn so we can verify the own/free contract
1212    // without a real plugin's free_string.
1213    std::thread_local! {
1214        static FREED: std::cell::Cell<usize> = std::cell::Cell::new(0);
1215    }
1216
1217    extern "C" fn test_free(s: StbString) {
1218        if s.is_empty() || s.ptr.is_null() {
1219            return;
1220        }
1221        // Reconstruct the boxed slice and drop it.
1222        unsafe {
1223            let slice = core::slice::from_raw_parts(s.ptr as *const u8, s.len);
1224            let _ = Box::from_raw(slice as *const [u8] as *mut [u8]);
1225        }
1226        FREED.with(|freed| freed.set(freed.get() + 1));
1227    }
1228
1229    fn reset_freed() -> usize {
1230        FREED.with(|freed| freed.replace(0))
1231    }
1232
1233    fn freed_count() -> usize {
1234        FREED.with(std::cell::Cell::get)
1235    }
1236
1237    // A no-op `runtime_action` impl for the vtable-construction tests (closures
1238    // can't coerce to `extern "C" fn`, so we use a real fn).
1239    extern "C" fn noop_runtime_action(
1240        _action_id: u32,
1241        _args: StbStringRef,
1242        _out: *mut StbString,
1243        _user_data: *mut c_void,
1244    ) -> i32 {
1245        0
1246    }
1247
1248    #[test]
1249    fn stbstring_round_trip_and_free_once() {
1250        let prev = reset_freed();
1251        let _ = prev;
1252        let s = StbString::from_string("hello, pi".to_string());
1253        assert_eq!(s.len, 9);
1254        assert_eq!(s.to_string_lossy(), "hello, pi");
1255        s.free_with(Some(test_free));
1256        assert_eq!(freed_count(), 1);
1257    }
1258
1259    #[test]
1260    fn empty_stbstring_free_is_noop() {
1261        let _ = reset_freed();
1262        StbString::empty().free_with(Some(test_free));
1263        assert_eq!(freed_count(), 0);
1264    }
1265
1266    #[test]
1267    fn json_round_trip_preserves_structure() {
1268        let val = serde_json::json!({ "name": "echo", "args": [1, 2, 3], "ok": true });
1269        let stb = json::to_stable(&val, None);
1270        let back = json::from_stable(&stb);
1271        assert_eq!(val, back);
1272        stb.free_with(Some(test_free));
1273        let _ = reset_freed();
1274    }
1275
1276    #[test]
1277    fn step_result_done_round_trip() {
1278        let result_json = StbString::from_string(r#"{"content":[{"text":"hi"}]}"#.to_string());
1279        let sr = StepResult::done(result_json);
1280        assert_eq!(sr.tag, StepResultTag::Done);
1281        // SAFETY: tag == Done.
1282        let done = unsafe { sr.done_payload() };
1283        assert_eq!(
1284            done.result.to_string_lossy(),
1285            r#"{"content":[{"text":"hi"}]}"#
1286        );
1287        done.result.free_with(Some(test_free));
1288        let _ = reset_freed();
1289    }
1290
1291    #[test]
1292    fn step_result_pending_and_err() {
1293        let prog = StbString::from_string("...".to_string());
1294        let srp = StepResult::pending(prog);
1295        assert_eq!(srp.tag, StepResultTag::Pending);
1296        // SAFETY: tag == Pending.
1297        unsafe {
1298            assert_eq!(srp.pending_payload().progress.to_string_lossy(), "...");
1299        }
1300        unsafe { srp.pending_payload().progress.free_with(Some(test_free)) };
1301
1302        let msg = StbString::from_string("boom".to_string());
1303        let sre = StepResult::err(msg);
1304        assert_eq!(sre.tag, StepResultTag::Err);
1305        // SAFETY: tag == Err.
1306        unsafe {
1307            assert_eq!(sre.err_payload().message.to_string_lossy(), "boom");
1308            sre.err_payload().message.free_with(Some(test_free));
1309        }
1310        let _ = reset_freed();
1311    }
1312
1313    #[test]
1314    fn event_tag_count_is_33() {
1315        // Enumerate every tag; a compile-time + runtime guarantee that the
1316        // 33-category surface is intact.
1317        let tags = [
1318            EventTag::ProjectTrust,
1319            EventTag::ResourcesDiscover,
1320            EventTag::SessionStart,
1321            EventTag::SessionInfoChanged,
1322            EventTag::SessionBeforeSwitch,
1323            EventTag::SessionBeforeFork,
1324            EventTag::SessionBeforeCompact,
1325            EventTag::SessionCompact,
1326            EventTag::SessionShutdown,
1327            EventTag::SessionBeforeTree,
1328            EventTag::SessionTree,
1329            EventTag::Context,
1330            EventTag::BeforeProviderRequest,
1331            EventTag::BeforeProviderHeaders,
1332            EventTag::AfterProviderResponse,
1333            EventTag::BeforeAgentStart,
1334            EventTag::AgentStart,
1335            EventTag::AgentEnd,
1336            EventTag::AgentSettled,
1337            EventTag::TurnStart,
1338            EventTag::TurnEnd,
1339            EventTag::MessageStart,
1340            EventTag::MessageUpdate,
1341            EventTag::MessageEnd,
1342            EventTag::ToolExecutionStart,
1343            EventTag::ToolExecutionUpdate,
1344            EventTag::ToolExecutionEnd,
1345            EventTag::ModelSelect,
1346            EventTag::ThinkingLevelSelect,
1347            EventTag::ToolCall,
1348            EventTag::ToolResult,
1349            EventTag::UserBash,
1350            EventTag::Input,
1351        ];
1352        assert_eq!(tags.len(), EVENT_TAG_COUNT);
1353        assert_eq!(EVENT_TAG_COUNT, 33);
1354        // Distinct discriminants 0..32.
1355        let mut discs: Vec<u32> = tags.iter().map(|t| *t as u32).collect();
1356        discs.sort();
1357        assert_eq!(discs, (0..33).collect::<Vec<u32>>());
1358    }
1359
1360    #[test]
1361    fn event_payloads_construct_and_free() {
1362        let m = StbString::from_string("msg".to_string());
1363        let ev = StablePluginEvent::message(EventTag::MessageEnd, m);
1364        assert_eq!(ev.tag, EventTag::MessageEnd);
1365        // SAFETY: tag == MessageEnd (message variant).
1366        unsafe {
1367            assert_eq!(ev.payload.message.message.to_string_lossy(), "msg");
1368            ev.payload.message.message.free_with(Some(test_free));
1369        }
1370
1371        let tc = StablePluginEvent::tool_call(
1372            EventTag::ToolCall,
1373            StbString::from_string("call_1".to_string()),
1374            StbString::from_string("echo".to_string()),
1375            StbString::from_string("{}".to_string()),
1376        );
1377        // SAFETY: tag == ToolCall (tool_call variant).
1378        unsafe {
1379            assert_eq!(tc.payload.tool_call.tool_name.to_string_lossy(), "echo");
1380            tc.payload.tool_call.tool_call_id.free_with(Some(test_free));
1381            tc.payload.tool_call.tool_name.free_with(Some(test_free));
1382            tc.payload.tool_call.params.free_with(Some(test_free));
1383        }
1384        let _ = reset_freed();
1385    }
1386
1387    #[test]
1388    fn plugin_api_vt_is_pod_and_sized() {
1389        // The vtable must be a plain old data struct: every fn pointer is
1390        // non-Drop, the struct has no Drop impl. We exercise that it can be
1391        // zeroed and read without UB.
1392        let vt = PluginApiVt {
1393            free_string: test_free,
1394            register_tool: None,
1395            register_command: None,
1396            register_shortcut: None,
1397            register_flag: None,
1398            register_provider: None,
1399            register_message_renderer: None,
1400            register_markdown_transformer: None,
1401            register_entry_renderer: None,
1402            register_event_handler: None,
1403            register_resources_discover: None,
1404            runtime_action: noop_runtime_action,
1405            dispatch_event: None,
1406            user_data: core::ptr::null_mut(),
1407        };
1408        // All optional slots are null → plugin must degrade.
1409        assert!(vt.register_tool.is_none());
1410        assert!(vt.register_event_handler.is_none());
1411        assert!(vt.register_resources_discover.is_none());
1412        // Copy (POD) — no UB from a plain copy.
1413        let _copy = vt;
1414        // `assert!(core::mem::needs_drop::<PluginApiVt>() == false)` — verified
1415        // by the absence of a Drop impl + all-Copy fields.
1416        assert!(!core::mem::needs_drop::<PluginApiVt>());
1417        assert!(!core::mem::needs_drop::<StbString>());
1418        assert!(!core::mem::needs_drop::<StepResult>());
1419        assert!(!core::mem::needs_drop::<StablePluginEvent>());
1420        assert!(!core::mem::needs_drop::<StableToolSchema>());
1421    }
1422
1423    #[test]
1424    fn legacy_v1_layout_is_frozen_and_matches_v2_shared_slots() {
1425        use core::mem::{align_of, offset_of, size_of};
1426
1427        let pointer_size = size_of::<*const ()>();
1428        assert_eq!(size_of::<LegacyPluginApiV1>(), 14 * pointer_size);
1429        assert_eq!(align_of::<LegacyPluginApiV1>(), align_of::<*const ()>());
1430        assert_eq!(size_of::<PluginApiVt>(), size_of::<LegacyPluginApiV1>());
1431        assert_eq!(align_of::<PluginApiVt>(), align_of::<LegacyPluginApiV1>());
1432
1433        macro_rules! assert_same_offset {
1434            ($field:ident, $index:expr) => {
1435                assert_eq!(
1436                    offset_of!(LegacyPluginApiV1, $field),
1437                    $index * pointer_size,
1438                    concat!("unexpected ABI v1 offset for ", stringify!($field))
1439                );
1440                assert_eq!(
1441                    offset_of!(PluginApiVt, $field),
1442                    offset_of!(LegacyPluginApiV1, $field),
1443                    concat!("v1/v2 shared field moved: ", stringify!($field))
1444                );
1445            };
1446        }
1447
1448        assert_same_offset!(free_string, 0);
1449        assert_same_offset!(register_tool, 1);
1450        assert_same_offset!(register_command, 2);
1451        assert_same_offset!(register_shortcut, 3);
1452        assert_same_offset!(register_flag, 4);
1453        assert_same_offset!(register_provider, 5);
1454        assert_same_offset!(register_message_renderer, 6);
1455        assert_same_offset!(register_markdown_transformer, 7);
1456        assert_same_offset!(register_entry_renderer, 8);
1457        assert_same_offset!(register_event_handler, 9);
1458        assert_same_offset!(register_resources_discover, 10);
1459        assert_same_offset!(runtime_action, 11);
1460        assert_same_offset!(dispatch_event, 12);
1461        assert_same_offset!(user_data, 13);
1462        assert!(!core::mem::needs_drop::<LegacyPluginApiV1>());
1463    }
1464
1465    #[test]
1466    fn runtime_action_ids_are_explicitly_validated() {
1467        let ids = [
1468            RuntimeActionId::SendMessage,
1469            RuntimeActionId::SendUserMessage,
1470            RuntimeActionId::AppendEntry,
1471            RuntimeActionId::SetSessionName,
1472            RuntimeActionId::GetActiveTools,
1473            RuntimeActionId::SetActiveTools,
1474            RuntimeActionId::SetModel,
1475            RuntimeActionId::GetThinkingLevel,
1476            RuntimeActionId::SetThinkingLevel,
1477            RuntimeActionId::Compact,
1478            RuntimeActionId::GetSystemPrompt,
1479            RuntimeActionId::NewSession,
1480            RuntimeActionId::Fork,
1481            RuntimeActionId::NavigateTree,
1482            RuntimeActionId::SwitchSession,
1483            RuntimeActionId::Reload,
1484            RuntimeActionId::GetCliFlag,
1485        ];
1486
1487        for (raw, expected) in ids.into_iter().enumerate() {
1488            assert_eq!(RuntimeActionId::try_from(raw as u32), Ok(expected));
1489            assert_eq!(u32::from(expected), raw as u32);
1490        }
1491        assert_eq!(
1492            RuntimeActionId::try_from(17),
1493            Err(UnknownRuntimeActionId(17))
1494        );
1495        assert_eq!(
1496            RuntimeActionId::try_from(u32::MAX),
1497            Err(UnknownRuntimeActionId(u32::MAX))
1498        );
1499    }
1500
1501    #[test]
1502    fn register_entrypoint_version_mismatch_refuses() {
1503        let vt = PluginApiVt {
1504            free_string: test_free,
1505            register_tool: None,
1506            register_command: None,
1507            register_shortcut: None,
1508            register_flag: None,
1509            register_provider: None,
1510            register_message_renderer: None,
1511            register_markdown_transformer: None,
1512            register_entry_renderer: None,
1513            register_event_handler: None,
1514            register_resources_discover: None,
1515            runtime_action: noop_runtime_action,
1516            dispatch_event: None,
1517            user_data: core::ptr::null_mut(),
1518        };
1519        assert_eq!(RPI_PLUGIN_ABI_VERSION, 2);
1520        assert_eq!(LEGACY_PLUGIN_ABI_VERSION, 1);
1521        assert_eq!(REGISTER_SYMBOL, REGISTER_SYMBOL_V2);
1522        assert_ne!(REGISTER_SYMBOL_V2, LEGACY_REGISTER_SYMBOL);
1523
1524        // An ABI v1 plugin/host mixture is refused before the v2 vtable is read.
1525        // A null pointer makes the ordering observable: checking `api` first
1526        // would return 2, while the required version-first path returns 1.
1527        let rc = register_entrypoint(core::ptr::null(), 1, |_| {
1528            panic!("body must not run on version mismatch");
1529        });
1530        assert_eq!(rc, 1);
1531
1532        // A future version is rejected by the same pre-dereference check.
1533        let rc = register_entrypoint(&vt, RPI_PLUGIN_ABI_VERSION + 1, |_| {
1534            panic!("body must not run on version mismatch");
1535        });
1536        assert_ne!(rc, 0);
1537
1538        // Right version → body runs, rc propagated.
1539        let rc = register_entrypoint(&vt, RPI_PLUGIN_ABI_VERSION, |_| 0);
1540        assert_eq!(rc, 0);
1541        let rc = register_entrypoint(&vt, RPI_PLUGIN_ABI_VERSION, |_| 42);
1542        assert_eq!(rc, 42);
1543
1544        // Null api → refuse.
1545        let rc = register_entrypoint(core::ptr::null(), RPI_PLUGIN_ABI_VERSION, |_| 0);
1546        assert_ne!(rc, 0);
1547        let _ = reset_freed();
1548    }
1549}