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// Host context injected into plugin tool arguments
304// ---------------------------------------------------------------------------
305
306/// Reserved key the host adds to a **plugin** tool call's arguments.
307///
308/// The model never writes this field and never sees it: the host injects it
309/// after the model's arguments are parsed and before the plugin's
310/// [`ToolExecuteFn`] runs, so a plugin can tell *which* project and *which*
311/// session it is serving without the model having to supply either. (Built-in
312/// tools do not need it — they receive the host's `ExecutionToolContext`
313/// directly. A plugin tool has no such channel.)
314///
315/// The value is an object; today:
316///
317/// ```json
318/// { "cwd": "D:\\Projects\\pi-rust",
319///   "sessionId": "01adb026-7230-708b-855b-91eab6b056b8" }
320/// ```
321///
322/// Both fields are optional and either may be absent: `cwd` when the host
323/// cannot resolve one, `sessionId` for a session that has no id (ephemeral
324/// sessions) and on hosts that predate this field. The key itself may be absent
325/// entirely. Treat everything under it as best-effort, and prefer a
326/// project-scoped fallback when `sessionId` is missing rather than refusing to
327/// work.
328///
329/// **Consequence for plugin parameter types:** a plugin must tolerate unknown
330/// top-level keys in its arguments. Reading them out of a `serde_json::Value`
331/// does that naturally; a `#[serde(deny_unknown_fields)]` parameter struct
332/// would reject this key, so do not use one for tool parameters.
333///
334/// The host's value wins if the model happens to send the same key: this is
335/// host truth, not a hint to be merged.
336pub const HOST_CONTEXT_KEY: &str = "__rpi";
337
338// ---------------------------------------------------------------------------
339// StepResult — the poll() return: Pending | Done | Err (explicit tag + union)
340// ---------------------------------------------------------------------------
341
342/// Opaque, plugin-allocated handle for one tool execution drive. Produced by
343/// [`ToolExecuteFn`], polled by [`ToolPollFn`], cancelled by [`ToolCancelFn`],
344/// freed by [`ToolDestroyFn`] (exactly once, idempotent).
345///
346/// The handle's interior layout is entirely plugin-private; the host treats it
347/// as an opaque pointer.
348pub type StepHandle = *mut c_void;
349
350/// Discriminant for [`StepResult`]. `#[repr(u32)]` pins the width so the union
351/// payload is sound across compilers.
352#[repr(u32)]
353#[derive(Debug, Clone, Copy, PartialEq, Eq)]
354pub enum StepResultTag {
355    /// `poll` has no terminal result yet; the partial payload may carry progress.
356    Pending = 0,
357    /// Terminal success; `done.result` is the JSON `AgentToolResult`.
358    Done = 1,
359    /// Terminal failure; `err.message` is a UTF-8 error string.
360    Err = 2,
361}
362
363/// A partial/progress result emitted during `Pending`. `progress` is a JSON
364/// `AgentToolResult` (the same shape `on_update` carries) — the host forwards it
365/// to the adapter's `on_update` callback. May be empty.
366#[repr(C)]
367#[derive(Clone, Copy)]
368pub struct StbPending {
369    pub progress: StbString,
370}
371
372/// Terminal success payload. `result` is a JSON `AgentToolResult`.
373#[repr(C)]
374#[derive(Clone, Copy)]
375pub struct StbDone {
376    pub result: StbString,
377}
378
379/// Terminal failure payload. `message` is a UTF-8 error string.
380#[repr(C)]
381#[derive(Clone, Copy)]
382pub struct StbErr {
383    pub message: StbString,
384}
385
386/// The `poll()` return value. Read the `payload` variant matching `tag`.
387///
388/// All payload variants are `#[repr(C)]` structs of [`StbString`] (Copy), so the
389/// union is `Copy`. A wrong-variant read is `unsafe`; always match on `tag`.
390#[repr(C)]
391#[derive(Clone, Copy)]
392pub union StepResultPayload {
393    pub pending: StbPending,
394    pub done: StbDone,
395    pub err: StbErr,
396}
397
398/// Return value of [`ToolPollFn`]. The blocking driver matches on `tag`, reads
399/// the matching payload, and breaks the loop on `Done`/`Err`.
400#[repr(C)]
401#[derive(Clone, Copy)]
402pub struct StepResult {
403    pub tag: StepResultTag,
404    pub payload: StepResultPayload,
405}
406
407impl StepResult {
408    /// Build a `Pending` with a progress JSON string (may be empty).
409    pub fn pending(progress: StbString) -> Self {
410        Self {
411            tag: StepResultTag::Pending,
412            payload: StepResultPayload {
413                pending: StbPending { progress },
414            },
415        }
416    }
417
418    /// Build a `Done` with the terminal JSON `AgentToolResult`.
419    pub fn done(result: StbString) -> Self {
420        Self {
421            tag: StepResultTag::Done,
422            payload: StepResultPayload {
423                done: StbDone { result },
424            },
425        }
426    }
427
428    /// Build an `Err` with an error message.
429    pub fn err(message: StbString) -> Self {
430        Self {
431            tag: StepResultTag::Err,
432            payload: StepResultPayload {
433                err: StbErr { message },
434            },
435        }
436    }
437
438    /// Access the `pending` payload. Caller MUST guarantee `tag == Pending`.
439    ///
440    /// # Safety
441    /// Undefined behavior if `tag != StepResultTag::Pending`.
442    pub unsafe fn pending_payload(&self) -> &StbPending {
443        unsafe { &self.payload.pending }
444    }
445
446    /// Access the `done` payload. Caller MUST guarantee `tag == Done`.
447    ///
448    /// # Safety
449    /// Undefined behavior if `tag != StepResultTag::Done`.
450    pub unsafe fn done_payload(&self) -> &StbDone {
451        unsafe { &self.payload.done }
452    }
453
454    /// Access the `err` payload. Caller MUST guarantee `tag == Err`.
455    ///
456    /// # Safety
457    /// Undefined behavior if `tag != StepResultTag::Err`.
458    pub unsafe fn err_payload(&self) -> &StbErr {
459        unsafe { &self.payload.err }
460    }
461}
462
463// ---------------------------------------------------------------------------
464// The 4-function tool lifecycle fn-pointer types
465// ---------------------------------------------------------------------------
466
467/// Partial-result callback the **blocking driver** passes to `poll`, wrapped in
468/// `catch_unwind` on the host side. The plugin invokes it **synchronously
469/// inside `poll()`** when it has a `Pending` partial — never retained, never
470/// invoked after `Done`/`Err`.
471///
472/// `partial` is a JSON `AgentToolResult`; ownership passes to the callback (the
473/// host frees it via the host's `free_string`).
474pub type ToolPartialCb = extern "C" fn(partial: StbString, user_data: *mut c_void);
475
476/// `execute(tool_call_id, params) -> StepHandle`. Plugin-allocates a drive
477/// handle and begins the work (non-blocking — the real progress comes via
478/// `poll`). `tool_call_id` is a borrowed [`StbStringRef`] (valid for the call);
479/// `params` is an owning JSON string of the tool-call arguments (the plugin
480/// frees it via the host's `free_string`). Returns null on allocation failure.
481pub type ToolExecuteFn = extern "C" fn(
482    tool_call_id: StbStringRef,
483    params: StbString,
484    free_params: Option<FreeStringFn>,
485) -> StepHandle;
486
487/// `poll(handle, partial_cb, user_data) -> StepResult`. **Non-blocking.** Must
488/// observe the cancel flag (set by [`ToolCancelFn`]) and return `Done`/`Err`
489/// within a bounded number of polls. Borrows `handle` (does not free it).
490pub type ToolPollFn = extern "C" fn(
491    handle: StepHandle,
492    partial_cb: Option<ToolPartialCb>,
493    user_data: *mut c_void,
494) -> StepResult;
495
496/// `cancel(handle)`. Sets an internal `AtomicBool` (SeqCst) cancel flag.
497/// **Idempotent, thread-safe, does NOT free.** The poll loop observes it.
498pub type ToolCancelFn = extern "C" fn(handle: StepHandle);
499
500/// `destroy(handle)`. Frees the handle. **Idempotent; called exactly once by the
501/// blocking driver on exit** (after the loop sees `Done`/`Err`, or after cancel
502/// propagated). Null handle is a no-op.
503pub type ToolDestroyFn = extern "C" fn(handle: StepHandle);
504
505// ---------------------------------------------------------------------------
506// StablePluginEvent — 33 on() categories (explicit tag + union)
507// ---------------------------------------------------------------------------
508
509/// Discriminant for [`StablePluginEvent`], one variant per pi `on()` category
510/// (33 total) plus one rpi-specific category (34 total). `#[repr(u32)]` pins
511/// the discriminant width.
512///
513/// The 33 Pi categories (verified against `extensions/types.ts:1203-1244`):
514/// project_trust, resources_discover, session_start, session_info_changed,
515/// session_before_switch, session_before_fork, session_before_compact,
516/// session_compact, session_shutdown, session_before_tree, session_tree,
517/// context, before_provider_request, before_provider_headers,
518/// after_provider_response, before_agent_start, agent_start, agent_end,
519/// agent_settled, turn_start, turn_end, message_start, message_update,
520/// message_end, tool_execution_start, tool_execution_update,
521/// tool_execution_end, model_select, thinking_level_select, tool_call,
522/// tool_result, user_bash, input.
523///
524/// `BeforeTuiStart` is **rpi-specific** (not a Pi category): dispatched once
525/// before the interactive TUI is initialized, so extensions can prepare
526/// (connect a server, load a resource) before the UI starts. Appended last so
527/// existing discriminants keep their values (ABI-safe for already-compiled
528/// plugins).
529#[repr(u32)]
530#[derive(Debug, Clone, Copy, PartialEq, Eq)]
531pub enum EventTag {
532    ProjectTrust = 0,
533    ResourcesDiscover = 1,
534    SessionStart = 2,
535    SessionInfoChanged = 3,
536    SessionBeforeSwitch = 4,
537    SessionBeforeFork = 5,
538    SessionBeforeCompact = 6,
539    SessionCompact = 7,
540    SessionShutdown = 8,
541    SessionBeforeTree = 9,
542    SessionTree = 10,
543    Context = 11,
544    BeforeProviderRequest = 12,
545    BeforeProviderHeaders = 13,
546    AfterProviderResponse = 14,
547    BeforeAgentStart = 15,
548    AgentStart = 16,
549    AgentEnd = 17,
550    AgentSettled = 18,
551    TurnStart = 19,
552    TurnEnd = 20,
553    MessageStart = 21,
554    MessageUpdate = 22,
555    MessageEnd = 23,
556    ToolExecutionStart = 24,
557    ToolExecutionUpdate = 25,
558    ToolExecutionEnd = 26,
559    ModelSelect = 27,
560    ThinkingLevelSelect = 28,
561    ToolCall = 29,
562    ToolResult = 30,
563    UserBash = 31,
564    Input = 32,
565    /// rpi-specific: dispatched before the interactive TUI initializes (not a
566    /// Pi `on()` category). Appended last so existing discriminants are stable.
567    BeforeTuiStart = 33,
568    /// Pi `on()` category: dispatched when the TUI shows an interactive prompt
569    /// (selector, dialog, etc.) that blocks agent work.
570    UiPromptStart = 34,
571    /// Pi `on()` category: dispatched when the TUI prompt is dismissed.
572    UiPromptEnd = 35,
573}
574
575/// Number of event categories — `36` (33 Pi `on()` categories + 1 rpi-specific
576/// [`EventTag::BeforeTuiStart`] + 2 UI prompt events). A test asserts
577/// `EVENT_TAG_COUNT == 36` so a future edit that adds/removes a tag is caught.
578pub const EVENT_TAG_COUNT: usize = 36;
579
580/// No-payload marker for events that carry none (e.g. `session_shutdown`).
581/// Carries a dummy byte so the empty-struct isn't flagged FFI-unsafe by
582/// `improper_ctypes` (zero-sized C structs are rejected regardless of `repr(C)`).
583#[repr(C)]
584#[derive(Clone, Copy)]
585pub struct EventEmpty {
586    _opaque: u8,
587}
588
589impl EventEmpty {
590    /// The one no-payload instance.
591    pub const INSTANCE: EventEmpty = EventEmpty { _opaque: 0 };
592}
593
594impl Default for EventEmpty {
595    fn default() -> Self {
596        Self::INSTANCE
597    }
598}
599
600/// A serialized message payload (`message_start`/`update`/`end`, tool-result
601/// messages). `message` is a JSON `AgentMessage`.
602#[repr(C)]
603#[derive(Clone, Copy)]
604pub struct EventMessage {
605    pub message: StbString,
606}
607
608/// A tool-call payload (`tool_call`, `tool_execution_start`/`update`).
609/// `tool_call_id` + `tool_name` are raw strings; `params` is the JSON args.
610#[repr(C)]
611#[derive(Clone, Copy)]
612pub struct EventToolCall {
613    pub tool_call_id: StbString,
614    pub tool_name: StbString,
615    pub params: StbString,
616}
617
618/// A tool-result payload (`tool_result`, `tool_execution_end`).
619#[repr(C)]
620#[derive(Clone, Copy)]
621pub struct EventToolResult {
622    pub tool_call_id: StbString,
623    pub tool_name: StbString,
624    pub result: StbString,
625    pub is_error: u8,
626}
627
628/// An error/failure payload.
629#[repr(C)]
630#[derive(Clone, Copy)]
631pub struct EventError {
632    pub message: StbString,
633}
634
635/// A generic JSON-data payload for the long-tail events whose structured shape
636/// the host serializes wholesale (`context`, `before_provider_request`, model
637/// select, resources_discover response, etc.). The plugin reads the fields it
638/// needs.
639#[repr(C)]
640#[derive(Clone, Copy)]
641pub struct EventData {
642    pub data: StbString,
643}
644
645/// Payload union for [`StablePluginEvent`]. All variants are `#[repr(C)]` structs
646/// of [`StbString`] / primitives (Copy), so the union is Copy. Discriminate by
647/// [`StablePluginEvent::tag`] before reading.
648#[repr(C)]
649#[derive(Clone, Copy)]
650pub union EventPayload {
651    pub empty: EventEmpty,
652    pub message: EventMessage,
653    pub tool_call: EventToolCall,
654    pub tool_result: EventToolResult,
655    pub error: EventError,
656    pub data: EventData,
657}
658
659/// One event dispatched to a plugin handler. The host translates its native
660/// `AgentEvent` / `HarnessEvent` into this and calls every registered handler
661/// for the `tag` (dispatch wrapped in `catch_unwind`). Ownership of the
662/// [`StbString`]s passes to the handler; the handler frees them via the host's
663/// `free_string`.
664#[repr(C)]
665#[derive(Clone, Copy)]
666pub struct StablePluginEvent {
667    pub tag: EventTag,
668    pub payload: EventPayload,
669}
670
671impl StablePluginEvent {
672    /// Build a no-payload event.
673    pub fn empty(tag: EventTag) -> Self {
674        Self {
675            tag,
676            payload: EventPayload {
677                empty: EventEmpty::INSTANCE,
678            },
679        }
680    }
681
682    /// Build a message event.
683    pub fn message(tag: EventTag, message: StbString) -> Self {
684        debug_assert!(matches!(
685            tag,
686            EventTag::MessageStart | EventTag::MessageUpdate | EventTag::MessageEnd
687        ));
688        Self {
689            tag,
690            payload: EventPayload {
691                message: EventMessage { message },
692            },
693        }
694    }
695
696    /// Build a tool-call event.
697    pub fn tool_call(
698        tag: EventTag,
699        tool_call_id: StbString,
700        tool_name: StbString,
701        params: StbString,
702    ) -> Self {
703        debug_assert!(matches!(
704            tag,
705            EventTag::ToolCall | EventTag::ToolExecutionStart | EventTag::ToolExecutionUpdate
706        ));
707        Self {
708            tag,
709            payload: EventPayload {
710                tool_call: EventToolCall {
711                    tool_call_id,
712                    tool_name,
713                    params,
714                },
715            },
716        }
717    }
718
719    /// Build a tool-result event.
720    pub fn tool_result(
721        tag: EventTag,
722        tool_call_id: StbString,
723        tool_name: StbString,
724        result: StbString,
725        is_error: bool,
726    ) -> Self {
727        debug_assert!(matches!(
728            tag,
729            EventTag::ToolResult | EventTag::ToolExecutionEnd
730        ));
731        Self {
732            tag,
733            payload: EventPayload {
734                tool_result: EventToolResult {
735                    tool_call_id,
736                    tool_name,
737                    result,
738                    is_error: is_error as u8,
739                },
740            },
741        }
742    }
743
744    /// Build an error event.
745    pub fn error(tag: EventTag, message: StbString) -> Self {
746        Self {
747            tag,
748            payload: EventPayload {
749                error: EventError { message },
750            },
751        }
752    }
753
754    /// Build a generic data event (JSON in `data`).
755    pub fn data(tag: EventTag, data: StbString) -> Self {
756        Self {
757            tag,
758            payload: EventPayload {
759                data: EventData { data },
760            },
761        }
762    }
763}
764
765/// Return codes an [`EventHandlerFn`] may return.
766///
767/// `0` means success; the host continues to the next handler. A **nonzero**
768/// return means the handler handled an error — the host logs it and (for the
769/// ordinary agent/observe fan-out) continues to other handlers (one handler's
770/// error does not abort the fan-out, mirroring pi's per-handler try/catch).
771///
772/// [`EVENT_HANDLER_ABORT`] is the **veto** code: the handler wants to stop the
773/// current lifecycle phase. It is honored only by the host's lifecycle
774/// dispatch (e.g. `before_tui_start` / `session_start` / `session_shutdown`);
775/// the ordinary observe fan-out treats it like any other nonzero (log +
776/// continue), because the agent loop must not be vetoed mid-run.
777pub const EVENT_HANDLER_CONTINUE: i32 = 0;
778/// The handler handled an error but the fan-out should continue (default
779/// nonzero semantics — equivalent to any other nonzero code).
780pub const EVENT_HANDLER_ERROR: i32 = 1;
781/// **Veto**: stop the current lifecycle phase. Honored only by the host's
782/// lifecycle dispatch; the observe fan-out logs + continues (see the module
783/// docs on [`EVENT_HANDLER_CONTINUE`]).
784pub const EVENT_HANDLER_ABORT: i32 = 2;
785
786/// Handler fn pointer registered via `register_event_handler(tag, handler)`.
787/// `user_data` is the plugin's opaque context. Return `0` on success; nonzero
788/// signals a handled error (the host logs it; dispatch continues to other
789/// handlers — one handler's error does not abort the fan-out). The one
790/// exception: when the host dispatches a **lifecycle** event through its
791/// veto-aware path, returning [`EVENT_HANDLER_ABORT`] stops the fan-out and
792/// aborts that phase (see the constants above).
793pub type EventHandlerFn = extern "C" fn(event: StablePluginEvent, user_data: *mut c_void) -> i32;
794
795/// `resources_discover` handler signature (B5b). Unlike [`EventHandlerFn`]
796/// (fire-and-forget, `i32` only), this carries an owning `out` so the plugin
797/// can hand `{skillPaths, promptPaths, themePaths}` back to the host. `cwd` and
798/// `reason` are borrowed inputs ([`StbStringRef`]); `out` is plugin-produced
799/// and reclaimed via the `plugin_free_string` the host stored alongside the
800/// handler at registration. `user_data` is the plugin's opaque context. Returns
801/// `0` on success (host reads `out`); nonzero on a handled error (host logs +
802/// skips this handler, fan-out continues — mirrors pi `runner.ts:1179-1188`).
803pub type ResourcesDiscoverFn = extern "C" fn(
804    cwd: StbStringRef,
805    reason: StbStringRef,
806    out: *mut StbString,
807    user_data: *mut c_void,
808) -> i32;
809
810// ---------------------------------------------------------------------------
811// Runtime actions — uniform JSON-RPC dispatch by RuntimeActionId
812// ---------------------------------------------------------------------------
813
814/// Identifier for a host runtime action the plugin may invoke via
815/// `PluginApiVt::runtime_action`. One slot dispatches all actions; the numeric
816/// id crosses the FFI boundary as a `u32` and the host validates it with
817/// [`TryFrom<u32>`] before constructing this enum. Args/results cross as JSON
818/// strings.
819#[repr(u32)]
820#[derive(Debug, Clone, Copy, PartialEq, Eq)]
821pub enum RuntimeActionId {
822    SendMessage = 0,
823    SendUserMessage = 1,
824    AppendEntry = 2,
825    SetSessionName = 3,
826    GetActiveTools = 4,
827    SetActiveTools = 5,
828    SetModel = 6,
829    GetThinkingLevel = 7,
830    SetThinkingLevel = 8,
831    Compact = 9,
832    GetSystemPrompt = 10,
833    NewSession = 11,
834    Fork = 12,
835    NavigateTree = 13,
836    SwitchSession = 14,
837    Reload = 15,
838    /// Read a parsed CLI flag by name. Args: `{"name":"flag"}`; result:
839    /// `{"value": <bool|string|null>}`.
840    GetCliFlag = 16,
841    /// Open/poll/cancel a host-provided interactive UI request. The payload is
842    /// a JSON object with `op` (`open`, `poll`, or `cancel`) and a unique
843    /// `requestId`; hosts without an attached TUI return an explicit error.
844    UiDialog = 17,
845    /// Publish a short status string for the host's UI (TUI footer).
846    /// Args: `{"key":"langfuse","value":"langfuse ✓ (trace sent)"}`; an
847    /// empty/absent `value` clears that key. Result: `{"ok":true,"changed":bool}`.
848    /// Headless hosts accept the write but have nothing to render.
849    SetStatus = 18,
850}
851
852/// Error returned when a plugin passes a numeric runtime-action id that this
853/// ABI does not define.
854#[derive(Debug, Clone, Copy, PartialEq, Eq)]
855pub struct UnknownRuntimeActionId(pub u32);
856
857impl core::fmt::Display for UnknownRuntimeActionId {
858    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
859        write!(f, "unknown runtime action id {}", self.0)
860    }
861}
862
863impl std::error::Error for UnknownRuntimeActionId {}
864
865impl TryFrom<u32> for RuntimeActionId {
866    type Error = UnknownRuntimeActionId;
867
868    fn try_from(value: u32) -> Result<Self, Self::Error> {
869        match value {
870            0 => Ok(Self::SendMessage),
871            1 => Ok(Self::SendUserMessage),
872            2 => Ok(Self::AppendEntry),
873            3 => Ok(Self::SetSessionName),
874            4 => Ok(Self::GetActiveTools),
875            5 => Ok(Self::SetActiveTools),
876            6 => Ok(Self::SetModel),
877            7 => Ok(Self::GetThinkingLevel),
878            8 => Ok(Self::SetThinkingLevel),
879            9 => Ok(Self::Compact),
880            10 => Ok(Self::GetSystemPrompt),
881            11 => Ok(Self::NewSession),
882            12 => Ok(Self::Fork),
883            13 => Ok(Self::NavigateTree),
884            14 => Ok(Self::SwitchSession),
885            15 => Ok(Self::Reload),
886            16 => Ok(Self::GetCliFlag),
887            17 => Ok(Self::UiDialog),
888            18 => Ok(Self::SetStatus),
889            other => Err(UnknownRuntimeActionId(other)),
890        }
891    }
892}
893
894impl From<RuntimeActionId> for u32 {
895    fn from(value: RuntimeActionId) -> Self {
896        value as u32
897    }
898}
899
900/// Runtime-action signature: `runtime_action(action_id, args_json, out,
901/// user_data) -> i32`. `args_json` is a borrowed input ([`StbStringRef`]); `out`
902/// is an owning output ([`StbString`]) the host produces and the plugin frees
903/// via the host's `free_string`. `action_id` is deliberately a raw `u32`, not a
904/// Rust enum: an unknown value must be rejected as a normal protocol error
905/// rather than materializing an invalid enum discriminant. Returns `0` on
906/// success, nonzero on error.
907pub type RuntimeActionFn = extern "C" fn(
908    action_id: u32,
909    args_json: StbStringRef,
910    out: *mut StbString,
911    user_data: *mut c_void,
912) -> i32;
913
914// ---------------------------------------------------------------------------
915// PluginApiVt — host-provided vtable of fn pointers the plugin calls
916// ---------------------------------------------------------------------------
917
918/// A generic command-handler fn (for `register_command`). `args_json` is a
919/// borrowed `{"args":"...","command":"/..."}` envelope; `out` is owning
920/// JSON output reclaimed with the host `free_string`. The TUI understands
921/// `{kind:"message",text}`, `{kind:"selector",items:[...]}`,
922/// `{kind:"editor",initialText}`, and `{kind:"input",title,placeholder}`
923/// responses; selector/editor/input submissions call the same handler with an
924/// `action` field in `args`.
925pub type CommandHandlerFn =
926    extern "C" fn(args_json: StbStringRef, out: *mut StbString, user_data: *mut c_void) -> i32;
927
928/// A render/transform fn (for the renderer registrars). `input_json` is borrowed;
929/// `out` is owning output the plugin frees via host `free_string`. Markdown
930/// handlers return `{markdown:"..."}`; message/entry handlers return
931/// `{text:"...",markdown?:true}` or `{lines:["..."]}` for terminal UI.
932pub type RenderFn =
933    extern "C" fn(input_json: StbStringRef, out: *mut StbString, user_data: *mut c_void) -> i32;
934
935/// A provider-injection factory fn (for `register_provider`). `req_json` is a
936/// borrowed request envelope; `out` is an owning response the plugin frees.
937/// The host wraps this into a `Provider` impl (B4/B5).
938pub type ProviderRequestFn =
939    extern "C" fn(req_json: StbStringRef, out: *mut StbString, user_data: *mut c_void) -> i32;
940
941/// ABI v1 runtime-action signature. The original SDK exposed a `#[repr(u32)]`
942/// enum at this position; the legacy host view uses the ABI-equivalent raw
943/// integer so it can reject unknown values before constructing an enum.
944pub type LegacyRuntimeActionFn = extern "C" fn(
945    action_id: u32,
946    args_json: StbStringRef,
947    out: *mut StbString,
948    user_data: *mut c_void,
949) -> i32;
950
951/// Frozen host vtable layout used by plugins exporting
952/// [`LEGACY_REGISTER_SYMBOL`]. Do not add, remove, reorder, or retype fields in
953/// this struct. New plugins use [`PluginApiVt`] and [`REGISTER_SYMBOL_V2`].
954///
955/// The v1 action slot accepts only the historical ids `0..=15`. Hosts must
956/// validate the raw `u32` before dispatching it.
957#[repr(C)]
958pub struct LegacyPluginApiV1 {
959    pub free_string: FreeStringFn,
960    pub register_tool: Option<
961        extern "C" fn(
962            schema: *const StableToolSchema,
963            execute_fn: ToolExecuteFn,
964            poll_fn: ToolPollFn,
965            cancel_fn: ToolCancelFn,
966            destroy_fn: ToolDestroyFn,
967            plugin_free_string: FreeStringFn,
968        ) -> i32,
969    >,
970    pub register_command: Option<
971        extern "C" fn(
972            name: StbStringRef,
973            description: StbStringRef,
974            handler: CommandHandlerFn,
975        ) -> i32,
976    >,
977    pub register_shortcut:
978        Option<extern "C" fn(key: StbStringRef, description: StbStringRef) -> i32>,
979    pub register_flag: Option<extern "C" fn(name: StbStringRef, description: StbStringRef) -> i32>,
980    pub register_provider: Option<
981        extern "C" fn(
982            provider_id: StbStringRef,
983            base_url: StbStringRef,
984            api_style: StbStringRef,
985            request_fn: ProviderRequestFn,
986            plugin_free_string: FreeStringFn,
987            user_data: *mut c_void,
988        ) -> i32,
989    >,
990    pub register_message_renderer: Option<
991        extern "C" fn(
992            name: StbStringRef,
993            render_fn: RenderFn,
994            plugin_free_string: FreeStringFn,
995            user_data: *mut c_void,
996        ) -> i32,
997    >,
998    pub register_markdown_transformer: Option<
999        extern "C" fn(
1000            name: StbStringRef,
1001            render_fn: RenderFn,
1002            plugin_free_string: FreeStringFn,
1003            user_data: *mut c_void,
1004        ) -> i32,
1005    >,
1006    pub register_entry_renderer: Option<
1007        extern "C" fn(
1008            name: StbStringRef,
1009            render_fn: RenderFn,
1010            plugin_free_string: FreeStringFn,
1011            user_data: *mut c_void,
1012        ) -> i32,
1013    >,
1014    pub register_event_handler: Option<
1015        extern "C" fn(tag: EventTag, handler: EventHandlerFn, user_data: *mut c_void) -> i32,
1016    >,
1017    pub register_resources_discover: Option<
1018        extern "C" fn(
1019            handler: ResourcesDiscoverFn,
1020            plugin_free_string: FreeStringFn,
1021            user_data: *mut c_void,
1022        ) -> i32,
1023    >,
1024    pub runtime_action: LegacyRuntimeActionFn,
1025    pub dispatch_event:
1026        Option<extern "C" fn(event: StablePluginEvent, user_data: *mut c_void) -> i32>,
1027    pub user_data: *mut c_void,
1028}
1029
1030unsafe impl Send for LegacyPluginApiV1 {}
1031unsafe impl Sync for LegacyPluginApiV1 {}
1032
1033/// The ABI v2 host-provided vtable, passed to `rpi_plugin_register_v2` as a
1034/// `*const`.
1035///
1036/// The plugin reads it during `register` and may copy fn pointers it needs (the
1037/// struct is POD/Copy). **Every slot is nullable**: a null fn pointer means the
1038/// host does not support that capability yet — the plugin MUST null-check
1039/// before calling and degrade gracefully. This keeps the vtable forward-
1040/// compatible across rpi versions without re-ABI bumps within one
1041/// `RPI_PLUGIN_ABI_VERSION`.
1042///
1043/// `user_data` is the host's opaque context, passed back to every host-provided
1044/// fn (so the host can recover its session/harness state). The plugin stores it
1045/// and passes it through unchanged.
1046#[repr(C)]
1047pub struct PluginApiVt {
1048    /// Host's `free_string` — the plugin calls this for every [`StbString`] it
1049    /// *receives* from the host (outputs of actions, event payloads, inputs to
1050    /// execute). Never null.
1051    pub free_string: FreeStringFn,
1052
1053    // --- 8 registrars (plugin → host "register X into the host") ---
1054    /// Register a tool. `schema` + the four lifecycle fns + the plugin's own
1055    /// `free_string` (for the [`StbString`]s in `schema`). Returns `0` on
1056    /// success. Nullable: host not yet wired for tool registration.
1057    pub register_tool: Option<
1058        extern "C" fn(
1059            schema: *const StableToolSchema,
1060            execute_fn: ToolExecuteFn,
1061            poll_fn: ToolPollFn,
1062            cancel_fn: ToolCancelFn,
1063            destroy_fn: ToolDestroyFn,
1064            plugin_free_string: FreeStringFn,
1065        ) -> i32,
1066    >,
1067
1068    /// Register a slash command. Nullable.
1069    pub register_command: Option<
1070        extern "C" fn(
1071            name: StbStringRef,
1072            description: StbStringRef,
1073            handler: CommandHandlerFn,
1074        ) -> i32,
1075    >,
1076
1077    /// Register a keyboard shortcut. Nullable.
1078    pub register_shortcut:
1079        Option<extern "C" fn(key: StbStringRef, description: StbStringRef) -> i32>,
1080
1081    /// Register a CLI flag using its bare name (without leading `--`). The
1082    /// parsed value is read later with [`RuntimeActionId::GetCliFlag`].
1083    /// Nullable.
1084    pub register_flag: Option<extern "C" fn(name: StbStringRef, description: StbStringRef) -> i32>,
1085
1086    /// Register a custom provider. The host stores `provider_id`/`base_url`/
1087    /// `api_style` + the plugin's `request_fn` + the plugin's own
1088    /// `plugin_free_string` (the `out` [`StbString`] `request_fn` *produces* is
1089    /// plugin-owned and the host must reclaim it — same ownership rule as
1090    /// `register_resources_discover`) + the plugin's `user_data` (which
1091    /// `request_fn` receives back unmodified on every call). Nullable (B5c).
1092    pub register_provider: Option<
1093        extern "C" fn(
1094            provider_id: StbStringRef,
1095            base_url: StbStringRef,
1096            api_style: StbStringRef,
1097            request_fn: ProviderRequestFn,
1098            plugin_free_string: FreeStringFn,
1099            user_data: *mut c_void,
1100        ) -> i32,
1101    >,
1102
1103    /// Register a message renderer. `plugin_free_string` reclaims the `out`
1104    /// [`StbString`] `render_fn` produces; `user_data` is passed back to it on
1105    /// every render call. Nullable; interactive TUI consumption accepts the
1106    /// host JSON component envelope (`text`/`lines`).
1107    pub register_message_renderer: Option<
1108        extern "C" fn(
1109            name: StbStringRef,
1110            render_fn: RenderFn,
1111            plugin_free_string: FreeStringFn,
1112            user_data: *mut c_void,
1113        ) -> i32,
1114    >,
1115
1116    /// Register a markdown transformer. Same ownership shape as
1117    /// `register_message_renderer`. Nullable; markdown output is chained in
1118    /// assistant rendering.
1119    pub register_markdown_transformer: Option<
1120        extern "C" fn(
1121            name: StbStringRef,
1122            render_fn: RenderFn,
1123            plugin_free_string: FreeStringFn,
1124            user_data: *mut c_void,
1125        ) -> i32,
1126    >,
1127
1128    /// Register an entry renderer. Same ownership shape as
1129    /// `register_message_renderer`. Nullable; interactive TUI consumption
1130    /// accepts the host JSON component envelope (`text`/`lines`).
1131    pub register_entry_renderer: Option<
1132        extern "C" fn(
1133            name: StbStringRef,
1134            render_fn: RenderFn,
1135            plugin_free_string: FreeStringFn,
1136            user_data: *mut c_void,
1137        ) -> i32,
1138    >,
1139
1140    // --- on() event handler registration (the 33-category subscription) ---
1141    /// Subscribe a handler to one event `tag`. Nullable: host not yet wiring
1142    /// events. The host dispatches [`StablePluginEvent`]s of that tag to the
1143    /// handler (`catch_unwind`-wrapped).
1144    pub register_event_handler: Option<
1145        extern "C" fn(tag: EventTag, handler: EventHandlerFn, user_data: *mut c_void) -> i32,
1146    >,
1147
1148    /// Register a `resources_discover` handler (B5b). The host stores `handler`
1149    /// + the plugin's own `plugin_free_string` (the `out` [`StbString`] the
1150    /// handler produces is plugin-owned and the host must reclaim it) +
1151    /// `user_data`. On discovery (`startup`/`reload`) the host fans the event to
1152    /// every registered handler in order, concatenating their returned
1153    /// `{skillPaths, promptPaths, themePaths}` (errors per-handler do NOT abort
1154    /// the fan-out). Nullable: a host without the resources-discover path leaves
1155    /// this null and the plugin must degrade (no dynamic resource contribution).
1156    pub register_resources_discover: Option<
1157        extern "C" fn(
1158            handler: ResourcesDiscoverFn,
1159            plugin_free_string: FreeStringFn,
1160            user_data: *mut c_void,
1161        ) -> i32,
1162    >,
1163
1164    // --- runtime actions (17, uniform dispatch) ---
1165    /// Invoke a host runtime action. See [`RuntimeActionId`] / [`RuntimeActionFn`].
1166    /// Always present; a host without an action bridge installs a nonzero-returning
1167    /// stub so plugins can degrade gracefully.
1168    pub runtime_action: RuntimeActionFn,
1169
1170    // --- event dispatch (plugin → host "emit an event upstream") ---
1171    /// Emit an event upstream (e.g. a tool announcing a custom UI event). The
1172    /// host forwards to interested subscribers. Ownership of the event's
1173    /// [`StbString`]s passes to the host (freed via `free_string`). Nullable.
1174    pub dispatch_event:
1175        Option<extern "C" fn(event: StablePluginEvent, user_data: *mut c_void) -> i32>,
1176
1177    /// The host's opaque context, passed through to every host-provided fn.
1178    /// The plugin stores this and hands it back unmodified on each call.
1179    pub user_data: *mut c_void,
1180}
1181
1182// SAFETY: the vtable is a POD struct of fn pointers + one raw `user_data`
1183// pointer. It is `Send`+`Sync` so the host can hand it to the plugin's register
1184// thread and the plugin can call its fns from the blocking driver thread; the
1185// host guarantees the `user_data` is valid across those calls.
1186unsafe impl Send for PluginApiVt {}
1187unsafe impl Sync for PluginApiVt {}
1188
1189// ---------------------------------------------------------------------------
1190// Register contract
1191// ---------------------------------------------------------------------------
1192
1193/// The ABI version this SDK publishes. ABI v2 adds the `GetCliFlag` runtime
1194/// action and makes [`RuntimeActionFn`]'s action parameter an explicitly
1195/// validated `u32`.
1196///
1197/// A host and plugin built for different ABI versions must never use each
1198/// other's [`PluginApiVt`]. [`register_entrypoint`] compares the scalar version
1199/// before dereferencing `api`, so an ABI v1 plugin presented to a v2 host (or a
1200/// v2 plugin presented to a v1 host) returns nonzero and is safely skipped
1201/// rather than reading a differently defined vtable.
1202pub const RPI_PLUGIN_ABI_VERSION: u32 = 2;
1203
1204/// ABI version passed to the legacy `rpi_plugin_register` entrypoint.
1205pub const LEGACY_PLUGIN_ABI_VERSION: u32 = 1;
1206
1207/// The ABI v2 symbol the host looks up first in each cdylib.
1208/// Must be an `extern "C" fn(*const PluginApiVt, u32) -> i32`.
1209pub const REGISTER_SYMBOL_V2: &[u8] = b"rpi_plugin_register_v2\0";
1210
1211/// Alias for the current SDK's register symbol.
1212pub const REGISTER_SYMBOL: &[u8] = REGISTER_SYMBOL_V2;
1213
1214/// The ABI v1 symbol used only when [`REGISTER_SYMBOL_V2`] is absent.
1215pub const LEGACY_REGISTER_SYMBOL: &[u8] = b"rpi_plugin_register\0";
1216
1217/// Plugin entrypoint signature. The host loads the cdylib, looks up
1218/// `rpi_plugin_register_v2`, and calls it with the host `PluginApiVt` and the
1219/// host's current `RPI_PLUGIN_ABI_VERSION`.
1220///
1221/// Return `0` on successful registration; nonzero is a plugin-defined error
1222/// code (the host logs it and skips the plugin). The plugin must compare
1223/// `abi_version` before reading `api`; [`register_entrypoint`] implements this
1224/// ordering and safely rejects old/new ABI mixtures. A plugin should copy any
1225/// needed function pointers only inside the callback this helper invokes.
1226pub type RpiPluginRegister = extern "C" fn(api: *const PluginApiVt, abi_version: u32) -> i32;
1227
1228/// ABI v1 plugin entrypoint signature.
1229pub type LegacyRpiPluginRegister =
1230    extern "C" fn(api: *const LegacyPluginApiV1, abi_version: u32) -> i32;
1231
1232// ---------------------------------------------------------------------------
1233// ABI v3 — plugin declarations (extension block beside the frozen v2 vtable)
1234// ---------------------------------------------------------------------------
1235
1236/// The ABI version the v3 entrypoint advertises.
1237pub const RPI_PLUGIN_ABI_VERSION_V3: u32 = 3;
1238
1239/// The ABI v3 symbol the host looks up before [`REGISTER_SYMBOL_V2`].
1240pub const REGISTER_SYMBOL_V3: &[u8] = b"rpi_plugin_register_v3\0";
1241
1242/// The ABI v3 **extension block**, passed to `rpi_plugin_register_v3` *in
1243/// addition to* the frozen v2 [`PluginApiVt`]. Keeping v3 as a separate side
1244/// struct means the v2 vtable layout never changes: a v2 plugin is unaffected,
1245/// a v3 plugin reads both.
1246///
1247/// Every slot is nullable. A null slot means the host does not support that
1248/// declaration yet — the plugin must null-check and degrade (skip the
1249/// declaration; the host applies defaults: priority `100`, all platforms).
1250#[repr(C)]
1251pub struct PluginApiVt3Ext {
1252    /// Declare this plugin's host-side preferences as a JSON object, e.g.
1253    /// `{"priority":60,"platforms":["linux","macos"]}`.
1254    ///
1255    /// - `priority` (integer, default `100`): dispatch order for this plugin's
1256    ///   handlers — **smaller runs first** (stable; registration order breaks
1257    ///   ties). Suggested bands: `0..=49` infrastructure, `50..=99`
1258    ///   connection, `100..=199` normal, `200+` post/cleanup.
1259    /// - `platforms` (array of strings, default all): platforms this plugin
1260    ///   supports; the host skips the plugin's handlers on any other platform.
1261    ///   Recognised: `"linux"`, `"macos"`, `"windows"`, `"android"`.
1262    ///
1263    /// Called during `register`. Returns `0` on success, nonzero on a handled
1264    /// error (host logs it and keeps the defaults).
1265    pub declare: Option<extern "C" fn(json: StbStringRef) -> i32>,
1266    /// Reserved for future v3 declarations. MUST be null-filled today; the host
1267    /// ignores these slots. Zeroing them lets a future host start using them
1268    /// without another ABI bump.
1269    pub _reserved: [*mut c_void; 3],
1270}
1271unsafe impl Send for PluginApiVt3Ext {}
1272unsafe impl Sync for PluginApiVt3Ext {}
1273
1274impl PluginApiVt3Ext {
1275    /// A v3 ext block with every slot null (no declarations). Useful for tests
1276    /// and hosts that support no declarations yet.
1277    pub const fn empty() -> Self {
1278        Self {
1279            declare: None,
1280            _reserved: [ptr::null_mut(); 3],
1281        }
1282    }
1283}
1284
1285/// ABI v3 plugin entrypoint signature: `(api, ext, abi_version)`.
1286pub type RpiPluginRegisterV3 =
1287    extern "C" fn(api: *const PluginApiVt, ext: *const PluginApiVt3Ext, abi_version: u32) -> i32;
1288
1289// ---------------------------------------------------------------------------
1290// Unified ABI — single unversioned symbol, version in struct
1291// ---------------------------------------------------------------------------
1292
1293/// The unified ABI version. This is the *only* ABI going forward; the version
1294/// lives inside the struct (first field) so future breaks can be detected by
1295/// reading the pointer, independent of entrypoint arity.
1296pub const RPI_PLUGIN_ABI_VERSION_UNIFIED: u32 = 4;
1297
1298/// The unversioned symbol for the unified ABI. Old v1 plugins also exported
1299/// `rpi_plugin_register` with a different signature; repurposing the name
1300/// means leftover v1 `.dll`s will be mis-called (accepted risk).
1301pub const REGISTER_SYMBOL_UNIFIED: &[u8] = b"rpi_plugin_register\0";
1302
1303/// The unified host-provided API struct. The first two fields (`abi_version`
1304/// and `struct_size`) are contract-identity markers that survive future
1305/// signature changes: a plugin reads them from the pointer before anything
1306/// else, so even if the entrypoint arity changes, the version check remains
1307/// robust.
1308///
1309/// This struct folds in v3's `declare` slot (previously in `PluginApiVt3Ext`),
1310/// so there's no side block — one struct, one entrypoint, forever.
1311#[repr(C)]
1312pub struct PluginApi {
1313    /// Contract identity. First field so a plugin can validate before reading
1314    /// anything else, independent of the entrypoint arity.
1315    pub abi_version: u32,
1316    /// `sizeof(PluginApi)` as the host built it. Lets a plugin detect a host
1317    /// that predates slots it wants (host_struct_size < plugin's expected
1318    /// offset). Future hosts may grow this struct; old plugins check
1319    /// `struct_size` before accessing new fields.
1320    pub struct_size: u32,
1321
1322    // --- All fields from PluginApiVt (same order, same types) ---
1323    /// Host's `free_string` — the plugin calls this for every [`StbString`] it
1324    /// *receives* from the host (outputs of actions, event payloads, inputs to
1325    /// execute). Never null.
1326    pub free_string: FreeStringFn,
1327
1328    // --- 8 registrars (plugin → host "register X into the host") ---
1329    pub register_tool: Option<
1330        extern "C" fn(
1331            schema: *const StableToolSchema,
1332            execute_fn: ToolExecuteFn,
1333            poll_fn: ToolPollFn,
1334            cancel_fn: ToolCancelFn,
1335            destroy_fn: ToolDestroyFn,
1336            plugin_free_string: FreeStringFn,
1337        ) -> i32,
1338    >,
1339    pub register_command: Option<
1340        extern "C" fn(
1341            name: StbStringRef,
1342            description: StbStringRef,
1343            handler: CommandHandlerFn,
1344        ) -> i32,
1345    >,
1346    pub register_shortcut:
1347        Option<extern "C" fn(key: StbStringRef, description: StbStringRef) -> i32>,
1348    pub register_flag: Option<extern "C" fn(name: StbStringRef, description: StbStringRef) -> i32>,
1349    pub register_provider: Option<
1350        extern "C" fn(
1351            provider_id: StbStringRef,
1352            base_url: StbStringRef,
1353            api_style: StbStringRef,
1354            request_fn: ProviderRequestFn,
1355            plugin_free_string: FreeStringFn,
1356            user_data: *mut c_void,
1357        ) -> i32,
1358    >,
1359    pub register_message_renderer: Option<
1360        extern "C" fn(
1361            name: StbStringRef,
1362            render_fn: RenderFn,
1363            plugin_free_string: FreeStringFn,
1364            user_data: *mut c_void,
1365        ) -> i32,
1366    >,
1367    pub register_markdown_transformer: Option<
1368        extern "C" fn(
1369            name: StbStringRef,
1370            render_fn: RenderFn,
1371            plugin_free_string: FreeStringFn,
1372            user_data: *mut c_void,
1373        ) -> i32,
1374    >,
1375    pub register_entry_renderer: Option<
1376        extern "C" fn(
1377            name: StbStringRef,
1378            render_fn: RenderFn,
1379            plugin_free_string: FreeStringFn,
1380            user_data: *mut c_void,
1381        ) -> i32,
1382    >,
1383    pub register_event_handler: Option<
1384        extern "C" fn(tag: EventTag, handler: EventHandlerFn, user_data: *mut c_void) -> i32,
1385    >,
1386    pub register_resources_discover: Option<
1387        extern "C" fn(
1388            handler: ResourcesDiscoverFn,
1389            plugin_free_string: FreeStringFn,
1390            user_data: *mut c_void,
1391        ) -> i32,
1392    >,
1393    pub runtime_action: RuntimeActionFn,
1394    pub dispatch_event:
1395        Option<extern "C" fn(event: StablePluginEvent, user_data: *mut c_void) -> i32>,
1396    pub user_data: *mut c_void,
1397
1398    // --- v3's declare slot, folded in ---
1399    /// Declare this plugin's host-side preferences as a JSON object, e.g.
1400    /// `{"priority":60,"platforms":["linux","macos"]}`. Nullable: host does
1401    /// not support declarations yet (plugin skips the call; host applies
1402    /// defaults: priority `100`, all platforms).
1403    pub declare: Option<extern "C" fn(json: StbStringRef) -> i32>,
1404}
1405unsafe impl Send for PluginApi {}
1406unsafe impl Sync for PluginApi {}
1407
1408/// Unified ABI plugin entrypoint signature: `(api)`. The version is read from
1409/// `api->abi_version`; no positional arg.
1410pub type RpiPluginRegisterUnified = extern "C" fn(api: *const PluginApi) -> i32;
1411
1412// ===========================================================================
1413// Panic containment
1414// ===========================================================================
1415
1416/// Status code a plugin's register entrypoint returns when its body panicked.
1417///
1418/// A panic must never unwind past the plugin's `extern "C"` entrypoint: Rust
1419/// turns such an unwind into a **process abort** (`panic in a function that
1420/// cannot unwind`), which would take down the whole host. The SDK catches the
1421/// panic and returns this code instead, so the host can skip the plugin with a
1422/// diagnostic.
1423pub const REGISTER_PANIC_STATUS: i32 = 70;
1424
1425/// Run `body`, converting a panic into [`Err`] instead of letting it unwind.
1426///
1427/// Plugin authors must wrap the body of **every** `extern "C"` entrypoint they
1428/// export (tool `execute`/`poll`/`cancel`, event handlers, provider/render
1429/// callbacks, runtime actions) with this (or an equivalent `catch_unwind`).
1430/// Unwinding out of an `extern "C"` function aborts the process.
1431pub fn guard<R>(body: impl FnOnce() -> R) -> Result<R, Box<dyn std::any::Any + Send>> {
1432    std::panic::catch_unwind(std::panic::AssertUnwindSafe(body))
1433}
1434
1435/// Like [`guard`], but returns `fallback` on panic. Handy for entrypoints that
1436/// must return a value rather than a `Result`.
1437pub fn guard_or<R>(fallback: R, body: impl FnOnce() -> R) -> R {
1438    guard(body).unwrap_or(fallback)
1439}
1440
1441/// Export an ABI v3 plugin entrypoint under `rpi_plugin_register_v3`.
1442///
1443/// The expression receives `(&PluginApiVt, &PluginApiVt3Ext)` and returns the
1444/// plugin-defined registration status code. Use this when the plugin wants to
1445/// declare a priority / supported platforms through [`PluginApiVt3Ext::declare`];
1446/// otherwise prefer `export_plugin_v2!`.
1447///
1448/// ```ignore
1449/// rpi_plugin_sdk::export_plugin_v3!(|api, ext| {
1450///     if let Some(declare) = ext.declare {
1451///         declare(rpi_plugin_sdk::StbStringRef::from_str(
1452///             r#"{"priority":60,"platforms":["linux"]}"#,
1453///         ));
1454///     }
1455///     // ... register tools and handlers through `api` ...
1456///     0
1457/// });
1458/// ```
1459#[macro_export]
1460macro_rules! export_plugin_v3 {
1461    ($body:expr) => {
1462        #[no_mangle]
1463        pub extern "C" fn rpi_plugin_register_v3(
1464            api: *const $crate::PluginApiVt,
1465            ext: *const $crate::PluginApiVt3Ext,
1466            abi_version: u32,
1467        ) -> i32 {
1468            // SAFETY: host guarantees `api`/`ext` are valid for this call.
1469            unsafe { $crate::register_entrypoint_v3(api, ext, abi_version, $body) }
1470        }
1471    };
1472}
1473
1474/// v3 entrypoint helper: compares `abi_version` against
1475/// [`RPI_PLUGIN_ABI_VERSION_V3`] before dereferencing `api`/`ext`.
1476///
1477/// # Safety
1478///
1479/// `api` and `ext` must be valid, properly aligned pointers that remain valid
1480/// for the duration of `body`.
1481pub unsafe fn register_entrypoint_v3(
1482    api: *const PluginApiVt,
1483    ext: *const PluginApiVt3Ext,
1484    abi_version: u32,
1485    body: impl FnOnce(&PluginApiVt, &PluginApiVt3Ext) -> i32,
1486) -> i32 {
1487    if abi_version != RPI_PLUGIN_ABI_VERSION_V3 {
1488        return 1;
1489    }
1490    if api.is_null() {
1491        return 2;
1492    }
1493    if ext.is_null() {
1494        return 3;
1495    }
1496    // SAFETY: caller guarantees both pointers are valid; we checked null.
1497    let api = &*api;
1498    let ext = &*ext;
1499    // Contain panics: unwinding out of the plugin's `extern "C"` shim aborts
1500    // the process, so convert a panic into a status code the host can report.
1501    match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| body(api, ext))) {
1502        Ok(code) => code,
1503        Err(_) => REGISTER_PANIC_STATUS,
1504    }
1505}
1506
1507/// Export a unified ABI plugin entrypoint under `rpi_plugin_register`.
1508///
1509/// The unified ABI has no version suffix in the symbol name; the version lives
1510/// inside the [`PluginApi`] struct (first field `abi_version`). This is the
1511/// *only* ABI going forward; old versioned symbols (`rpi_plugin_register_v2`,
1512/// `_v3`) are kept temporarily for migration but are deprecated.
1513///
1514/// The expression receives `&PluginApi` and returns the plugin-defined
1515/// registration status code.
1516///
1517/// ```ignore
1518/// rpi_plugin_sdk::export_plugin!(|api| {
1519///     if let Some(declare) = api.declare {
1520///         declare(rpi_plugin_sdk::StbStringRef::from_str(
1521///             r#"{"priority":60,"platforms":["linux"]}"#,
1522///         ));
1523///     }
1524///     // ... register tools and handlers through `api` ...
1525///     0
1526/// });
1527/// ```
1528#[macro_export]
1529macro_rules! export_plugin {
1530    ($body:expr) => {
1531        #[no_mangle]
1532        pub extern "C" fn rpi_plugin_register(api: *const $crate::PluginApi) -> i32 {
1533            // SAFETY: host guarantees `api` is valid for this call.
1534            unsafe { $crate::register_entrypoint_unified(api, $body) }
1535        }
1536    };
1537}
1538
1539/// Unified ABI entrypoint helper: reads `abi_version` and `struct_size` from
1540/// the struct pointer before dereferencing anything else. This survives future
1541/// signature changes because the pointer is always the first argument.
1542///
1543/// # Safety
1544///
1545/// `api` must be a valid, properly aligned pointer to a [`PluginApi`] that
1546/// remains valid for the duration of `body`.
1547pub unsafe fn register_entrypoint_unified(
1548    api: *const PluginApi,
1549    body: impl FnOnce(&PluginApi) -> i32,
1550) -> i32 {
1551    if api.is_null() {
1552        return 2;
1553    }
1554    // SAFETY: caller guarantees `api` is valid; we check null above.
1555    // Read the version and size fields first — they're at known offsets
1556    // regardless of future struct growth.
1557    let abi_version = (*api).abi_version;
1558    let struct_size = (*api).struct_size;
1559    if abi_version != RPI_PLUGIN_ABI_VERSION_UNIFIED {
1560        // Mismatch: refuse to register. The host logs "ABI version mismatch"
1561        // and skips loading this plugin.
1562        return 1;
1563    }
1564    if (struct_size as usize) < core::mem::size_of::<PluginApi>() {
1565        // Host predates a field the plugin expects; degrade gracefully.
1566        return 3;
1567    }
1568    let api = &*api;
1569    // Contain panics: unwinding out of the plugin's `extern "C"` shim aborts
1570    // the process, so convert a panic into a status code the host can report.
1571    match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| body(api))) {
1572        Ok(code) => code,
1573        Err(_) => REGISTER_PANIC_STATUS,
1574    }
1575}
1576
1577/// Export an ABI v2 plugin entrypoint under `rpi_plugin_register_v2`.
1578///
1579/// The expression receives `&PluginApiVt` and returns the plugin-defined
1580/// registration status code.
1581///
1582/// ```ignore
1583/// rpi_plugin_sdk::export_plugin_v2!(|api| {
1584///     // register tools and handlers through `api`
1585///     0
1586/// });
1587/// ```
1588#[macro_export]
1589macro_rules! export_plugin_v2 {
1590    ($body:expr) => {
1591        #[no_mangle]
1592        pub extern "C" fn rpi_plugin_register_v2(
1593            api: *const $crate::PluginApiVt,
1594            abi_version: u32,
1595        ) -> i32 {
1596            // SAFETY: host guarantees `api` is valid for this call.
1597            unsafe { $crate::register_entrypoint(api, abi_version, $body) }
1598        }
1599    };
1600}
1601
1602/// Convenience for host + plugin: declare the register entrypoint.
1603///
1604/// A plugin crate writes:
1605/// ```ignore
1606/// #[no_mangle]
1607/// pub extern "C" fn rpi_plugin_register_v2(api: *const PluginApiVt, abi_version: u32) -> i32 {
1608///     rpi_plugin_sdk::register_entrypoint(api, abi_version, |api| {
1609///         // ... register tools / handlers using `api` ...
1610///         0
1611///     })
1612/// }
1613/// ```
1614/// The helper performs the version check (return nonzero on mismatch) and
1615/// null-checks `api` before invoking the plugin body.
1616/// # Safety
1617///
1618/// `api` must be a valid, properly aligned pointer to a `PluginApiVt` that
1619/// remains valid for the duration of the `body` call. The host is responsible
1620/// for ensuring this invariant.
1621pub unsafe fn register_entrypoint(
1622    api: *const PluginApiVt,
1623    abi_version: u32,
1624    body: impl FnOnce(&PluginApiVt) -> i32,
1625) -> i32 {
1626    if abi_version != RPI_PLUGIN_ABI_VERSION {
1627        // Mismatch: refuse to register. The host logs "ABI version mismatch"
1628        // and skips loading this plugin.
1629        return 1;
1630    }
1631    if api.is_null() {
1632        return 2;
1633    }
1634    // SAFETY: caller guarantees `api` is valid; we additionally check for null.
1635    let api = &*api;
1636    // Contain panics (see `REGISTER_PANIC_STATUS`): a panic in the plugin body
1637    // must not unwind out of the `extern "C"` entrypoint.
1638    match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| body(api))) {
1639        Ok(code) => code,
1640        Err(_) => REGISTER_PANIC_STATUS,
1641    }
1642}
1643
1644// ===========================================================================
1645// Tests (need std + serde_json)
1646// ===========================================================================
1647
1648#[cfg(test)]
1649mod tests {
1650    use super::*;
1651
1652    /// A panicking `register` body must NOT unwind out of the entrypoint shim;
1653    /// it must come back as `REGISTER_PANIC_STATUS` so the host can skip the
1654    /// plugin instead of the process aborting.
1655    #[test]
1656    fn register_entrypoint_contains_panics() {
1657        // The body panics before touching the vtable, so an uninitialized (but
1658        // aligned, non-null) pointer is sufficient — it is only dereferenced to
1659        // build `&PluginApiVt`, never read.
1660        let mut vt = std::mem::MaybeUninit::<PluginApiVt>::uninit();
1661        let vt_ptr = vt.as_mut_ptr();
1662        let rc = unsafe {
1663            register_entrypoint(vt_ptr, RPI_PLUGIN_ABI_VERSION, |_api| {
1664                panic!("plugin register exploded");
1665            })
1666        };
1667        assert_eq!(rc, REGISTER_PANIC_STATUS);
1668    }
1669
1670    #[test]
1671    fn guard_returns_value_and_contains_panic() {
1672        assert_eq!(guard(|| 41 + 1).ok(), Some(42));
1673        assert!(guard(|| -> i32 { panic!("nope") }).is_err());
1674        assert_eq!(guard_or(-1, || -> i32 { panic!("nope") }), -1);
1675    }
1676
1677    // A test allocator + free fn so we can verify the own/free contract
1678    // without a real plugin's free_string.
1679    std::thread_local! {
1680        static FREED: std::cell::Cell<usize> = std::cell::Cell::new(0);
1681    }
1682
1683    extern "C" fn test_free(s: StbString) {
1684        if s.is_empty() || s.ptr.is_null() {
1685            return;
1686        }
1687        // Reconstruct the boxed slice and drop it.
1688        unsafe {
1689            let slice = core::slice::from_raw_parts_mut(s.ptr as *mut u8, s.len);
1690            let _ = Box::from_raw(slice as *mut [u8]);
1691        }
1692        FREED.with(|freed| freed.set(freed.get() + 1));
1693    }
1694
1695    fn reset_freed() -> usize {
1696        FREED.with(|freed| freed.replace(0))
1697    }
1698
1699    fn freed_count() -> usize {
1700        FREED.with(std::cell::Cell::get)
1701    }
1702
1703    // A no-op `runtime_action` impl for the vtable-construction tests (closures
1704    // can't coerce to `extern "C" fn`, so we use a real fn).
1705    extern "C" fn noop_runtime_action(
1706        _action_id: u32,
1707        _args: StbStringRef,
1708        _out: *mut StbString,
1709        _user_data: *mut c_void,
1710    ) -> i32 {
1711        0
1712    }
1713
1714    #[test]
1715    fn stbstring_round_trip_and_free_once() {
1716        let prev = reset_freed();
1717        let _ = prev;
1718        let s = StbString::from_string("hello, pi".to_string());
1719        assert_eq!(s.len, 9);
1720        assert_eq!(s.to_string_lossy(), "hello, pi");
1721        s.free_with(Some(test_free));
1722        assert_eq!(freed_count(), 1);
1723    }
1724
1725    #[test]
1726    fn empty_stbstring_free_is_noop() {
1727        let _ = reset_freed();
1728        StbString::empty().free_with(Some(test_free));
1729        assert_eq!(freed_count(), 0);
1730    }
1731
1732    #[test]
1733    fn json_round_trip_preserves_structure() {
1734        let val = serde_json::json!({ "name": "echo", "args": [1, 2, 3], "ok": true });
1735        let stb = json::to_stable(&val, None);
1736        let back = json::from_stable(&stb);
1737        assert_eq!(val, back);
1738        stb.free_with(Some(test_free));
1739        let _ = reset_freed();
1740    }
1741
1742    #[test]
1743    fn step_result_done_round_trip() {
1744        let result_json = StbString::from_string(r#"{"content":[{"text":"hi"}]}"#.to_string());
1745        let sr = StepResult::done(result_json);
1746        assert_eq!(sr.tag, StepResultTag::Done);
1747        // SAFETY: tag == Done.
1748        let done = unsafe { sr.done_payload() };
1749        assert_eq!(
1750            done.result.to_string_lossy(),
1751            r#"{"content":[{"text":"hi"}]}"#
1752        );
1753        done.result.free_with(Some(test_free));
1754        let _ = reset_freed();
1755    }
1756
1757    #[test]
1758    fn step_result_pending_and_err() {
1759        let prog = StbString::from_string("...".to_string());
1760        let srp = StepResult::pending(prog);
1761        assert_eq!(srp.tag, StepResultTag::Pending);
1762        // SAFETY: tag == Pending.
1763        unsafe {
1764            assert_eq!(srp.pending_payload().progress.to_string_lossy(), "...");
1765        }
1766        unsafe { srp.pending_payload().progress.free_with(Some(test_free)) };
1767
1768        let msg = StbString::from_string("boom".to_string());
1769        let sre = StepResult::err(msg);
1770        assert_eq!(sre.tag, StepResultTag::Err);
1771        // SAFETY: tag == Err.
1772        unsafe {
1773            assert_eq!(sre.err_payload().message.to_string_lossy(), "boom");
1774            sre.err_payload().message.free_with(Some(test_free));
1775        }
1776        let _ = reset_freed();
1777    }
1778
1779    #[test]
1780    fn event_tag_count_is_36() {
1781        // Enumerate every tag; a compile-time + runtime guarantee that the
1782        // 36-category surface is intact (33 Pi on() categories + the
1783        // rpi-specific BeforeTuiStart + 2 UI prompt events).
1784        let tags = [
1785            EventTag::ProjectTrust,
1786            EventTag::ResourcesDiscover,
1787            EventTag::SessionStart,
1788            EventTag::SessionInfoChanged,
1789            EventTag::SessionBeforeSwitch,
1790            EventTag::SessionBeforeFork,
1791            EventTag::SessionBeforeCompact,
1792            EventTag::SessionCompact,
1793            EventTag::SessionShutdown,
1794            EventTag::SessionBeforeTree,
1795            EventTag::SessionTree,
1796            EventTag::Context,
1797            EventTag::BeforeProviderRequest,
1798            EventTag::BeforeProviderHeaders,
1799            EventTag::AfterProviderResponse,
1800            EventTag::BeforeAgentStart,
1801            EventTag::AgentStart,
1802            EventTag::AgentEnd,
1803            EventTag::AgentSettled,
1804            EventTag::TurnStart,
1805            EventTag::TurnEnd,
1806            EventTag::MessageStart,
1807            EventTag::MessageUpdate,
1808            EventTag::MessageEnd,
1809            EventTag::ToolExecutionStart,
1810            EventTag::ToolExecutionUpdate,
1811            EventTag::ToolExecutionEnd,
1812            EventTag::ModelSelect,
1813            EventTag::ThinkingLevelSelect,
1814            EventTag::ToolCall,
1815            EventTag::ToolResult,
1816            EventTag::UserBash,
1817            EventTag::Input,
1818            EventTag::BeforeTuiStart,
1819            EventTag::UiPromptStart,
1820            EventTag::UiPromptEnd,
1821        ];
1822        assert_eq!(tags.len(), EVENT_TAG_COUNT);
1823        assert_eq!(EVENT_TAG_COUNT, 36);
1824        // Distinct discriminants 0..35.
1825        let mut discs: Vec<u32> = tags.iter().map(|t| *t as u32).collect();
1826        discs.sort();
1827        assert_eq!(discs, (0..36).collect::<Vec<u32>>());
1828    }
1829
1830    #[test]
1831    fn event_payloads_construct_and_free() {
1832        let m = StbString::from_string("msg".to_string());
1833        let ev = StablePluginEvent::message(EventTag::MessageEnd, m);
1834        assert_eq!(ev.tag, EventTag::MessageEnd);
1835        // SAFETY: tag == MessageEnd (message variant).
1836        unsafe {
1837            assert_eq!(ev.payload.message.message.to_string_lossy(), "msg");
1838            ev.payload.message.message.free_with(Some(test_free));
1839        }
1840
1841        let tc = StablePluginEvent::tool_call(
1842            EventTag::ToolCall,
1843            StbString::from_string("call_1".to_string()),
1844            StbString::from_string("echo".to_string()),
1845            StbString::from_string("{}".to_string()),
1846        );
1847        // SAFETY: tag == ToolCall (tool_call variant).
1848        unsafe {
1849            assert_eq!(tc.payload.tool_call.tool_name.to_string_lossy(), "echo");
1850            tc.payload.tool_call.tool_call_id.free_with(Some(test_free));
1851            tc.payload.tool_call.tool_name.free_with(Some(test_free));
1852            tc.payload.tool_call.params.free_with(Some(test_free));
1853        }
1854        let _ = reset_freed();
1855    }
1856
1857    #[test]
1858    fn plugin_api_vt_is_pod_and_sized() {
1859        // The vtable must be a plain old data struct: every fn pointer is
1860        // non-Drop, the struct has no Drop impl. We exercise that it can be
1861        // zeroed and read without UB.
1862        let vt = PluginApiVt {
1863            free_string: test_free,
1864            register_tool: None,
1865            register_command: None,
1866            register_shortcut: None,
1867            register_flag: None,
1868            register_provider: None,
1869            register_message_renderer: None,
1870            register_markdown_transformer: None,
1871            register_entry_renderer: None,
1872            register_event_handler: None,
1873            register_resources_discover: None,
1874            runtime_action: noop_runtime_action,
1875            dispatch_event: None,
1876            user_data: core::ptr::null_mut(),
1877        };
1878        // All optional slots are null → plugin must degrade.
1879        assert!(vt.register_tool.is_none());
1880        assert!(vt.register_event_handler.is_none());
1881        assert!(vt.register_resources_discover.is_none());
1882        // Copy (POD) — no UB from a plain copy.
1883        let _copy = vt;
1884        // `assert!(core::mem::needs_drop::<PluginApiVt>() == false)` — verified
1885        // by the absence of a Drop impl + all-Copy fields.
1886        assert!(!core::mem::needs_drop::<PluginApiVt>());
1887        assert!(!core::mem::needs_drop::<StbString>());
1888        assert!(!core::mem::needs_drop::<StepResult>());
1889        assert!(!core::mem::needs_drop::<StablePluginEvent>());
1890        assert!(!core::mem::needs_drop::<StableToolSchema>());
1891    }
1892
1893    #[test]
1894    fn legacy_v1_layout_is_frozen_and_matches_v2_shared_slots() {
1895        use core::mem::{align_of, offset_of, size_of};
1896
1897        let pointer_size = size_of::<*const ()>();
1898        assert_eq!(size_of::<LegacyPluginApiV1>(), 14 * pointer_size);
1899        assert_eq!(align_of::<LegacyPluginApiV1>(), align_of::<*const ()>());
1900        assert_eq!(size_of::<PluginApiVt>(), size_of::<LegacyPluginApiV1>());
1901        assert_eq!(align_of::<PluginApiVt>(), align_of::<LegacyPluginApiV1>());
1902
1903        macro_rules! assert_same_offset {
1904            ($field:ident, $index:expr) => {
1905                assert_eq!(
1906                    offset_of!(LegacyPluginApiV1, $field),
1907                    $index * pointer_size,
1908                    concat!("unexpected ABI v1 offset for ", stringify!($field))
1909                );
1910                assert_eq!(
1911                    offset_of!(PluginApiVt, $field),
1912                    offset_of!(LegacyPluginApiV1, $field),
1913                    concat!("v1/v2 shared field moved: ", stringify!($field))
1914                );
1915            };
1916        }
1917
1918        assert_same_offset!(free_string, 0);
1919        assert_same_offset!(register_tool, 1);
1920        assert_same_offset!(register_command, 2);
1921        assert_same_offset!(register_shortcut, 3);
1922        assert_same_offset!(register_flag, 4);
1923        assert_same_offset!(register_provider, 5);
1924        assert_same_offset!(register_message_renderer, 6);
1925        assert_same_offset!(register_markdown_transformer, 7);
1926        assert_same_offset!(register_entry_renderer, 8);
1927        assert_same_offset!(register_event_handler, 9);
1928        assert_same_offset!(register_resources_discover, 10);
1929        assert_same_offset!(runtime_action, 11);
1930        assert_same_offset!(dispatch_event, 12);
1931        assert_same_offset!(user_data, 13);
1932        assert!(!core::mem::needs_drop::<LegacyPluginApiV1>());
1933    }
1934
1935    #[test]
1936    fn runtime_action_ids_are_explicitly_validated() {
1937        let ids = [
1938            RuntimeActionId::SendMessage,
1939            RuntimeActionId::SendUserMessage,
1940            RuntimeActionId::AppendEntry,
1941            RuntimeActionId::SetSessionName,
1942            RuntimeActionId::GetActiveTools,
1943            RuntimeActionId::SetActiveTools,
1944            RuntimeActionId::SetModel,
1945            RuntimeActionId::GetThinkingLevel,
1946            RuntimeActionId::SetThinkingLevel,
1947            RuntimeActionId::Compact,
1948            RuntimeActionId::GetSystemPrompt,
1949            RuntimeActionId::NewSession,
1950            RuntimeActionId::Fork,
1951            RuntimeActionId::NavigateTree,
1952            RuntimeActionId::SwitchSession,
1953            RuntimeActionId::Reload,
1954            RuntimeActionId::GetCliFlag,
1955            RuntimeActionId::UiDialog,
1956            RuntimeActionId::SetStatus,
1957        ];
1958
1959        for (raw, expected) in ids.into_iter().enumerate() {
1960            assert_eq!(RuntimeActionId::try_from(raw as u32), Ok(expected));
1961            assert_eq!(u32::from(expected), raw as u32);
1962        }
1963        // One past the highest defined id is rejected (nothing is silently
1964        // accepted), and so is the u32 ceiling.
1965        assert_eq!(
1966            RuntimeActionId::try_from(19),
1967            Err(UnknownRuntimeActionId(19))
1968        );
1969        assert_eq!(
1970            RuntimeActionId::try_from(u32::MAX),
1971            Err(UnknownRuntimeActionId(u32::MAX))
1972        );
1973    }
1974
1975    #[test]
1976    fn register_entrypoint_version_mismatch_refuses() {
1977        let vt = PluginApiVt {
1978            free_string: test_free,
1979            register_tool: None,
1980            register_command: None,
1981            register_shortcut: None,
1982            register_flag: None,
1983            register_provider: None,
1984            register_message_renderer: None,
1985            register_markdown_transformer: None,
1986            register_entry_renderer: None,
1987            register_event_handler: None,
1988            register_resources_discover: None,
1989            runtime_action: noop_runtime_action,
1990            dispatch_event: None,
1991            user_data: core::ptr::null_mut(),
1992        };
1993        assert_eq!(RPI_PLUGIN_ABI_VERSION, 2);
1994        assert_eq!(LEGACY_PLUGIN_ABI_VERSION, 1);
1995        assert_eq!(REGISTER_SYMBOL, REGISTER_SYMBOL_V2);
1996        assert_ne!(REGISTER_SYMBOL_V2, LEGACY_REGISTER_SYMBOL);
1997
1998        // An ABI v1 plugin/host mixture is refused before the v2 vtable is read.
1999        // A null pointer makes the ordering observable: checking `api` first
2000        // would return 2, while the required version-first path returns 1.
2001        let rc = unsafe {
2002            register_entrypoint(core::ptr::null(), 1, |_| {
2003                panic!("body must not run on version mismatch");
2004            })
2005        };
2006        assert_eq!(rc, 1);
2007
2008        // A future version is rejected by the same pre-dereference check.
2009        let rc = unsafe {
2010            register_entrypoint(&vt, RPI_PLUGIN_ABI_VERSION + 1, |_| {
2011                panic!("body must not run on version mismatch");
2012            })
2013        };
2014        assert_ne!(rc, 0);
2015
2016        // Right version → body runs, rc propagated.
2017        let rc = unsafe { register_entrypoint(&vt, RPI_PLUGIN_ABI_VERSION, |_| 0) };
2018        assert_eq!(rc, 0);
2019        let rc = unsafe { register_entrypoint(&vt, RPI_PLUGIN_ABI_VERSION, |_| 42) };
2020        assert_eq!(rc, 42);
2021
2022        // Null api → refuse.
2023        let rc = unsafe { register_entrypoint(core::ptr::null(), RPI_PLUGIN_ABI_VERSION, |_| 0) };
2024        assert_ne!(rc, 0);
2025        let _ = reset_freed();
2026    }
2027
2028    #[test]
2029    fn v3_ext_is_pod_and_entrypoint_validates() {
2030        // The v3 ext block is POD (fn pointer + reserved raw pointers, no Drop).
2031        assert!(!core::mem::needs_drop::<PluginApiVt3Ext>());
2032        assert_eq!(RPI_PLUGIN_ABI_VERSION_V3, 3);
2033        assert_ne!(REGISTER_SYMBOL_V3, REGISTER_SYMBOL_V2);
2034        let empty = PluginApiVt3Ext::empty();
2035        assert!(empty.declare.is_none());
2036        assert!(empty._reserved.iter().all(|p| p.is_null()));
2037
2038        let vt = PluginApiVt {
2039            free_string: test_free,
2040            register_tool: None,
2041            register_command: None,
2042            register_shortcut: None,
2043            register_flag: None,
2044            register_provider: None,
2045            register_message_renderer: None,
2046            register_markdown_transformer: None,
2047            register_entry_renderer: None,
2048            register_event_handler: None,
2049            register_resources_discover: None,
2050            runtime_action: noop_runtime_action,
2051            dispatch_event: None,
2052            user_data: core::ptr::null_mut(),
2053        };
2054        let ext = PluginApiVt3Ext::empty();
2055
2056        // Version mismatch → refused before the body runs (null api makes the
2057        // ordering observable: version-first returns 1, not the null-api code).
2058        let rc = unsafe {
2059            register_entrypoint_v3(core::ptr::null(), &ext, 2, |_, _| {
2060                panic!("body must not run on version mismatch");
2061            })
2062        };
2063        assert_eq!(rc, 1);
2064
2065        // Null api / null ext → distinct refusals.
2066        assert_eq!(
2067            unsafe { register_entrypoint_v3(core::ptr::null(), &ext, 3, |_, _| 0) },
2068            2
2069        );
2070        assert_eq!(
2071            unsafe { register_entrypoint_v3(&vt, core::ptr::null(), 3, |_, _| 0) },
2072            3
2073        );
2074
2075        // Right version + non-null → body runs, rc propagated.
2076        let rc = unsafe { register_entrypoint_v3(&vt, &ext, 3, |_, _| 0) };
2077        assert_eq!(rc, 0);
2078        let rc = unsafe { register_entrypoint_v3(&vt, &ext, 3, |_, _| 7) };
2079        assert_eq!(rc, 7);
2080        let _ = reset_freed();
2081    }
2082}