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