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//!
59//! The optional `provider` feature adds the `provider` module and `export_provider!`
60//! for bridging Rust `rpi_ai::Provider` implementations to this ABI. It alone
61//! enables the AI, async and HTTP dependencies; the default ABI SDK stays light.
62
63#![deny(unsafe_op_in_unsafe_fn)]
64
65use core::ffi::c_char;
66use core::ffi::c_void;
67use core::ptr;
68
69#[cfg(feature = "binary")]
70pub mod binary;
71pub mod callbacks;
72#[cfg(feature = "json")]
73pub mod dialogs;
74#[cfg(feature = "json")]
75pub mod events;
76pub mod handles;
77#[cfg(feature = "process")]
78pub mod process;
79#[cfg(feature = "provider")]
80pub mod provider;
81#[cfg(feature = "stdio")]
82pub mod stdio;
83
84// ---------------------------------------------------------------------------
85// StbString — owned UTF-8 crossing as ptr+len with an explicit free
86// ---------------------------------------------------------------------------
87
88/// An owned UTF-8 string crossing the ABI as a `(ptr, len)` pair.
89///
90/// `Copy` (raw pointers are `Copy`): copying a `StbString` duplicates the
91/// **pointer**, not the allocation. The **receiver** of a `StbString` owns the
92/// allocation and MUST free it **exactly once** by calling the producer's
93/// [`FreeStringFn`] (or [`StbString::free_with`]).
94/// Never `free` a `StbString` you did not receive as an owner (e.g. one built
95/// from a borrow via [`StbString::from_ref`], which is non-owning — its `free`
96/// is a no-op only if the producer guarantees the buffer outlives the call; in
97/// practice inputs cross as [`StbStringRef`] instead).
98///
99/// **Construction ownership contract:**
100/// - [`StbString::from_owned`] — takes a `Box<[u8]>` the caller allocated; the
101///   `StbString` now owns it; `free` deallocates.
102/// - [`StbString::from_boxed_str`] / [`StbString::from_string`] — convenience
103///   over `from_owned` (host side, needs `alloc`).
104/// - [`StbString::empty`] — null/0; `free` is a no-op.
105///
106/// **Null `ptr` ⇒ empty** (`len` MUST be 0). A null pointer is never
107/// dereferenced.
108#[repr(C)]
109#[derive(Clone, Copy)]
110pub struct StbString {
111    /// UTF-8 bytes. Null when `len == 0` (empty string).
112    pub ptr: *mut c_char,
113    /// Byte length (NOT a NUL terminator — the buffer is NOT NUL-terminated).
114    pub len: usize,
115}
116
117// SAFETY: `StbString` is a plain `(ptr, len)` of raw pointers — no ownership
118// transferred across threads by the type itself, and lifetime/aliasing is the
119// caller's contract (documented above). It is `Send`+`Sync` so the host and the
120// blocking driver can pass it across threads; the *allocation* ownership rules
121// above still apply regardless of thread.
122unsafe impl Send for StbString {}
123unsafe impl Sync for StbString {}
124
125impl StbString {
126    /// An empty string: null pointer, zero length. `free` is a no-op.
127    pub const fn empty() -> Self {
128        Self {
129            ptr: ptr::null_mut(),
130            len: 0,
131        }
132    }
133
134    /// Whether this is the empty/null string.
135    pub const fn is_empty(&self) -> bool {
136        self.len == 0
137    }
138}
139
140// Constructors and the raw reader depend only on std, not serde_json. Keep
141// them available when the optional JSON feature is disabled: ABI trampolines
142// still allocate, copy and reclaim strings in that configuration.
143impl StbString {
144    /// Wrap an allocation the caller already boxed. The `StbString` takes
145    /// ownership: a later `free_with(free_fn)` will deallocate it via the
146    /// producer's `free_string`.
147    ///
148    /// The buffer MUST be UTF-8.
149    pub fn from_owned(buf: Box<[u8]>) -> Self {
150        let len = buf.len();
151        // Stabilize the pointer via `Box::into_raw`; the free fn reconstructs a
152        // slice from ptr+len and drops it. Store the element pointer as
153        // `*mut c_char` (u8 ↔ c_char on every platform rpi targets). Ownership
154        // moves into the `StbString` (we do NOT drop here).
155        let ptr = Box::into_raw(buf) as *mut [u8] as *mut u8 as *mut c_char;
156        let _ = len;
157        Self { ptr, len }
158    }
159
160    /// Convenience: from a `String`, transferring ownership. After this the
161    /// passed `String` is consumed and must not be reused.
162    pub fn from_string(s: String) -> Self {
163        Self::from_vec(s.into_bytes())
164    }
165
166    /// Convenience: from a `Vec<u8>` (UTF-8), transferring ownership.
167    pub fn from_vec(v: Vec<u8>) -> Self {
168        Self::from_owned(v.into_boxed_slice())
169    }
170
171    /// Convenience: from a boxed `str`.
172    pub fn from_boxed_str(s: Box<str>) -> Self {
173        let string: String = s.into();
174        Self::from_vec(string.into_bytes())
175    }
176
177    /// Copy this `StbString`'s bytes into an owned `String` (**without** freeing
178    /// the original — the caller still owns the original allocation). Use this
179    /// to *read* a received `StbString` into safe Rust; then `free` the original.
180    /// Invalid UTF-8 is replaced with the Unicode replacement character.
181    ///
182    /// # Safety
183    /// For a non-null pointer and nonzero length, `ptr` must be valid for
184    /// `len` initialized bytes in a single allocation, with `len <= isize::MAX`.
185    /// The bytes must remain live and unchanged during this call. A copied
186    /// `StbString` does not extend the allocation's lifetime.
187    ///
188    /// ```compile_fail
189    /// use rpi_plugin_sdk::StbString;
190    /// let raw = StbString::empty();
191    /// // Reading public ABI pointers requires an explicit validity contract.
192    /// let text = raw.to_string_lossy();
193    /// ```
194    pub unsafe fn to_string_lossy(&self) -> String {
195        if self.len == 0 || self.ptr.is_null() {
196            return String::new();
197        }
198        // SAFETY: the caller guarantees a live, readable, unchanged buffer.
199        // UTF-8 validity is not required by this lossy conversion.
200        let slice = unsafe { core::slice::from_raw_parts(self.ptr as *const u8, self.len) };
201        String::from_utf8_lossy(slice).into_owned()
202    }
203}
204
205/// A **borrowed**, non-owning view of a string passed as an *input* to an FFI
206/// call. The callee MUST NOT free it and MUST NOT retain it past the call.
207///
208/// Built from a `&str` on the calling side; the buffer is valid for the
209/// duration of the call (the caller's borrow).
210#[repr(C)]
211#[derive(Clone, Copy)]
212pub struct StbStringRef {
213    /// UTF-8 bytes (valid for the call; NUL-terminated not required).
214    pub ptr: *const c_char,
215    /// Byte length.
216    pub len: usize,
217}
218
219unsafe impl Send for StbStringRef {}
220unsafe impl Sync for StbStringRef {}
221
222impl StbStringRef {
223    /// Empty input.
224    pub const fn empty() -> Self {
225        Self {
226            ptr: ptr::null(),
227            len: 0,
228        }
229    }
230
231    /// Borrow a `&str` for the duration of a call. The caller must outlive the
232    /// call (normal borrow rules).
233    pub fn from_str(s: &str) -> Self {
234        Self {
235            ptr: s.as_ptr() as *const c_char,
236            len: s.len(),
237        }
238    }
239
240    /// Read into a safe `&str` for the callee's lifetime `'a`.
241    ///
242    /// Panics on invalid UTF-8; SDK callback trampolines contain that panic.
243    ///
244    /// # Safety
245    /// The caller guarantees `ptr` is valid for `len` initialized bytes in a
246    /// single allocation, with `len <= isize::MAX`, and those bytes remain live
247    /// and unchanged for the borrow's lifetime `'a`.
248    pub unsafe fn as_str<'a>(&self) -> &'a str {
249        if self.len == 0 || self.ptr.is_null() {
250            return "";
251        }
252        let slice = unsafe { core::slice::from_raw_parts(self.ptr as *const u8, self.len) };
253        core::str::from_utf8(slice).expect("ABI string input must be valid UTF-8")
254    }
255}
256
257/// Function pointer a plugin exports to free a [`StbString`] it produced.
258/// Empty strings require no deallocation. Transfer each non-empty allocation
259/// exactly once; freeing another copy of an already-freed string is invalid.
260///
261/// The host calls this for every [`StbString`] it receives from the plugin; the
262/// plugin calls the **host's** `free_string` (from [`PluginApi`]) for every
263/// [`StbString`] it receives from the host.
264///
265/// # Safety
266/// The caller must transfer a live allocation made by this producer exactly
267/// once. Calling with a forged, borrowed or already-freed buffer is invalid.
268pub type FreeStringFn = unsafe extern "C" fn(s: StbString);
269
270impl StbString {
271    /// Free this `StbString` via the given `free_string` fn, if non-null and
272    /// non-empty. Consumes ownership (the value is `Copy`, but semantically the
273    /// caller relinquishes the allocation).
274    ///
275    /// After this call the bytes are invalid; do not use the `StbString` again.
276    ///
277    /// # Safety
278    /// A non-empty string must be a live owning allocation created by the
279    /// supplied producer. This call transfers it exactly once; no other copy
280    /// may be read or freed afterward. Prefer [`callbacks::OwnedString`] for
281    /// Rust-side ownership and automatic cleanup.
282    ///
283    /// ```compile_fail
284    /// use rpi_plugin_sdk::{callbacks, StbString};
285    /// let raw = StbString::from_string("text".into());
286    /// raw.free_with(Some(callbacks::free_string));
287    /// ```
288    pub unsafe fn free_with(self, free_fn: Option<FreeStringFn>) {
289        if let Some(free_fn) = free_fn {
290            if !self.is_empty() {
291                // SAFETY: the caller supplies the producer and unique ownership.
292                unsafe { free_fn(self) };
293            }
294        }
295    }
296}
297
298// ---------------------------------------------------------------------------
299// StableJsonValue — JSON round-trip helpers (json feature)
300// ---------------------------------------------------------------------------
301
302/// Helpers to cross structured data as JSON-in-`StbString`. See the module docs
303/// for the precision/order limit.
304#[cfg(feature = "json")]
305pub mod json {
306    use super::StbString;
307    use serde_json::Value;
308
309    /// Serialize a `serde_json::Value` into an owning [`StbString`] the receiver
310    /// must `free` via the producer's `free_string`.
311    pub fn to_stable(value: &Value, free_fn: Option<super::FreeStringFn>) -> StbString {
312        let s = serde_json::to_string(value).unwrap_or_else(|_| "null".to_string());
313        let stb = StbString::from_string(s);
314        // `free_fn` is advisory metadata the receiver needs; the StbString
315        // itself carries only ptr+len. Stash nothing — the receiver must know
316        // which free fn to use (host's vs plugin's) by direction.
317        let _ = free_fn;
318        stb
319    }
320
321    /// Parse a received [`StbString`] back into a `Value`. Does **not** free the
322    /// input — the caller still owns it.
323    ///
324    /// # Safety
325    /// The buffer must satisfy [`StbString::to_string_lossy`]'s validity and
326    /// lifetime requirements for the duration of this call.
327    ///
328    /// ```compile_fail
329    /// use rpi_plugin_sdk::{json, StbString};
330    /// let raw = StbString::empty();
331    /// let value = json::from_stable(&raw);
332    /// ```
333    pub unsafe fn from_stable(s: &StbString) -> Value {
334        // SAFETY: the caller supplies the raw string validity contract.
335        let text = unsafe { s.to_string_lossy() };
336        if text.is_empty() {
337            return Value::Null;
338        }
339        serde_json::from_str(&text).unwrap_or(Value::Null)
340    }
341}
342
343// ---------------------------------------------------------------------------
344// StableToolSchema — the provider-facing tool definition
345// ---------------------------------------------------------------------------
346
347/// A tool's provider-facing schema crossing the ABI. `name` / `description` are
348/// raw strings; `parameters` is a JSON Schema serialized to a JSON string (the
349/// host parses it into its native `schemars::Schema`).
350///
351/// All three are owning [`StbString`]s the **plugin** produced; the **host**
352/// frees them via the plugin's `free_string` (passed to [`ToolExecuteFn`] /
353/// `register_tool`).
354#[repr(C)]
355#[derive(Clone, Copy)]
356pub struct StableToolSchema {
357    pub name: StbString,
358    pub description: StbString,
359    /// JSON-encoded JSON Schema for the tool's `parameters`.
360    pub parameters: StbString,
361}
362
363// ---------------------------------------------------------------------------
364// Host context injected into plugin tool arguments
365// ---------------------------------------------------------------------------
366
367/// Reserved key the host adds to a **plugin** tool call's arguments.
368///
369/// The model never writes this field and never sees it: the host injects it
370/// after the model's arguments are parsed and before the plugin's
371/// [`ToolExecuteFn`] runs, so a plugin can tell *which* project and *which*
372/// session it is serving without the model having to supply either. (Built-in
373/// tools do not need it — they receive the host's `ExecutionToolContext`
374/// directly. A plugin tool has no such channel.)
375///
376/// The value is an object; today:
377///
378/// ```json
379/// { "cwd": "D:\\Projects\\pi-rust",
380///   "sessionId": "01adb026-7230-708b-855b-91eab6b056b8" }
381/// ```
382///
383/// Both fields are optional and either may be absent: `cwd` when the host
384/// cannot resolve one, `sessionId` for a session that has no id (ephemeral
385/// sessions) and on hosts that predate this field. The key itself may be absent
386/// entirely. Treat everything under it as best-effort, and prefer a
387/// project-scoped fallback when `sessionId` is missing rather than refusing to
388/// work.
389///
390/// Plugins using host-managed child actions additionally receive per-call
391/// `runtimeHandle` (opaque integer) and `runtimeKey` (capability string). Worker
392/// threads use that handle as runtime_action's opaque user_data and include
393/// `__rpiRuntimeKey` in ChildAgent arguments. Never log, persist, expose to the
394/// model, or reuse these values across tool calls. Missing values mean the
395/// host cannot support worker-originated ChildAgent safely; fail closed.
396///
397/// **Consequence for plugin parameter types:** a plugin must tolerate unknown
398/// top-level keys in its arguments. Reading them out of a `serde_json::Value`
399/// does that naturally; a `#[serde(deny_unknown_fields)]` parameter struct
400/// would reject this key, so do not use one for tool parameters.
401///
402/// The host's value wins if the model happens to send the same key: this is
403/// host truth, not a hint to be merged.
404pub const HOST_CONTEXT_KEY: &str = "__rpi";
405
406// ---------------------------------------------------------------------------
407// StepResult — the poll() return: Pending | Done | Err (explicit tag + union)
408// ---------------------------------------------------------------------------
409
410/// Opaque, plugin-allocated handle for one tool execution drive. Produced by
411/// [`ToolExecuteFn`], polled by [`ToolPollFn`], cancelled by [`ToolCancelFn`],
412/// freed by [`ToolDestroyFn`] (exactly once, idempotent).
413///
414/// The handle's interior layout is entirely plugin-private; the host treats it
415/// as an opaque pointer.
416pub type StepHandle = *mut c_void;
417
418/// Discriminant for [`StepResult`]. `#[repr(u32)]` pins the width so the union
419/// payload is sound across compilers.
420#[repr(u32)]
421#[derive(Debug, Clone, Copy, PartialEq, Eq)]
422pub enum StepResultTag {
423    /// `poll` has no terminal result yet; the partial payload may carry progress.
424    Pending = 0,
425    /// Terminal success; `done.result` is the JSON `AgentToolResult`.
426    Done = 1,
427    /// Terminal failure; `err.message` is a UTF-8 error string.
428    Err = 2,
429}
430
431/// A partial/progress result emitted during `Pending`. `progress` is a JSON
432/// `AgentToolResult` (the same shape `on_update` carries) — the host forwards it
433/// to the adapter's `on_update` callback. May be empty.
434#[repr(C)]
435#[derive(Clone, Copy)]
436pub struct StbPending {
437    pub progress: StbString,
438}
439
440/// Terminal success payload. `result` is a JSON `AgentToolResult`.
441#[repr(C)]
442#[derive(Clone, Copy)]
443pub struct StbDone {
444    pub result: StbString,
445}
446
447/// Terminal failure payload. `message` is a UTF-8 error string.
448#[repr(C)]
449#[derive(Clone, Copy)]
450pub struct StbErr {
451    pub message: StbString,
452}
453
454/// The `poll()` return value. Read the `payload` variant matching `tag`.
455///
456/// All payload variants are `#[repr(C)]` structs of [`StbString`] (Copy), so the
457/// union is `Copy`. A wrong-variant read is `unsafe`; always match on `tag`.
458#[repr(C)]
459#[derive(Clone, Copy)]
460pub union StepResultPayload {
461    pub pending: StbPending,
462    pub done: StbDone,
463    pub err: StbErr,
464}
465
466/// Return value of [`ToolPollFn`]. The blocking driver matches on `tag`, reads
467/// the matching payload, and breaks the loop on `Done`/`Err`.
468#[repr(C)]
469#[derive(Clone, Copy)]
470pub struct StepResult {
471    pub tag: StepResultTag,
472    pub payload: StepResultPayload,
473}
474
475impl StepResult {
476    /// Build a `Pending` with a progress JSON string (may be empty).
477    pub fn pending(progress: StbString) -> Self {
478        Self {
479            tag: StepResultTag::Pending,
480            payload: StepResultPayload {
481                pending: StbPending { progress },
482            },
483        }
484    }
485
486    /// Build a `Done` with the terminal JSON `AgentToolResult`.
487    pub fn done(result: StbString) -> Self {
488        Self {
489            tag: StepResultTag::Done,
490            payload: StepResultPayload {
491                done: StbDone { result },
492            },
493        }
494    }
495
496    /// Build an `Err` with an error message.
497    pub fn err(message: StbString) -> Self {
498        Self {
499            tag: StepResultTag::Err,
500            payload: StepResultPayload {
501                err: StbErr { message },
502            },
503        }
504    }
505
506    /// Access the `pending` payload. Caller MUST guarantee `tag == Pending`.
507    ///
508    /// # Safety
509    /// Undefined behavior if `tag != StepResultTag::Pending`.
510    pub unsafe fn pending_payload(&self) -> &StbPending {
511        unsafe { &self.payload.pending }
512    }
513
514    /// Access the `done` payload. Caller MUST guarantee `tag == Done`.
515    ///
516    /// # Safety
517    /// Undefined behavior if `tag != StepResultTag::Done`.
518    pub unsafe fn done_payload(&self) -> &StbDone {
519        unsafe { &self.payload.done }
520    }
521
522    /// Access the `err` payload. Caller MUST guarantee `tag == Err`.
523    ///
524    /// # Safety
525    /// Undefined behavior if `tag != StepResultTag::Err`.
526    pub unsafe fn err_payload(&self) -> &StbErr {
527        unsafe { &self.payload.err }
528    }
529}
530
531// ---------------------------------------------------------------------------
532// The 4-function tool lifecycle fn-pointer types
533// ---------------------------------------------------------------------------
534
535/// Partial-result callback the **blocking driver** passes to `poll`, wrapped in
536/// `catch_unwind` on the host side. The plugin invokes it **synchronously
537/// inside `poll()`** when it has a `Pending` partial — never retained, never
538/// invoked after `Done`/`Err`.
539///
540/// `partial` is a JSON `AgentToolResult`; ownership passes to the callback (the
541/// host frees it through this tool's registered plugin `free_string`).
542pub type ToolPartialCb = extern "C" fn(partial: StbString, user_data: *mut c_void);
543
544/// `execute(tool_call_id, params) -> StepHandle`. Plugin-allocates a drive
545/// handle and begins the work (non-blocking — the real progress comes via
546/// `poll`). `tool_call_id` is a borrowed [`StbStringRef`] (valid for the call);
547/// `params` is an owning JSON string of the tool-call arguments (the plugin
548/// frees it via the host's `free_string`). Returns null on allocation failure.
549pub type ToolExecuteFn = extern "C" fn(
550    tool_call_id: StbStringRef,
551    params: StbString,
552    free_params: Option<FreeStringFn>,
553) -> StepHandle;
554
555/// `poll(handle, partial_cb, user_data) -> StepResult`. **Non-blocking.** Must
556/// observe the cancel flag (set by [`ToolCancelFn`]) and return `Done`/`Err`
557/// within a bounded number of polls. Borrows `handle` (does not free it).
558pub type ToolPollFn = extern "C" fn(
559    handle: StepHandle,
560    partial_cb: Option<ToolPartialCb>,
561    user_data: *mut c_void,
562) -> StepResult;
563
564/// `cancel(handle)`. Sets an internal `AtomicBool` (SeqCst) cancel flag.
565/// **Idempotent, thread-safe, does NOT free.** The poll loop observes it.
566pub type ToolCancelFn = extern "C" fn(handle: StepHandle);
567
568/// `destroy(handle)`. Frees the handle. **Idempotent; called exactly once by the
569/// blocking driver on exit** (after the loop sees `Done`/`Err`, or after cancel
570/// propagated). Null handle is a no-op.
571pub type ToolDestroyFn = extern "C" fn(handle: StepHandle);
572
573/// Host notification that a wake-capable tool may make progress. May be called
574/// from any thread, including after unregistration; the opaque token is never
575/// dereferenced. Notifications coalesce and are retained until the next poll.
576pub type ToolWakeCb = extern "C" fn(callback_data: *mut c_void);
577
578/// Install or unregister a tool wake callback. Installing must notify if work
579/// is already ready. `None` unregisters; callbacks racing with removal are safe.
580/// The host serializes execute/install/poll/cancel/unregister/destroy per handle.
581/// After Pending, the host waits for a wake; plugins must wake for every new
582/// opportunity to poll. Partial callbacks remain synchronous within poll only.
583pub type ToolSetWakerFn =
584    extern "C" fn(handle: StepHandle, callback: Option<ToolWakeCb>, callback_data: *mut c_void);
585
586/// Begin shutdown without blocking: cancel work and reject new work.
587/// Must be idempotent; do not join workers or call back into locked host state.
588pub type PluginBeginShutdownFn = extern "C" fn(user_data: *mut c_void);
589/// Join workers and release resources on a host blocking cleanup thread.
590/// Return zero only when no background thread, task, or callback can execute
591/// plugin code again. Nonzero retains the library and host context forever;
592/// the host never times out shutdown and then unloads live code.
593pub type PluginFinishShutdownFn = extern "C" fn(user_data: *mut c_void) -> i32;
594/// Register shutdown hooks and their plugin-owned resource context.
595/// The host calls begin outside its session locks, then calls finish after all
596/// active FFI calls and plugin-owned string frees release their keepalives.
597/// The plugin may free `user_data` only after a successful finish.
598pub type RegisterPluginLifecycleFn =
599    extern "C" fn(PluginBeginShutdownFn, PluginFinishShutdownFn, *mut c_void) -> i32;
600
601// ---------------------------------------------------------------------------
602// StablePluginEvent — 33 on() categories (explicit tag + union)
603// ---------------------------------------------------------------------------
604
605/// Discriminant for [`StablePluginEvent`], one variant per pi `on()` category
606/// (33 total) plus one rpi-specific category (34 total). `#[repr(u32)]` pins
607/// the discriminant width.
608///
609/// The 33 Pi categories (verified against `extensions/types.ts:1203-1244`):
610/// project_trust, resources_discover, session_start, session_info_changed,
611/// session_before_switch, session_before_fork, session_before_compact,
612/// session_compact, session_shutdown, session_before_tree, session_tree,
613/// context, before_provider_request, before_provider_headers,
614/// after_provider_response, before_agent_start, agent_start, agent_end,
615/// agent_settled, turn_start, turn_end, message_start, message_update,
616/// message_end, tool_execution_start, tool_execution_update,
617/// tool_execution_end, model_select, thinking_level_select, tool_call,
618/// tool_result, user_bash, input.
619///
620/// `BeforeTuiStart` is **rpi-specific** (not a Pi category): dispatched once
621/// before the interactive TUI is initialized, so extensions can prepare
622/// (connect a server, load a resource) before the UI starts. Appended last so
623/// existing discriminants keep their values (ABI-safe for already-compiled
624/// plugins).
625#[repr(u32)]
626#[derive(Debug, Clone, Copy, PartialEq, Eq)]
627pub enum EventTag {
628    ProjectTrust = 0,
629    ResourcesDiscover = 1,
630    SessionStart = 2,
631    SessionInfoChanged = 3,
632    SessionBeforeSwitch = 4,
633    SessionBeforeFork = 5,
634    SessionBeforeCompact = 6,
635    SessionCompact = 7,
636    SessionShutdown = 8,
637    SessionBeforeTree = 9,
638    SessionTree = 10,
639    Context = 11,
640    BeforeProviderRequest = 12,
641    BeforeProviderHeaders = 13,
642    AfterProviderResponse = 14,
643    /// The start of a run, carrying the user prompt (`{"prompt", "imageCount",
644    /// "sessionId", "host"}`). Fire-and-forget like every other tag: subscribe to
645    /// *observe* the turn. To **change** the system prompt for the run, register a
646    /// handler with `register_before_agent_start` — that slot carries an `out`
647    /// pointer, which this fire-and-forget signature cannot.
648    BeforeAgentStart = 15,
649    AgentStart = 16,
650    AgentEnd = 17,
651    AgentSettled = 18,
652    TurnStart = 19,
653    TurnEnd = 20,
654    MessageStart = 21,
655    MessageUpdate = 22,
656    MessageEnd = 23,
657    ToolExecutionStart = 24,
658    ToolExecutionUpdate = 25,
659    ToolExecutionEnd = 26,
660    ModelSelect = 27,
661    ThinkingLevelSelect = 28,
662    ToolCall = 29,
663    ToolResult = 30,
664    UserBash = 31,
665    Input = 32,
666    /// rpi-specific: dispatched before the interactive TUI initializes (not a
667    /// Pi `on()` category). Appended last so existing discriminants are stable.
668    BeforeTuiStart = 33,
669    /// Pi `on()` category: dispatched when the TUI shows an interactive prompt
670    /// (selector, dialog, etc.) that blocks agent work.
671    UiPromptStart = 34,
672    /// Pi `on()` category: dispatched when the TUI prompt is dismissed.
673    UiPromptEnd = 35,
674    /// rpi-specific (not a Pi `on()` category): dispatched whenever the
675    /// interactive prompt editor's **text changes** — typing, pasting, deleting,
676    /// or a programmatic rewrite. Appended last so existing discriminants are
677    /// stable. The payload is `{"chars":N,"empty":bool}`; the draft text itself
678    /// is deliberately not shipped, because subscribers (a voice extension
679    /// barging in on playback) only need to know *that* the user is composing.
680    EditorChange = 36,
681}
682
683/// Number of event categories — `37` (33 Pi `on()` categories + 1 rpi-specific
684/// [`EventTag::BeforeTuiStart`] + 2 UI prompt events + 1 rpi-specific
685/// [`EventTag::EditorChange`]). A test asserts
686/// `EVENT_TAG_COUNT == 37` so a future edit that adds/removes a tag is caught.
687pub const EVENT_TAG_COUNT: usize = 37;
688
689/// The application name of the host that loads plugins: `"rpi"`.
690///
691/// This is the **contract's** copy of the host identity, and the host's
692/// `APP_NAME` is defined in terms of it, so the two cannot drift. An extension
693/// that labels its own output (a trace name, a tag, a `service.name`) should
694/// derive from this rather than hardcode a brand — it has no other way to learn
695/// what it is embedded in.
696///
697/// [`HOST_VERSION`] is the matching version. Both are also handed to plugins at
698/// runtime in the `BeforeAgentStart` payload's `host` object, which is what an
699/// extension should prefer: that value always describes the process actually
700/// running, whereas these constants describe the host the plugin was *built*
701/// against.
702pub const HOST_NAME: &str = "rpi";
703
704/// Version of the [`HOST_NAME`] host that this SDK ships with.
705///
706/// Because every workspace crate shares one version, this is the host's version
707/// as of this SDK release — good for a user agent or a fallback label, and
708/// superseded at runtime by the `host.version` field of the `BeforeAgentStart`
709/// payload.
710/// A preview build sets `RPI_BUILD_VERSION` so the executable and plugin
711/// bridge report the full prerelease version without changing the checked-in
712/// Cargo workspace version. Empty values are ignored for normal builds.
713pub const HOST_VERSION: &str = match option_env!("RPI_BUILD_VERSION") {
714    Some(version) if !version.is_empty() => version,
715    _ => env!("CARGO_PKG_VERSION"),
716};
717
718/// Environment variable carrying the id of the session this process serves.
719///
720/// The host **rewrites** it whenever a session becomes active — at startup and
721/// on every in-process swap (`/import`, fork, switch) — so the value always
722/// names the session currently being served. It is not an embedder override:
723/// a value inherited from a parent process would otherwise make a child `rpi`
724/// report its ancestor's session forever. Embedders that need to pin the id set
725/// it after the session is active rather than before.
726///
727/// The id is a plain UTF-8 string (no NUL), which is what makes writing it with
728/// `std::env::set_var` sound.
729pub const SESSION_ID_ENV: &str = "RPI_SESSION_ID";
730
731/// Environment variable a tracing extension publishes to name the trace a
732/// *nested* `rpi` should attach to, and reads back to decide it is a subagent.
733///
734/// The name is `rpi-langfuse`'s (it owns the `LANGFUSE_` prefix), but the
735/// variable is declared here because **three** parties have to agree on it and
736/// they live in different crates, two of them in different repositories:
737///
738/// - the extension writes it (so a launcher can attach a nested run),
739/// - the host's `bash`/`powershell` must *not* pass it on to a shell (or a plain
740///   `rpi` started from one mistakes itself for a subagent), and
741/// - a launcher may set it deliberately, which is the supported nesting path.
742///
743/// Declaring it once here is what keeps those three in step. The host builds its
744/// exclusion list from these declarations, so dropping or renaming the family
745/// ([`SUBAGENT_PARENT_ENV`]) is a compile error there rather than a silently
746/// re-opened leak, and a newly added member is excluded without the host having
747/// to be told. What the compiler still cannot check is the string itself, since
748/// `rpi-langfuse` lives in another repository and matches on the *value* — that
749/// is pinned by a test. See [`SUBAGENT_PARENT_ENV`] for the whole family.
750pub const SUBAGENT_PARENT_TRACE_ID_ENV: &str = "LANGFUSE_PI_PARENT_TRACE_ID";
751
752/// See [`SUBAGENT_PARENT_TRACE_ID_ENV`]. The parent turn's root observation id.
753pub const SUBAGENT_PARENT_SPAN_ID_ENV: &str = "LANGFUSE_PI_PARENT_SPAN_ID";
754
755/// See [`SUBAGENT_PARENT_TRACE_ID_ENV`]. The parent's session id.
756pub const SUBAGENT_PARENT_SESSION_ID_ENV: &str = "LANGFUSE_PI_PARENT_SESSION_ID";
757
758/// See [`SUBAGENT_PARENT_TRACE_ID_ENV`]. Nesting depth, so a grandchild knows
759/// how far down it is.
760pub const SUBAGENT_PARENT_DEPTH_ENV: &str = "LANGFUSE_PI_PARENT_DEPTH";
761
762/// Every [`SUBAGENT_PARENT_TRACE_ID_ENV`] sibling, as one slice.
763///
764/// A host that excludes this channel from a spawned shell should cover the whole
765/// slice rather than restating the names, and assert it does — a new entry here
766/// that is not excluded is the failure mode this array exists to make testable.
767pub const SUBAGENT_PARENT_ENV: &[&str] = &[
768    SUBAGENT_PARENT_TRACE_ID_ENV,
769    SUBAGENT_PARENT_SPAN_ID_ENV,
770    SUBAGENT_PARENT_SESSION_ID_ENV,
771    SUBAGENT_PARENT_DEPTH_ENV,
772];
773
774/// No-payload marker for events that carry none (e.g. `session_shutdown`).
775/// Carries a dummy byte so the empty-struct isn't flagged FFI-unsafe by
776/// `improper_ctypes` (zero-sized C structs are rejected regardless of `repr(C)`).
777#[repr(C)]
778#[derive(Clone, Copy)]
779pub struct EventEmpty {
780    _opaque: u8,
781}
782
783impl EventEmpty {
784    /// The one no-payload instance.
785    pub const INSTANCE: EventEmpty = EventEmpty { _opaque: 0 };
786}
787
788impl Default for EventEmpty {
789    fn default() -> Self {
790        Self::INSTANCE
791    }
792}
793
794/// A serialized message lifecycle payload (`message_start`/`message_update`/
795/// `message_end`). `message` is JSON. For `message_update`, it follows native
796/// Pi's envelope and carries `assistantMessageEvent`; streaming consumers must
797/// read its real `text_delta.delta` instead of diffing a cumulative snapshot.
798#[repr(C)]
799#[derive(Clone, Copy)]
800pub struct EventMessage {
801    pub message: StbString,
802}
803
804/// A tool-call payload (`tool_call`, `tool_execution_start`/`update`).
805/// `tool_call_id` + `tool_name` are raw strings; `params` is the JSON args.
806#[repr(C)]
807#[derive(Clone, Copy)]
808pub struct EventToolCall {
809    pub tool_call_id: StbString,
810    pub tool_name: StbString,
811    pub params: StbString,
812}
813
814/// A tool-result payload (`tool_result`, `tool_execution_end`).
815#[repr(C)]
816#[derive(Clone, Copy)]
817pub struct EventToolResult {
818    pub tool_call_id: StbString,
819    pub tool_name: StbString,
820    pub result: StbString,
821    pub is_error: u8,
822}
823
824/// An error/failure payload.
825#[repr(C)]
826#[derive(Clone, Copy)]
827pub struct EventError {
828    pub message: StbString,
829}
830
831/// A generic JSON-data payload for the long-tail events whose structured shape
832/// the host serializes wholesale (`context`, `before_provider_request`, model
833/// select, resources_discover response, etc.). The plugin reads the fields it
834/// needs.
835#[repr(C)]
836#[derive(Clone, Copy)]
837pub struct EventData {
838    pub data: StbString,
839}
840
841/// Payload union for [`StablePluginEvent`]. All variants are `#[repr(C)]` structs
842/// of [`StbString`] / primitives (Copy), so the union is Copy. Discriminate by
843/// [`StablePluginEvent::tag`] before reading.
844#[repr(C)]
845#[derive(Clone, Copy)]
846pub union EventPayload {
847    pub empty: EventEmpty,
848    pub message: EventMessage,
849    pub tool_call: EventToolCall,
850    pub tool_result: EventToolResult,
851    pub error: EventError,
852    pub data: EventData,
853}
854
855/// One event dispatched to a plugin handler. The host translates its native
856/// `AgentEvent` / `HarnessEvent` into this and calls every registered handler
857/// for the `tag`. Event buffers are borrowed during fan-out: handlers must not
858/// free them or retain their pointers. The host frees them after all handlers
859/// return. Use `events::EventView` to retain owned payload copies.
860#[repr(C)]
861#[derive(Clone, Copy)]
862pub struct StablePluginEvent {
863    pub tag: EventTag,
864    pub payload: EventPayload,
865}
866
867impl StablePluginEvent {
868    /// Build a no-payload event.
869    pub fn empty(tag: EventTag) -> Self {
870        Self {
871            tag,
872            payload: EventPayload {
873                empty: EventEmpty::INSTANCE,
874            },
875        }
876    }
877
878    /// Build a message event.
879    pub fn message(tag: EventTag, message: StbString) -> Self {
880        debug_assert!(matches!(
881            tag,
882            EventTag::MessageStart | EventTag::MessageUpdate | EventTag::MessageEnd
883        ));
884        Self {
885            tag,
886            payload: EventPayload {
887                message: EventMessage { message },
888            },
889        }
890    }
891
892    /// Build a tool-call event.
893    pub fn tool_call(
894        tag: EventTag,
895        tool_call_id: StbString,
896        tool_name: StbString,
897        params: StbString,
898    ) -> Self {
899        debug_assert!(matches!(
900            tag,
901            EventTag::ToolCall | EventTag::ToolExecutionStart | EventTag::ToolExecutionUpdate
902        ));
903        Self {
904            tag,
905            payload: EventPayload {
906                tool_call: EventToolCall {
907                    tool_call_id,
908                    tool_name,
909                    params,
910                },
911            },
912        }
913    }
914
915    /// Build a tool-result event.
916    pub fn tool_result(
917        tag: EventTag,
918        tool_call_id: StbString,
919        tool_name: StbString,
920        result: StbString,
921        is_error: bool,
922    ) -> Self {
923        debug_assert!(matches!(
924            tag,
925            EventTag::ToolResult | EventTag::ToolExecutionEnd
926        ));
927        Self {
928            tag,
929            payload: EventPayload {
930                tool_result: EventToolResult {
931                    tool_call_id,
932                    tool_name,
933                    result,
934                    is_error: is_error as u8,
935                },
936            },
937        }
938    }
939
940    /// Build an error event.
941    pub fn error(tag: EventTag, message: StbString) -> Self {
942        Self {
943            tag,
944            payload: EventPayload {
945                error: EventError { message },
946            },
947        }
948    }
949
950    /// Build a generic data event (JSON in `data`).
951    pub fn data(tag: EventTag, data: StbString) -> Self {
952        Self {
953            tag,
954            payload: EventPayload {
955                data: EventData { data },
956            },
957        }
958    }
959}
960
961/// Return codes an [`EventHandlerFn`] may return.
962///
963/// `0` means success; the host continues to the next handler. A **nonzero**
964/// return means the handler handled an error — the host logs it and (for the
965/// ordinary agent/observe fan-out) continues to other handlers (one handler's
966/// error does not abort the fan-out, mirroring pi's per-handler try/catch).
967///
968/// [`EVENT_HANDLER_ABORT`] is the **veto** code: the handler wants to stop the
969/// current lifecycle phase. It is honored only by the host's lifecycle
970/// dispatch (e.g. `before_tui_start` / `session_start` / `session_shutdown`);
971/// the ordinary observe fan-out treats it like any other nonzero (log +
972/// continue), because the agent loop must not be vetoed mid-run.
973pub const EVENT_HANDLER_CONTINUE: i32 = 0;
974/// The handler handled an error but the fan-out should continue (default
975/// nonzero semantics — equivalent to any other nonzero code).
976pub const EVENT_HANDLER_ERROR: i32 = 1;
977/// **Veto**: stop the current lifecycle phase. Honored only by the host's
978/// lifecycle dispatch; the observe fan-out logs + continues (see the module
979/// docs on [`EVENT_HANDLER_CONTINUE`]).
980pub const EVENT_HANDLER_ABORT: i32 = 2;
981/// **Claim**: the handler consumed this event and the host should not fall back
982/// to its own default handling. Honored only by dispatch paths that ask
983/// extensions to arbitrate (currently `EventTag::Input` key routing, via
984/// [`EVENT_HANDLER_CLAIMED`]); the ordinary observe fan-out treats it like any
985/// other nonzero (log + continue).
986///
987/// Lets a handler subscribe to a key yet decline it while its feature is off:
988/// return [`EVENT_HANDLER_CONTINUE`] to pass the key through to normal editor
989/// handling, or this to swallow it.
990pub const EVENT_HANDLER_CLAIMED: i32 = 3;
991
992/// Handler fn pointer registered via `register_event_handler(tag, handler)`.
993/// `user_data` is the plugin's opaque context. Return `0` on success; nonzero
994/// signals a handled error (the host logs it; dispatch continues to other
995/// handlers — one handler's error does not abort the fan-out). The one
996/// exception: when the host dispatches a **lifecycle** event through its
997/// veto-aware path, returning [`EVENT_HANDLER_ABORT`] stops the fan-out and
998/// aborts that phase (see the constants above).
999pub type EventHandlerFn = extern "C" fn(event: StablePluginEvent, user_data: *mut c_void) -> i32;
1000
1001/// `resources_discover` handler signature (B5b). Unlike [`EventHandlerFn`]
1002/// (fire-and-forget, `i32` only), this carries an owning `out` so the plugin
1003/// can hand `{skillPaths, promptPaths, themePaths}` back to the host. `cwd` and
1004/// `reason` are borrowed inputs ([`StbStringRef`]); `out` is plugin-produced
1005/// and reclaimed via the `plugin_free_string` the host stored alongside the
1006/// handler at registration. `user_data` is the plugin's opaque context. Returns
1007/// `0` on success (host reads `out`); nonzero on a handled error (host logs +
1008/// skips this handler, fan-out continues — mirrors pi `runner.ts:1179-1188`).
1009pub type ResourcesDiscoverFn = extern "C" fn(
1010    cwd: StbStringRef,
1011    reason: StbStringRef,
1012    out: *mut StbString,
1013    user_data: *mut c_void,
1014) -> i32;
1015
1016/// `before_agent_start` handler signature. Unlike [`EventHandlerFn`]
1017/// (fire-and-forget, `i32` only), this carries an owning `out` so the plugin can
1018/// hand back a system prompt to install for the run — pi's
1019/// `BeforeAgentStartEventResult` (`extensions/types.ts:1394`), whose
1020/// `systemPrompt` becomes `forceSystemPrompt` (`runner.ts:1347`).
1021///
1022/// `event_json` is a borrowed envelope with the same fields the
1023/// [`EventTag::BeforeAgentStart`] broadcast carries (`prompt`, `imageCount`,
1024/// `sessionId`, `host`) plus the live `systemPrompt` the run would otherwise
1025/// use. `out` is plugin-produced JSON `{"systemPrompt": "..."}` (or an empty
1026/// object for "no change"), reclaimed via the `plugin_free_string` the host
1027/// stored at registration.
1028///
1029/// Returns `0` on success (host reads `out`); nonzero on a handled error (host
1030/// logs the plugin's message and leaves the prompt untouched, then continues the
1031/// fan-out — mirrors pi, which isolates a throwing handler).
1032///
1033/// **Replacement, not append**: the returned text becomes the system prompt. A
1034/// plugin that wants to append reads `event_json.systemPrompt` and returns the
1035/// two concatenated, which is what pi requires of its extensions too.
1036/// SAFETY: the plugin warrants `user_data` is valid for the registry's lifetime
1037/// and the handler is callable from any thread.
1038pub type BeforeAgentStartFn =
1039    extern "C" fn(event_json: StbStringRef, out: *mut StbString, user_data: *mut c_void) -> i32;
1040
1041// ---------------------------------------------------------------------------
1042// Runtime actions — uniform JSON-RPC dispatch by RuntimeActionId
1043// ---------------------------------------------------------------------------
1044
1045/// Identifier for a host runtime action the plugin may invoke via
1046/// `PluginApi::runtime_action`. One slot dispatches all actions; the numeric
1047/// id crosses the FFI boundary as a `u32` and the host validates it with
1048/// [`TryFrom<u32>`] before constructing this enum. Args/results cross as JSON
1049/// strings.
1050#[repr(u32)]
1051#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1052pub enum RuntimeActionId {
1053    /// Send `message` (stored AgentMessage or Pi customType/content/display/
1054    /// details) or `text`. Options `triggerTurn` and `deliverAs` can be flat or
1055    /// nested under `options`. Default: idle append, active steer. Use explicit
1056    /// `triggerTurn: true` to start an idle run. `nextTurn` always defers.
1057    SendMessage = 0,
1058    SendUserMessage = 1,
1059    /// Append without starting a run. `{"customType": ..., "data": ...}` stores
1060    /// extension data; `{"message": ...}` queues context until the active turn
1061    /// settles. The returned `entryId` can be reserved before its entry exists.
1062    AppendEntry = 2,
1063    SetSessionName = 3,
1064    GetActiveTools = 4,
1065    SetActiveTools = 5,
1066    SetModel = 6,
1067    GetThinkingLevel = 7,
1068    SetThinkingLevel = 8,
1069    Compact = 9,
1070    GetSystemPrompt = 10,
1071    NewSession = 11,
1072    Fork = 12,
1073    NavigateTree = 13,
1074    SwitchSession = 14,
1075    Reload = 15,
1076    /// Read a parsed CLI flag by name. Args: `{"name":"flag"}`; result:
1077    /// `{"value": <bool|string|null>}`.
1078    GetCliFlag = 16,
1079    /// Open/poll/cancel a host-provided interactive UI request. The payload is
1080    /// a JSON object with `op` (`open`, `poll`, or `cancel`) and a unique
1081    /// `requestId`; hosts without an attached TUI return an explicit error.
1082    UiDialog = 17,
1083    /// Publish a short status string for the host's UI (TUI footer).
1084    /// Args: `{"key":"langfuse","value":"langfuse ✓ (trace sent)"}`; an
1085    /// empty/absent `value` clears that key. Result: `{"ok":true,"changed":bool}`.
1086    /// Headless hosts accept the write but have nothing to render.
1087    /// Legacy footer text (`key`, `value`). Use UiComponent for registered UI.
1088    SetStatus = 18,
1089    /// Put text into the interactive editor (the prompt input box) instead of
1090    /// sending it. Args: `{"text":"...", "mode":"append"|"replace",
1091    /// "autoSendMs": 3000}` (omit/`0` for manual send only). Result:
1092    /// `{"ok":true,"chars":N}`. A headless host has no editor and accepts the
1093    /// write without rendering it, mirroring [`RuntimeActionId::SetStatus`].
1094    SetEditorText = 19,
1095    /// Return the current session branch entries oldest-first as JSON.
1096    /// Args: `{}`; result: `{"entries":[...]}`.
1097    GetSessionBranch = 20,
1098    /// Inspect or finish a plugin-owned stdio server lifecycle. Args:
1099    /// `{"op":"status"}` or `{"op":"finish","exitCode":0}`. Only the
1100    /// declaring owner may finish; the host performs normal session shutdown.
1101    StdioLifecycle = 21,
1102    /// Plugin-owned text component lifecycle and content updates.
1103    UiComponent = 22,
1104    /// Safe live execution metadata (no credentials, endpoints or headers).
1105    /// Args: `{}`; result: model identity, thinkingLevel, tools, cwd, sessionId.
1106    GetExecutionContext = 23,
1107    /// Host-owned independent child run. `op`: start/poll/cancel/dispose.
1108    /// Start: task, optional role/maxTurns/timeoutSeconds/tools/toolCallId.
1109    /// Others: childId. Poll drains text deltas and returns status/result.
1110    /// Handles are scoped to the calling plugin and must be disposed.
1111    ChildAgent = 24,
1112    /// Read-only, host-managed side question. Registration-level commands may
1113    /// use start(question), status, cancel, hide and show. Never changes the
1114    /// main transcript or its queues. Presentation and execution stay in host.
1115    SideQuestion = 25,
1116}
1117
1118/// Error returned when a plugin passes a numeric runtime-action id that this
1119/// ABI does not define.
1120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1121pub struct UnknownRuntimeActionId(pub u32);
1122
1123impl core::fmt::Display for UnknownRuntimeActionId {
1124    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
1125        write!(f, "unknown runtime action id {}", self.0)
1126    }
1127}
1128
1129impl std::error::Error for UnknownRuntimeActionId {}
1130
1131impl TryFrom<u32> for RuntimeActionId {
1132    type Error = UnknownRuntimeActionId;
1133
1134    fn try_from(value: u32) -> Result<Self, Self::Error> {
1135        match value {
1136            0 => Ok(Self::SendMessage),
1137            1 => Ok(Self::SendUserMessage),
1138            2 => Ok(Self::AppendEntry),
1139            3 => Ok(Self::SetSessionName),
1140            4 => Ok(Self::GetActiveTools),
1141            5 => Ok(Self::SetActiveTools),
1142            6 => Ok(Self::SetModel),
1143            7 => Ok(Self::GetThinkingLevel),
1144            8 => Ok(Self::SetThinkingLevel),
1145            9 => Ok(Self::Compact),
1146            10 => Ok(Self::GetSystemPrompt),
1147            11 => Ok(Self::NewSession),
1148            12 => Ok(Self::Fork),
1149            13 => Ok(Self::NavigateTree),
1150            14 => Ok(Self::SwitchSession),
1151            15 => Ok(Self::Reload),
1152            16 => Ok(Self::GetCliFlag),
1153            17 => Ok(Self::UiDialog),
1154            18 => Ok(Self::SetStatus),
1155            19 => Ok(Self::SetEditorText),
1156            20 => Ok(Self::GetSessionBranch),
1157            21 => Ok(Self::StdioLifecycle),
1158            22 => Ok(Self::UiComponent),
1159            23 => Ok(Self::GetExecutionContext),
1160            24 => Ok(Self::ChildAgent),
1161            25 => Ok(Self::SideQuestion),
1162            other => Err(UnknownRuntimeActionId(other)),
1163        }
1164    }
1165}
1166
1167impl From<RuntimeActionId> for u32 {
1168    fn from(value: RuntimeActionId) -> Self {
1169        value as u32
1170    }
1171}
1172
1173/// Runtime-action signature: `runtime_action(action_id, args_json, out,
1174/// user_data) -> i32`. `args_json` is a borrowed input ([`StbStringRef`]); `out`
1175/// is an owning output ([`StbString`]) the host produces and the plugin frees
1176/// via the host's `free_string`. `action_id` is deliberately a raw `u32`, not a
1177/// Rust enum: an unknown value must be rejected as a normal protocol error
1178/// rather than materializing an invalid enum discriminant. Returns `0` on
1179/// success, nonzero on error.
1180pub type RuntimeActionFn = extern "C" fn(
1181    action_id: u32,
1182    args_json: StbStringRef,
1183    out: *mut StbString,
1184    user_data: *mut c_void,
1185) -> i32;
1186
1187// ---------------------------------------------------------------------------
1188// PluginApi — host-provided API struct the plugin calls
1189// ---------------------------------------------------------------------------
1190
1191/// A generic command-handler fn (for `register_command`). `args_json` is a
1192/// borrowed `{"args":"...","command":"/..."}` envelope; `out` is owning
1193/// JSON output reclaimed with the host `free_string`. The TUI understands
1194/// `{kind:"message",text}`, `{kind:"selector",items:[...]}`,
1195/// `{kind:"editor",initialText}`, and `{kind:"input",title,placeholder}`
1196/// responses; selector/editor/input submissions call the same handler with an
1197/// `action` field in `args`. `user_data` is the registering plugin's opaque
1198/// runtime handle (or null when no action bridge exists), scoped to that
1199/// registration. Pass it to runtime_action; never cache it across registrations.
1200pub type CommandHandlerFn =
1201    extern "C" fn(args_json: StbStringRef, out: *mut StbString, user_data: *mut c_void) -> i32;
1202
1203/// A render/transform fn (for the renderer registrars). `input_json` is borrowed;
1204/// `out` is owning output the plugin frees via host `free_string`. Markdown
1205/// handlers return `{markdown:"..."}`; message/entry handlers return
1206/// `{text:"...",markdown?:true}` or `{lines:["..."]}` for terminal UI.
1207pub type RenderFn =
1208    extern "C" fn(input_json: StbStringRef, out: *mut StbString, user_data: *mut c_void) -> i32;
1209
1210/// A provider-injection factory fn (for `register_provider`). `req_json` is a
1211/// borrowed request envelope; `out` is an owning response the plugin frees.
1212/// The host wraps this into a `Provider` impl (B4/B5).
1213///
1214/// **One-shot.** The plugin returns the complete assistant message and the host
1215/// emits it as a single terminal chunk (no progressive rendering). A provider
1216/// that can stream should use [`ProviderStreamRequestFn`] via
1217/// `register_streaming_provider` instead; both slots stay registered so an
1218/// existing plugin keeps its behavior unchanged.
1219pub type ProviderRequestFn =
1220    extern "C" fn(req_json: StbStringRef, out: *mut StbString, user_data: *mut c_void) -> i32;
1221
1222/// Chunk kind for [`ProviderStreamControl::is_cancelled`]. **Appended last** so a
1223/// plugin compiled against an older, shorter struct reads only the fields it
1224/// knows about.
1225#[repr(u32)]
1226#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1227pub enum ProviderChunkKind {
1228    /// A chunk that carries model output (`text_delta`, `thinking_delta`, …).
1229    /// A cancelled request has no one left to deliver it to.
1230    Content = 0,
1231    /// A structural or accounting chunk (`start`, `*_start`, `*_end`, `usage`).
1232    /// Still acknowledged while the request winds down. A host that already
1233    /// emitted its aborted terminal discards these cleanup chunks.
1234    Structural = 1,
1235}
1236
1237/// The plugin's synchronous control surface over the host, passed to a **v2**
1238/// streaming request fn.
1239///
1240/// The fields are plain fn pointers plus an opaque pointer, so the struct is
1241/// `Copy` and needs no deallocation. The host owns it for the duration of the
1242/// request and it must not be retained.
1243///
1244/// `is_cancelled` is the cheap check a plugin's request loop makes between
1245/// chunks (poll it, do not block on it). `wait_cancelled_ms` is the blocking
1246/// variant for a plugin whose loop is not able to poll — an SSE frame read, a
1247/// sleep between retries — and it must return promptly on cancellation rather
1248/// than waiting out the full timeout.
1249///
1250/// ```ignore
1251/// // Inside a v2 request fn, before handing a delta to `emit`:
1252/// if control.is_cancelled(control.user_data, ProviderChunkKind::Content as u32) != 0 {
1253///     return 1; // stop producing; the host is already gone
1254/// }
1255/// ```
1256#[repr(C)]
1257#[derive(Clone, Copy)]
1258pub struct ProviderStreamControl {
1259    /// `is_cancelled(control_user_data, chunk_kind: u32) -> i32`. Nonzero when
1260    /// the caller has gone away. `chunk_kind` lets the host still admit the
1261    /// structural chunks a partial message needs: pass
1262    /// [`ProviderChunkKind::Content`] (0) for model output and
1263    /// [`ProviderChunkKind::Structural`] (1) otherwise. Never blocking, never
1264    /// frees anything.
1265    pub is_cancelled: extern "C" fn(user_data: *mut c_void, chunk_kind: u32) -> i32,
1266    /// `wait_cancelled_ms(control_user_data, timeout_ms) -> i32`. Blocks up to
1267    /// `timeout_ms` waiting for cancellation, returning nonzero the moment it is
1268    /// observed. `timeout_ms == 0` is a non-blocking poll (same as
1269    /// `is_cancelled` with [`ProviderChunkKind::Content`]). Safe to call from the
1270    /// request thread: it only touches the host's cancellation latch, which is
1271    /// always present in the snapshot and never holds another lock.
1272    pub wait_cancelled_ms: extern "C" fn(user_data: *mut c_void, timeout_ms: u32) -> i32,
1273    /// Host-owned opaque handle, passed back unchanged to both fields above.
1274    pub user_data: *mut c_void,
1275}
1276
1277/// Completion signal a **v2** streaming provider calls **after** its request fn
1278/// has returned.
1279///
1280/// Retained in the ABI for a future host that can keep the plugin's own stream
1281/// alive past the request fn. This host does **not** use it: the plugin receives
1282/// `None` and must therefore finish inside its request fn (which it can do
1283/// promptly on cancellation by polling [`ProviderStreamControl`]). Keeping the
1284/// type documented — rather than deleting it — is what makes a future host able
1285/// to add the capability without another signature change.
1286///
1287/// `message_json` is a **plugin-owned** owning [`StbString`] holding the final
1288/// assistant message.
1289pub type ProviderStreamCompletionFn =
1290    extern "C" fn(message_json: StbString, user_data: *mut c_void) -> i32;
1291
1292/// Per-chunk callback a **streaming** provider invokes while the response is
1293/// still being produced (for `register_streaming_provider`).
1294///
1295/// `event_json` is an **owning** [`StbString`] the plugin produces and transfers
1296/// to the host: the host reclaims it with the **producer's** `plugin_free_string`
1297/// — the one the plugin passed to `register_streaming_provider` — exactly like a
1298/// tool's partial-progress chunk. The host must not use its own `free_string`
1299/// for it: the allocation belongs to the plugin's SDK instance.
1300///
1301/// The callback is invoked **synchronously inside the request fn**, on the same
1302/// thread, only for the duration of that call — a plugin must never retain
1303/// `emit`, `emit_user_data`, or the struct they live in past the request.
1304/// Returns `0` on success; nonzero means the host rejected the chunk (a malformed
1305/// envelope), which a plugin may ignore or treat as a request failure.
1306pub type ProviderStreamEmitFn = extern "C" fn(event_json: StbString, user_data: *mut c_void) -> i32;
1307
1308/// A **streaming** provider-injection fn (for `register_streaming_provider`).
1309///
1310/// Same request envelope and final-response contract as [`ProviderRequestFn`],
1311/// plus a per-chunk channel and the cancellation protocol:
1312///
1313/// - `req_json` — borrowed `{ model, context, options }` envelope (never freed by
1314///   the plugin).
1315/// - `emit` + `emit_user_data` — the host's per-chunk callback. `emit` is `None`
1316///   only if the host cannot accept chunks; a plugin then degrades to buffering
1317///   and returning the message through `out`. While the caller is cancelled the
1318///   host acknowledges structural cleanup chunks and rejects content chunks
1319///   (nonzero rc). Cleanup chunks are discarded after the aborted terminal.
1320/// - `control` — pointer to the host's [`ProviderStreamControl`], present on
1321///   **every** call. It is `null` only against a host that cannot describe its
1322///   control surface; a plugin then treats the request as not cancellable and
1323///   behaves as if cancellation never happens. The plugin must not retain it: it
1324///   is valid only for the duration of the request fn.
1325/// - `completion` — reserved; always `None` on this host (see
1326///   [`ProviderStreamCompletionFn`]). A plugin must return its final message
1327///   through `out` and may not expect to keep streaming after returning.
1328/// - `out` — owning, plugin-owned, plugin-freed response holding the **final**
1329///   `AssistantMessage` JSON (identical to the one-shot contract).
1330/// - `user_data` — the opaque value the plugin registered with.
1331///
1332/// Call order: zero or more `emit` chunks, then exactly one return. The host
1333/// derives the terminal `Done`/`Error` event from `out` (or from a nonzero rc),
1334/// so a plugin that emits deltas still must fill `out`.
1335///
1336/// **Cancellation is opt-in per chunk, not per registration.** There is one
1337/// streaming registration and one signature; whether a plugin checks `control` is
1338/// up to it. A plugin that ignores `control` behaves exactly like the simpler
1339/// fire-and-forget shape (the host still stops forwarding content and terminates
1340/// the run on `Aborted`) — it just keeps generating until it finishes on its own.
1341///
1342/// Chunk envelope (JSON object with a `type` discriminator). `contentIndex` is the
1343/// index of the block inside the final message; each index may be started once,
1344/// but blocks may be opened in any order:
1345///
1346/// ```json
1347/// {"type":"start"}
1348/// {"type":"text_start","contentIndex":0}
1349/// {"type":"text_delta","contentIndex":0,"delta":"hello "}
1350/// {"type":"text_end","contentIndex":0,"content":"hello world"}
1351/// {"type":"thinking_start","contentIndex":1}
1352/// {"type":"thinking_delta","contentIndex":1,"delta":"considering"}
1353/// {"type":"thinking_end","contentIndex":1,"content":"considering..."}
1354/// {"type":"toolcall_start","contentIndex":2,"id":"call_1","name":"echo"}
1355/// {"type":"toolcall_delta","contentIndex":2,"delta":"{\"text\""}
1356/// {"type":"toolcall_end","contentIndex":2,"id":"call_1","name":"echo","arguments":{}}
1357/// {"type":"usage","usage":{"input":1,"output":2}}
1358/// ```
1359///
1360/// Unknown `type` values are ignored (forward compatibility). A `*_delta` may
1361/// carry an optional `partial` object (a full assistant message) to override the
1362/// host-side accumulated snapshot; otherwise the host accumulates deltas itself.
1363pub type ProviderStreamRequestFn = extern "C" fn(
1364    req_json: StbStringRef,
1365    emit: Option<ProviderStreamEmitFn>,
1366    emit_user_data: *mut c_void,
1367    control: *const ProviderStreamControl,
1368    completion: Option<ProviderStreamCompletionFn>,
1369    completion_user_data: *mut c_void,
1370    out: *mut StbString,
1371    user_data: *mut c_void,
1372) -> i32;
1373
1374/// OAuth provider callback. The host sends a JSON action envelope and the
1375/// plugin returns a JSON response. Actions are `begin`, `exchange`,
1376/// `refresh`, and `revoke`; token material remains plugin-owned and is never
1377/// placed in normal agent events.
1378pub type OAuthRequestFn =
1379    extern "C" fn(req_json: StbStringRef, out: *mut StbString, user_data: *mut c_void) -> i32;
1380
1381/// Register an OAuth provider using a JSON manifest plus one action callback.
1382/// The manifest is copied by the host and should include `id`, `displayName`,
1383/// `scopes`, `supportsPkce`, and optional `capabilities`.
1384pub type OAuthRegisterFn = OAuthRequestFn;
1385
1386// ---------------------------------------------------------------------------
1387// Unified ABI — single struct, version inside
1388// ---------------------------------------------------------------------------
1389
1390/// The unified ABI version.
1391pub const RPI_PLUGIN_ABI_VERSION_UNIFIED: u32 = 4;
1392
1393/// The unversioned symbol for the unified ABI.
1394pub const REGISTER_SYMBOL_UNIFIED: &[u8] = b"rpi_plugin_register\0";
1395
1396/// The host-provided API struct. The first two fields (`abi_version`
1397/// and `struct_size`) are contract-identity markers that survive future
1398/// signature changes: a plugin reads them from the pointer before anything
1399/// else, so even if the entrypoint arity changes, the version check remains
1400/// robust.
1401#[repr(C)]
1402#[derive(Clone, Copy)]
1403pub struct PluginApi {
1404    /// Contract identity. First field so a plugin can validate before reading
1405    /// anything else, independent of the entrypoint arity.
1406    pub abi_version: u32,
1407    /// `sizeof(PluginApi)` as the host built it. Lets a plugin detect a host
1408    /// that predates slots it wants (host_struct_size < plugin's expected
1409    /// offset). Future hosts may grow this struct; old plugins check
1410    /// `struct_size` before accessing new fields.
1411    pub struct_size: u32,
1412
1413    // --- Host API ---
1414    /// Host's `free_string` — the plugin calls this for every [`StbString`] it
1415    /// *receives* from the host (outputs of actions, event payloads, inputs to
1416    /// execute). Never null.
1417    pub free_string: FreeStringFn,
1418
1419    // --- registrars (plugin → host "register X into the host") ---
1420    pub register_tool: Option<
1421        extern "C" fn(
1422            schema: *const StableToolSchema,
1423            execute_fn: ToolExecuteFn,
1424            poll_fn: ToolPollFn,
1425            cancel_fn: ToolCancelFn,
1426            destroy_fn: ToolDestroyFn,
1427            plugin_free_string: FreeStringFn,
1428        ) -> i32,
1429    >,
1430    pub register_command: Option<
1431        extern "C" fn(
1432            name: StbStringRef,
1433            description: StbStringRef,
1434            handler: CommandHandlerFn,
1435        ) -> i32,
1436    >,
1437    pub register_shortcut:
1438        Option<extern "C" fn(key: StbStringRef, description: StbStringRef) -> i32>,
1439    pub register_flag: Option<extern "C" fn(name: StbStringRef, description: StbStringRef) -> i32>,
1440    pub register_provider: Option<
1441        extern "C" fn(
1442            provider_id: StbStringRef,
1443            base_url: StbStringRef,
1444            api_style: StbStringRef,
1445            request_fn: ProviderRequestFn,
1446            plugin_free_string: FreeStringFn,
1447            user_data: *mut c_void,
1448        ) -> i32,
1449    >,
1450    pub register_message_renderer: Option<
1451        extern "C" fn(
1452            name: StbStringRef,
1453            render_fn: RenderFn,
1454            plugin_free_string: FreeStringFn,
1455            user_data: *mut c_void,
1456        ) -> i32,
1457    >,
1458    pub register_markdown_transformer: Option<
1459        extern "C" fn(
1460            name: StbStringRef,
1461            render_fn: RenderFn,
1462            plugin_free_string: FreeStringFn,
1463            user_data: *mut c_void,
1464        ) -> i32,
1465    >,
1466    pub register_entry_renderer: Option<
1467        extern "C" fn(
1468            name: StbStringRef,
1469            render_fn: RenderFn,
1470            plugin_free_string: FreeStringFn,
1471            user_data: *mut c_void,
1472        ) -> i32,
1473    >,
1474    pub register_event_handler: Option<
1475        extern "C" fn(tag: EventTag, handler: EventHandlerFn, user_data: *mut c_void) -> i32,
1476    >,
1477    pub register_resources_discover: Option<
1478        extern "C" fn(
1479            handler: ResourcesDiscoverFn,
1480            plugin_free_string: FreeStringFn,
1481            user_data: *mut c_void,
1482        ) -> i32,
1483    >,
1484    pub runtime_action: RuntimeActionFn,
1485    pub dispatch_event:
1486        Option<extern "C" fn(event: StablePluginEvent, user_data: *mut c_void) -> i32>,
1487    pub user_data: *mut c_void,
1488
1489    // --- declarations ---
1490    /// Declare this plugin's host-side preferences as a JSON object, e.g.
1491    /// `{"priority":60,"platforms":["linux","macos"]}`. Nullable: host does
1492    /// not support declarations yet (plugin skips the call; host applies
1493    /// defaults: priority `100`, all platforms).
1494    pub declare: Option<extern "C" fn(json: StbStringRef) -> i32>,
1495
1496    /// Register a system-prompt transformer for the run (pi's
1497    /// `before_agent_start` returning `systemPrompt`).
1498    ///
1499    /// **Appended last on purpose.** A plugin compiled against a shorter
1500    /// `PluginApi` indexes every earlier field by offset, so a slot inserted in
1501    /// the middle would make an old plugin read the new host's fields at the
1502    /// wrong offsets. Appending keeps every existing offset valid, which is what
1503    /// lets this ship without an ABI version bump; a plugin checks
1504    /// `struct_size` (or the slot for `None`) before using it.
1505    ///
1506    /// Nullable: a host that predates this slot leaves it `None`, the plugin
1507    /// skips the call, and the prompt is left alone — exactly that host's
1508    /// behavior before this slot existed.
1509    pub register_before_agent_start: Option<
1510        extern "C" fn(
1511            handler: BeforeAgentStartFn,
1512            plugin_free_string: FreeStringFn,
1513            user_data: *mut c_void,
1514        ) -> i32,
1515    >,
1516
1517    /// Register a provider-owned OAuth flow. Appended for ABI compatibility;
1518    /// older plugins compiled against a shorter struct remain valid.
1519    pub register_oauth_provider: Option<
1520        extern "C" fn(
1521            manifest_json: StbStringRef,
1522            request_fn: OAuthRequestFn,
1523            plugin_free_string: FreeStringFn,
1524            user_data: *mut c_void,
1525        ) -> i32,
1526    >,
1527
1528    /// Register a **streaming** custom provider (see
1529    /// [`ProviderStreamRequestFn`]). Appended after `register_oauth_provider` so
1530    /// every earlier field keeps its offset — a plugin compiled against a shorter
1531    /// [`PluginApi`] stays valid without an ABI version bump.
1532    ///
1533    /// Nullable: `register_entrypoint` fills slots absent from an older host's
1534    /// vtable with `None`; a plugin that cannot find the slot should fall back to
1535    /// `register_provider` (one-shot) rather than refuse to load.
1536    ///
1537    /// `provider_id`/`base_url`/`api_style` mirror `register_provider` and are
1538    /// copied by the host at registration. A streaming registration for an id
1539    /// that already has a one-shot registration **replaces** it: streaming is a
1540    /// strict superset of the one-shot contract, so a plugin registering both
1541    /// ends up on the richer path.
1542    pub register_streaming_provider: Option<
1543        extern "C" fn(
1544            provider_id: StbStringRef,
1545            base_url: StbStringRef,
1546            api_style: StbStringRef,
1547            request_fn: ProviderStreamRequestFn,
1548            plugin_free_string: FreeStringFn,
1549            user_data: *mut c_void,
1550        ) -> i32,
1551    >,
1552    /// Optional wake-driven tool registration, appended for ABI compatibility.
1553    /// Fall back to `register_tool` when an older host supplies None.
1554    pub register_tool_with_wake: Option<
1555        extern "C" fn(
1556            schema: *const StableToolSchema,
1557            execute_fn: ToolExecuteFn,
1558            poll_fn: ToolPollFn,
1559            cancel_fn: ToolCancelFn,
1560            destroy_fn: ToolDestroyFn,
1561            plugin_free_string: FreeStringFn,
1562            set_waker_fn: ToolSetWakerFn,
1563        ) -> i32,
1564    >,
1565
1566    /// Optional plugin shutdown hooks, appended after wake-driven tools.
1567    pub register_plugin_lifecycle: Option<RegisterPluginLifecycleFn>,
1568}
1569unsafe impl Send for PluginApi {}
1570unsafe impl Sync for PluginApi {}
1571
1572/// Unified ABI plugin entrypoint signature: `(api)`. The version is read from
1573/// `api->abi_version`.
1574pub type RpiPluginRegisterUnified = extern "C" fn(api: *const PluginApi) -> i32;
1575
1576// ===========================================================================
1577// Panic containment
1578// ===========================================================================
1579
1580/// Status code a plugin's register entrypoint returns when its body panicked.
1581///
1582/// A panic must never unwind past the plugin's `extern "C"` entrypoint: Rust
1583/// turns such an unwind into a **process abort** (`panic in a function that
1584/// cannot unwind`), which would take down the whole host. The SDK catches the
1585/// panic and returns this code instead, so the host can skip the plugin with a
1586/// diagnostic.
1587pub const REGISTER_PANIC_STATUS: i32 = 70;
1588
1589/// Run `body`, converting a panic into [`Err`] instead of letting it unwind.
1590///
1591/// Plugin authors must wrap the body of **every** `extern "C"` entrypoint they
1592/// export (tool `execute`/`poll`/`cancel`, event handlers, provider/render
1593/// callbacks, runtime actions) with this (or an equivalent `catch_unwind`).
1594/// Unwinding out of an `extern "C"` function aborts the process.
1595pub fn guard<R>(body: impl FnOnce() -> R) -> Result<R, Box<dyn std::any::Any + Send>> {
1596    std::panic::catch_unwind(std::panic::AssertUnwindSafe(body))
1597}
1598
1599/// Like [`guard`], but returns `fallback` on panic. Handy for entrypoints that
1600/// must return a value rather than a `Result`.
1601pub fn guard_or<R>(fallback: R, body: impl FnOnce() -> R) -> R {
1602    guard(body).unwrap_or(fallback)
1603}
1604
1605/// Export a unified ABI plugin entrypoint under `rpi_plugin_register`.
1606///
1607/// The unified ABI has no version suffix in the symbol name; the version lives
1608/// inside the [`PluginApi`] struct (first field `abi_version`).
1609///
1610/// The expression receives `&PluginApi` and returns the plugin-defined
1611/// registration status code.
1612///
1613/// ```ignore
1614/// rpi_plugin_sdk::export_plugin!(|api| {
1615///     if let Some(declare) = api.declare {
1616///         declare(rpi_plugin_sdk::StbStringRef::from_str(
1617///             r#"{"priority":60,"platforms":["linux"]}"#,
1618///         ));
1619///     }
1620///     // ... register tools and handlers through `api` ...
1621///     0
1622/// });
1623/// ```
1624#[macro_export]
1625macro_rules! export_plugin {
1626    ($body:expr) => {
1627        #[no_mangle]
1628        pub extern "C" fn rpi_plugin_register(api: *const $crate::PluginApi) -> i32 {
1629            // SAFETY: host guarantees `api` is valid for this call.
1630            unsafe { $crate::register_entrypoint(api, $body) }
1631        }
1632    };
1633}
1634
1635/// Unified ABI entrypoint helper: reads `abi_version` and `struct_size` from
1636/// the struct pointer before dereferencing anything else. This survives future
1637/// signature changes because the pointer is always the first argument.
1638///
1639/// # Safety
1640///
1641/// `api` must point to a properly aligned, readable ABI header followed by
1642/// `struct_size` readable bytes of the host's initialized vtable. It may be a
1643/// shorter, older layout of [`PluginApi`]. The mandatory prefix through
1644/// `user_data` must be present, and optional trailing slots must be complete.
1645pub unsafe fn register_entrypoint(
1646    api: *const PluginApi,
1647    body: impl FnOnce(&PluginApi) -> i32,
1648) -> i32 {
1649    if api.is_null() {
1650        return 2;
1651    }
1652    // SAFETY: caller guarantees `api` is valid; we check null above.
1653    // Read the version and size fields first — they're at known offsets
1654    // regardless of future struct growth.
1655    let (abi_version, struct_size) = unsafe { ((*api).abi_version, (*api).struct_size) };
1656    if abi_version != RPI_PLUGIN_ABI_VERSION_UNIFIED {
1657        // Mismatch: refuse to register. The host logs "ABI version mismatch"
1658        // and skips loading this plugin.
1659        return 1;
1660    }
1661    let host_size = struct_size as usize;
1662    let known_size = core::mem::size_of::<PluginApi>();
1663    let required_size = core::mem::offset_of!(PluginApi, declare);
1664    // All trailing fields are nullable function-pointer slots. Do not accept a
1665    // truncated mandatory prefix or a partial function pointer.
1666    if host_size < required_size
1667        || (host_size < known_size
1668            && (host_size - required_size) % core::mem::size_of::<*const ()>() != 0)
1669    {
1670        return 3;
1671    }
1672    let mut normalized = core::mem::MaybeUninit::<PluginApi>::zeroed();
1673    // SAFETY: copy only bytes the host advertises and this SDK knows. The
1674    // mandatory prefix contains every non-null function pointer; all missing
1675    // trailing fields are Option<extern "C" fn>, whose zero representation is
1676    // None. No reference to the larger PluginApi is formed over the old layout.
1677    let api = unsafe {
1678        core::ptr::copy_nonoverlapping(
1679            api.cast::<u8>(),
1680            normalized.as_mut_ptr().cast::<u8>(),
1681            host_size.min(known_size),
1682        );
1683        normalized.assume_init()
1684    };
1685    // Contain panics: unwinding out of the plugin's `extern "C"` shim aborts
1686    // the process, so convert a panic into a status code the host can report.
1687    match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| body(&api))) {
1688        Ok(code) => code,
1689        Err(_) => REGISTER_PANIC_STATUS,
1690    }
1691}
1692
1693// ===========================================================================
1694// Tests (need std + serde_json)
1695// ===========================================================================
1696
1697#[cfg(test)]
1698mod tests {
1699    use super::*;
1700
1701    /// A panicking `register` body must NOT unwind out of the entrypoint shim;
1702    /// it must come back as `REGISTER_PANIC_STATUS` so the host can skip the
1703    /// plugin instead of the process aborting.
1704    #[test]
1705    fn register_entrypoint_contains_panics() {
1706        let mut api = std::mem::MaybeUninit::<PluginApi>::uninit();
1707        let api_ptr = api.as_mut_ptr();
1708        // Set the version/size fields so the entrypoint gets past the checks
1709        // and into the body (which panics).
1710        unsafe {
1711            (*api_ptr).abi_version = RPI_PLUGIN_ABI_VERSION_UNIFIED;
1712            (*api_ptr).struct_size = core::mem::size_of::<PluginApi>() as u32;
1713        }
1714        let rc = unsafe {
1715            register_entrypoint(api_ptr, |_api| {
1716                panic!("plugin register exploded");
1717            })
1718        };
1719        assert_eq!(rc, REGISTER_PANIC_STATUS);
1720    }
1721
1722    #[test]
1723    fn guard_returns_value_and_contains_panic() {
1724        assert_eq!(guard(|| 41 + 1).ok(), Some(42));
1725        assert!(guard(|| -> i32 { panic!("nope") }).is_err());
1726        assert_eq!(guard_or(-1, || -> i32 { panic!("nope") }), -1);
1727    }
1728
1729    // A test allocator + free fn so we can verify the own/free contract
1730    // without a real plugin's free_string.
1731    std::thread_local! {
1732        static FREED: std::cell::Cell<usize> = std::cell::Cell::new(0);
1733    }
1734
1735    unsafe extern "C" fn test_free(s: StbString) {
1736        if s.is_empty() || s.ptr.is_null() {
1737            return;
1738        }
1739        // SAFETY: the test transfers a live SDK allocation exactly once.
1740        unsafe { callbacks::free_string(s) };
1741        FREED.with(|freed| freed.set(freed.get() + 1));
1742    }
1743
1744    fn reset_freed() -> usize {
1745        FREED.with(|freed| freed.replace(0))
1746    }
1747
1748    fn freed_count() -> usize {
1749        FREED.with(std::cell::Cell::get)
1750    }
1751
1752    // A no-op `runtime_action` impl for the vtable-construction tests (closures
1753    // can't coerce to `extern "C" fn`, so we use a real fn).
1754    extern "C" fn noop_runtime_action(
1755        _action_id: u32,
1756        _args: StbStringRef,
1757        _out: *mut StbString,
1758        _user_data: *mut c_void,
1759    ) -> i32 {
1760        0
1761    }
1762
1763    #[test]
1764    fn stbstring_round_trip_and_free_once() {
1765        let prev = reset_freed();
1766        let _ = prev;
1767        let s = StbString::from_string("hello, pi".to_string());
1768        assert_eq!(s.len, 9);
1769        // SAFETY: s owns a live allocation, freed below.
1770        assert_eq!(unsafe { s.to_string_lossy() }, "hello, pi");
1771        // SAFETY: this test transfers its live SDK allocation exactly once.
1772        unsafe { s.free_with(Some(test_free)) };
1773        assert_eq!(freed_count(), 1);
1774    }
1775
1776    #[test]
1777    fn lossy_copy_accepts_non_utf8_and_does_not_free_or_modify_the_buffer() {
1778        let _ = reset_freed();
1779        let s = StbString::from_vec(vec![b'a', 0xff, b'b']);
1780        // SAFETY: s owns three initialized, unchanged bytes until free below.
1781        let first = unsafe { s.to_string_lossy() };
1782        // SAFETY: copying did not consume or free the original allocation.
1783        let second = unsafe { s.to_string_lossy() };
1784        assert_eq!(first, "a\u{fffd}b");
1785        assert_eq!(first, second);
1786        assert_eq!(freed_count(), 0);
1787        // SAFETY: this test transfers its live SDK allocation exactly once.
1788        unsafe { s.free_with(Some(test_free)) };
1789        assert_eq!(freed_count(), 1);
1790        assert_eq!(first, "a\u{fffd}b");
1791    }
1792
1793    #[test]
1794    fn empty_stbstring_free_is_noop() {
1795        let _ = reset_freed();
1796        // SAFETY: this test transfers its live SDK allocation exactly once.
1797        unsafe { StbString::empty().free_with(Some(test_free)) };
1798        assert_eq!(freed_count(), 0);
1799    }
1800
1801    #[cfg(feature = "json")]
1802    #[test]
1803    fn json_round_trip_preserves_structure() {
1804        let val = serde_json::json!({ "name": "echo", "args": [1, 2, 3], "ok": true });
1805        let stb = json::to_stable(&val, None);
1806        // SAFETY: stb owns a live allocation, freed below.
1807        let back = unsafe { json::from_stable(&stb) };
1808        assert_eq!(val, back);
1809        // SAFETY: this test transfers its live SDK allocation exactly once.
1810        unsafe { stb.free_with(Some(test_free)) };
1811        let _ = reset_freed();
1812    }
1813
1814    #[test]
1815    fn step_result_done_round_trip() {
1816        let result_json = StbString::from_string(r#"{"content":[{"text":"hi"}]}"#.to_string());
1817        let sr = StepResult::done(result_json);
1818        assert_eq!(sr.tag, StepResultTag::Done);
1819        // SAFETY: tag == Done.
1820        let done = unsafe { sr.done_payload() };
1821        assert_eq!(
1822            // SAFETY: the initialized Done result is live until test_free below.
1823            unsafe { done.result.to_string_lossy() },
1824            r#"{"content":[{"text":"hi"}]}"#
1825        );
1826        // SAFETY: this test transfers its live SDK allocation exactly once.
1827        unsafe { done.result.free_with(Some(test_free)) };
1828        let _ = reset_freed();
1829    }
1830
1831    #[test]
1832    fn step_result_pending_and_err() {
1833        let prog = StbString::from_string("...".to_string());
1834        let srp = StepResult::pending(prog);
1835        assert_eq!(srp.tag, StepResultTag::Pending);
1836        // SAFETY: tag == Pending.
1837        unsafe {
1838            assert_eq!(srp.pending_payload().progress.to_string_lossy(), "...");
1839        }
1840        unsafe { srp.pending_payload().progress.free_with(Some(test_free)) };
1841
1842        let msg = StbString::from_string("boom".to_string());
1843        let sre = StepResult::err(msg);
1844        assert_eq!(sre.tag, StepResultTag::Err);
1845        // SAFETY: tag == Err.
1846        unsafe {
1847            assert_eq!(sre.err_payload().message.to_string_lossy(), "boom");
1848            sre.err_payload().message.free_with(Some(test_free));
1849        }
1850        let _ = reset_freed();
1851    }
1852
1853    #[test]
1854    fn event_tag_count_is_37() {
1855        // Enumerate every tag; a compile-time + runtime guarantee that the
1856        // 37-category surface is intact (33 Pi on() categories + the
1857        // rpi-specific BeforeTuiStart + 2 UI prompt events + EditorChange).
1858        let tags = [
1859            EventTag::ProjectTrust,
1860            EventTag::ResourcesDiscover,
1861            EventTag::SessionStart,
1862            EventTag::SessionInfoChanged,
1863            EventTag::SessionBeforeSwitch,
1864            EventTag::SessionBeforeFork,
1865            EventTag::SessionBeforeCompact,
1866            EventTag::SessionCompact,
1867            EventTag::SessionShutdown,
1868            EventTag::SessionBeforeTree,
1869            EventTag::SessionTree,
1870            EventTag::Context,
1871            EventTag::BeforeProviderRequest,
1872            EventTag::BeforeProviderHeaders,
1873            EventTag::AfterProviderResponse,
1874            EventTag::BeforeAgentStart,
1875            EventTag::AgentStart,
1876            EventTag::AgentEnd,
1877            EventTag::AgentSettled,
1878            EventTag::TurnStart,
1879            EventTag::TurnEnd,
1880            EventTag::MessageStart,
1881            EventTag::MessageUpdate,
1882            EventTag::MessageEnd,
1883            EventTag::ToolExecutionStart,
1884            EventTag::ToolExecutionUpdate,
1885            EventTag::ToolExecutionEnd,
1886            EventTag::ModelSelect,
1887            EventTag::ThinkingLevelSelect,
1888            EventTag::ToolCall,
1889            EventTag::ToolResult,
1890            EventTag::UserBash,
1891            EventTag::Input,
1892            EventTag::BeforeTuiStart,
1893            EventTag::UiPromptStart,
1894            EventTag::UiPromptEnd,
1895            EventTag::EditorChange,
1896        ];
1897        assert_eq!(tags.len(), EVENT_TAG_COUNT);
1898        assert_eq!(EVENT_TAG_COUNT, 37);
1899        // Distinct discriminants 0..36.
1900        let mut discs: Vec<u32> = tags.iter().map(|t| *t as u32).collect();
1901        discs.sort();
1902        assert_eq!(discs, (0..37).collect::<Vec<u32>>());
1903    }
1904
1905    #[test]
1906    fn event_payloads_construct_and_free() {
1907        let m = StbString::from_string("msg".to_string());
1908        let ev = StablePluginEvent::message(EventTag::MessageEnd, m);
1909        assert_eq!(ev.tag, EventTag::MessageEnd);
1910        // SAFETY: tag == MessageEnd (message variant).
1911        unsafe {
1912            assert_eq!(ev.payload.message.message.to_string_lossy(), "msg");
1913            ev.payload.message.message.free_with(Some(test_free));
1914        }
1915
1916        let tc = StablePluginEvent::tool_call(
1917            EventTag::ToolCall,
1918            StbString::from_string("call_1".to_string()),
1919            StbString::from_string("echo".to_string()),
1920            StbString::from_string("{}".to_string()),
1921        );
1922        // SAFETY: tag == ToolCall (tool_call variant).
1923        unsafe {
1924            assert_eq!(tc.payload.tool_call.tool_name.to_string_lossy(), "echo");
1925            tc.payload.tool_call.tool_call_id.free_with(Some(test_free));
1926            tc.payload.tool_call.tool_name.free_with(Some(test_free));
1927            tc.payload.tool_call.params.free_with(Some(test_free));
1928        }
1929        let _ = reset_freed();
1930    }
1931
1932    #[test]
1933    fn plugin_api_is_pod_and_sized() {
1934        // The API struct is a plain old data struct: every fn pointer is
1935        // non-Drop, the struct has no Drop impl.
1936        let api = PluginApi {
1937            abi_version: RPI_PLUGIN_ABI_VERSION_UNIFIED,
1938            struct_size: core::mem::size_of::<PluginApi>() as u32,
1939            free_string: test_free,
1940            register_tool: None,
1941            register_command: None,
1942            register_shortcut: None,
1943            register_flag: None,
1944            register_provider: None,
1945            register_message_renderer: None,
1946            register_markdown_transformer: None,
1947            register_entry_renderer: None,
1948            register_event_handler: None,
1949            register_resources_discover: None,
1950            runtime_action: noop_runtime_action,
1951            dispatch_event: None,
1952            user_data: core::ptr::null_mut(),
1953            declare: None,
1954            register_before_agent_start: None,
1955            register_oauth_provider: None,
1956            register_streaming_provider: None,
1957            register_tool_with_wake: None,
1958            register_plugin_lifecycle: None,
1959        };
1960        // All optional slots are null → plugin must degrade.
1961        assert!(api.register_tool.is_none());
1962        assert!(api.register_event_handler.is_none());
1963        assert!(api.register_resources_discover.is_none());
1964        assert!(api.register_before_agent_start.is_none());
1965        // Copy (POD) — no UB from a plain copy.
1966        let _copy = api;
1967        assert!(!core::mem::needs_drop::<PluginApi>());
1968        assert!(!core::mem::needs_drop::<StbString>());
1969        assert!(!core::mem::needs_drop::<StepResult>());
1970        assert!(!core::mem::needs_drop::<StablePluginEvent>());
1971        assert!(!core::mem::needs_drop::<StableToolSchema>());
1972        assert!(
1973            !core::mem::needs_drop::<ProviderStreamControl>(),
1974            "the control struct is fn pointers + a u32: it must stay Copy/POD"
1975        );
1976    }
1977
1978    /// New vtable slots must be **appended**, never inserted.
1979    ///
1980    /// A plugin compiled against a shorter `PluginApi` reads every field by
1981    /// offset. Inserting a slot in the middle would make that plugin read the
1982    /// new host's fields at the wrong offsets — silently, since both sides just
1983    /// see pointers. Appending is what lets a slot ship without an ABI version
1984    /// bump. This pins the three fields at the end and the identity markers at
1985    /// the front, so a future edit that inserts in the middle fails here.
1986    #[test]
1987    fn vtable_grows_only_at_the_end() {
1988        use core::mem::{align_of, size_of};
1989
1990        // Identity markers stay first: a plugin reads these before anything
1991        // else, independent of the entrypoint arity.
1992        assert_eq!(core::mem::offset_of!(PluginApi, abi_version), 0);
1993        assert_eq!(
1994            core::mem::offset_of!(PluginApi, struct_size),
1995            size_of::<u32>()
1996        );
1997
1998        // The most recently added slot is last, and the ones before it are
1999        // adjacent — i.e. nothing was wedged in between them.
2000        let declare = core::mem::offset_of!(PluginApi, declare);
2001        let prompt_slot = core::mem::offset_of!(PluginApi, register_before_agent_start);
2002        assert_eq!(
2003            prompt_slot,
2004            declare + size_of::<Option<extern "C" fn(StbStringRef) -> i32>>(),
2005            "register_before_agent_start must be appended right after `declare`; \
2006             inserting a slot earlier shifts every later offset and breaks \
2007             plugins built against the previous layout"
2008        );
2009        let oauth_slot = core::mem::offset_of!(PluginApi, register_oauth_provider);
2010        assert_eq!(
2011            oauth_slot,
2012            prompt_slot
2013                + size_of::<
2014                    Option<extern "C" fn(BeforeAgentStartFn, FreeStringFn, *mut c_void) -> i32>,
2015                >(),
2016            "register_oauth_provider must stay adjacent to register_before_agent_start"
2017        );
2018        let streaming_slot = core::mem::offset_of!(PluginApi, register_streaming_provider);
2019        assert_eq!(
2020            streaming_slot,
2021            oauth_slot
2022                + size_of::<
2023                    Option<
2024                        extern "C" fn(
2025                            StbStringRef,
2026                            OAuthRequestFn,
2027                            FreeStringFn,
2028                            *mut c_void,
2029                        ) -> i32,
2030                    >,
2031                >(),
2032            "register_streaming_provider must be appended after register_oauth_provider \
2033             so an older plugin's every offset stays valid"
2034        );
2035        assert_eq!(
2036            streaming_slot
2037                + size_of::<
2038                    Option<
2039                        extern "C" fn(
2040                            StbStringRef,
2041                            StbStringRef,
2042                            StbStringRef,
2043                            ProviderStreamRequestFn,
2044                            FreeStringFn,
2045                            *mut c_void,
2046                        ) -> i32,
2047                    >,
2048                >(),
2049            core::mem::offset_of!(PluginApi, register_tool_with_wake),
2050            "wake registration must follow streaming registration"
2051        );
2052        assert_eq!(
2053            core::mem::offset_of!(PluginApi, register_plugin_lifecycle),
2054            core::mem::offset_of!(PluginApi, register_tool_with_wake) + size_of::<usize>()
2055        );
2056        assert_eq!(
2057            size_of::<PluginApi>(),
2058            core::mem::offset_of!(PluginApi, register_plugin_lifecycle) + size_of::<usize>()
2059        );
2060        assert_eq!(align_of::<PluginApi>(), align_of::<*const ()>());
2061    }
2062
2063    #[test]
2064    fn runtime_action_ids_are_explicitly_validated() {
2065        let ids = [
2066            RuntimeActionId::SendMessage,
2067            RuntimeActionId::SendUserMessage,
2068            RuntimeActionId::AppendEntry,
2069            RuntimeActionId::SetSessionName,
2070            RuntimeActionId::GetActiveTools,
2071            RuntimeActionId::SetActiveTools,
2072            RuntimeActionId::SetModel,
2073            RuntimeActionId::GetThinkingLevel,
2074            RuntimeActionId::SetThinkingLevel,
2075            RuntimeActionId::Compact,
2076            RuntimeActionId::GetSystemPrompt,
2077            RuntimeActionId::NewSession,
2078            RuntimeActionId::Fork,
2079            RuntimeActionId::NavigateTree,
2080            RuntimeActionId::SwitchSession,
2081            RuntimeActionId::Reload,
2082            RuntimeActionId::GetCliFlag,
2083            RuntimeActionId::UiDialog,
2084            RuntimeActionId::SetStatus,
2085            RuntimeActionId::SetEditorText,
2086            RuntimeActionId::GetSessionBranch,
2087            RuntimeActionId::StdioLifecycle,
2088            RuntimeActionId::UiComponent,
2089            RuntimeActionId::GetExecutionContext,
2090            RuntimeActionId::ChildAgent,
2091            RuntimeActionId::SideQuestion,
2092        ];
2093
2094        for (raw, expected) in ids.into_iter().enumerate() {
2095            assert_eq!(RuntimeActionId::try_from(raw as u32), Ok(expected));
2096            assert_eq!(u32::from(expected), raw as u32);
2097        }
2098        // One past the highest defined id is rejected (nothing is silently
2099        // accepted), and so is the u32 ceiling.
2100        assert_eq!(
2101            RuntimeActionId::try_from(26),
2102            Err(UnknownRuntimeActionId(26))
2103        );
2104        assert_eq!(
2105            RuntimeActionId::try_from(u32::MAX),
2106            Err(UnknownRuntimeActionId(u32::MAX))
2107        );
2108    }
2109
2110    fn entrypoint_test_api() -> PluginApi {
2111        PluginApi {
2112            abi_version: RPI_PLUGIN_ABI_VERSION_UNIFIED,
2113            struct_size: core::mem::size_of::<PluginApi>() as u32,
2114            free_string: test_free,
2115            register_tool: None,
2116            register_command: None,
2117            register_shortcut: None,
2118            register_flag: None,
2119            register_provider: None,
2120            register_message_renderer: None,
2121            register_markdown_transformer: None,
2122            register_entry_renderer: None,
2123            register_event_handler: None,
2124            register_resources_discover: None,
2125            runtime_action: noop_runtime_action,
2126            dispatch_event: None,
2127            user_data: core::ptr::null_mut(),
2128            declare: None,
2129            register_before_agent_start: None,
2130            register_oauth_provider: None,
2131            register_streaming_provider: None,
2132            register_tool_with_wake: None,
2133            register_plugin_lifecycle: None,
2134        }
2135    }
2136
2137    #[test]
2138    fn register_entrypoint_version_mismatch_refuses() {
2139        let api = entrypoint_test_api();
2140        assert_eq!(RPI_PLUGIN_ABI_VERSION_UNIFIED, 4);
2141
2142        // A future version is rejected by the pre-dereference check.
2143        let mut future_api = api;
2144        future_api.abi_version = RPI_PLUGIN_ABI_VERSION_UNIFIED + 1;
2145        let rc = unsafe {
2146            register_entrypoint(&future_api, |_| {
2147                panic!("body must not run on version mismatch");
2148            })
2149        };
2150        assert_eq!(rc, 1);
2151
2152        // Right version → body runs, rc propagated.
2153        let rc = unsafe { register_entrypoint(&api, |_| 0) };
2154        assert_eq!(rc, 0);
2155        let rc = unsafe { register_entrypoint(&api, |_| 42) };
2156        assert_eq!(rc, 42);
2157
2158        // Null api → refuse.
2159        let rc = unsafe { register_entrypoint(core::ptr::null(), |_| 0) };
2160        assert_ne!(rc, 0);
2161        let _ = reset_freed();
2162    }
2163
2164    #[test]
2165    fn register_entrypoint_accepts_short_vtables_without_reading_missing_slots() {
2166        extern "C" fn declare(_: StbStringRef) -> i32 {
2167            7
2168        }
2169        extern "C" fn provider(
2170            _: StbStringRef,
2171            _: StbStringRef,
2172            _: StbStringRef,
2173            _: ProviderRequestFn,
2174            _: FreeStringFn,
2175            _: *mut c_void,
2176        ) -> i32 {
2177            0
2178        }
2179        for size in [
2180            core::mem::offset_of!(PluginApi, declare),
2181            core::mem::offset_of!(PluginApi, register_before_agent_start),
2182            core::mem::offset_of!(PluginApi, register_oauth_provider),
2183            core::mem::offset_of!(PluginApi, register_streaming_provider),
2184            core::mem::offset_of!(PluginApi, register_tool_with_wake),
2185            core::mem::offset_of!(PluginApi, register_plugin_lifecycle),
2186        ] {
2187            let mut api = entrypoint_test_api();
2188            api.struct_size = size as u32;
2189            api.register_provider = Some(provider);
2190            api.declare = Some(declare);
2191            // Allocate exactly the old layout's size, with pointer alignment.
2192            let mut old = vec![0usize; size / core::mem::size_of::<usize>()];
2193            // SAFETY: both buffers are valid for size bytes; the old layout is
2194            // a fully initialized prefix ending at a complete field boundary.
2195            unsafe {
2196                core::ptr::copy_nonoverlapping(
2197                    (&api as *const PluginApi).cast::<u8>(),
2198                    old.as_mut_ptr().cast::<u8>(),
2199                    size,
2200                );
2201            }
2202            let mut called = false;
2203            let rc = unsafe {
2204                register_entrypoint(old.as_ptr().cast(), |api| {
2205                    called = true;
2206                    assert_eq!(api.struct_size, size as u32);
2207                    assert!(api.register_streaming_provider.is_none());
2208                    assert!(api.register_tool_with_wake.is_none());
2209                    assert!(api.register_plugin_lifecycle.is_none());
2210                    assert!(
2211                        api.register_provider.is_some(),
2212                        "one-shot fallback remains available"
2213                    );
2214                    if size > core::mem::offset_of!(PluginApi, declare) {
2215                        assert_eq!((api.declare.unwrap())(StbStringRef::from_str("{}")), 7);
2216                    } else {
2217                        assert!(api.declare.is_none());
2218                    }
2219                    0
2220                })
2221            };
2222            assert_eq!(rc, 0);
2223            assert!(
2224                called,
2225                "the plugin registration body must run on an older host"
2226            );
2227        }
2228    }
2229
2230    #[test]
2231    fn register_entrypoint_rejects_incomplete_mandatory_fields_or_slots() {
2232        for size in [
2233            core::mem::offset_of!(PluginApi, declare) - 1,
2234            core::mem::offset_of!(PluginApi, register_streaming_provider) + 1,
2235        ] {
2236            let mut api = entrypoint_test_api();
2237            api.struct_size = size as u32;
2238            let rc = unsafe { register_entrypoint(&api, |_| panic!("incomplete ABI layout")) };
2239            assert_eq!(rc, 3);
2240        }
2241    }
2242}