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