rpi_extensions/registry.rs
1//! The extension registry — accumulates registrations during a plugin's
2//! `rpi_plugin_register` call, then snapshots for the host session.
3//!
4//! `HostApi` (in [`crate`]) holds an `ExtensionRegistry` behind a `Mutex` while
5//! the plugin's register trampolines run; after register completes the host
6//! [`take`]s it and builds a [`RegistrySnapshot`] the session keeps for its
7//! lifetime. Staleness across a session swap / `/reload` is guarded by a shared
8//! `Arc<AtomicBool>` "active" flag ([`assert_active`]).
9//!
10//! [`take`]: crate::HostApi::take_registry
11
12use std::sync::atomic::{AtomicBool, Ordering};
13use std::sync::Arc;
14
15use rpi_agent::error::AgentError;
16use rpi_ai::types::Tool;
17use rpi_plugin_sdk::{
18 BeforeAgentStartFn, CommandHandlerFn, EventHandlerFn, EventTag, FreeStringFn, OAuthRequestFn,
19 ProviderRequestFn, RenderFn, ResourcesDiscoverFn, EVENT_TAG_COUNT,
20};
21
22use crate::tool::PluginToolHandle;
23
24// ---------------------------------------------------------------------------
25// Registered-item records
26// ---------------------------------------------------------------------------
27
28/// A tool an extension registered: the provider-facing [`Tool`] schema + the
29/// plugin's 4-function handle ([`PluginToolHandle`]) the host drives via
30/// [`PluginToolAdapter`](crate::PluginToolAdapter).
31///
32/// `tool` is public so the host can merge schemas; `handle` is crate-private
33/// (only [`PluginToolAdapter`](crate::PluginToolAdapter) drives it). `Clone`
34/// derives because [`Tool`] is `Clone` and [`PluginToolHandle`] is `Copy`.
35#[derive(Clone)]
36pub struct ExtensionTool {
37 /// The provider-facing tool definition (name/description/parameters).
38 pub tool: Tool,
39 pub(crate) handle: PluginToolHandle,
40}
41
42impl ExtensionTool {
43 pub fn new(tool: Tool, handle: PluginToolHandle) -> Self {
44 Self { tool, handle }
45 }
46
47 /// The plugin-side function handles backing this tool. `Copy` (fn pointers
48 /// + a `FreeStringFn`), so handing it out is free. Public so the host
49 /// (e.g. rpi-cli's session builder) can wrap a registered tool in a
50 /// [`PluginToolAdapter`](crate::PluginToolAdapter) outside this crate.
51 pub fn handle(&self) -> PluginToolHandle {
52 self.handle
53 }
54}
55
56/// A registered slash command and its plugin callback.
57#[derive(Clone)]
58pub struct RegisteredCommand {
59 pub name: String,
60 pub description: String,
61 pub handler: CommandHandlerFn,
62 pub user_data: *mut std::ffi::c_void,
63}
64
65/// A keyboard shortcut declared by a native extension (e.g. `"space"` for
66/// push-to-talk). The host records it so the TUI can route keys to it; the
67/// owning plugin receives the key via its `Input` event handler.
68///
69/// `key` is a normalized key name (`"space"`, `"enter"`, `"c"`, `"f1"`)
70/// matching the editor's keybinding vocabulary.
71#[derive(Clone, Debug, PartialEq, Eq)]
72pub struct RegisteredShortcut {
73 pub key: String,
74 pub description: String,
75 /// The owning extension's display name (host-side only, for diagnostics).
76 pub plugin: String,
77}
78
79// SAFETY: the plugin owns the callback/context and promises they remain valid
80// for the loaded library lifetime, matching the other registered callbacks.
81unsafe impl Send for RegisteredCommand {}
82unsafe impl Sync for RegisteredCommand {}
83
84/// A CLI flag declared by a native extension. Values are supplied by the
85/// host's parsed `Args::unknown_flags` map and read by the plugin through the
86/// `RuntimeActionId::GetCliFlag` action.
87#[derive(Clone, Debug, PartialEq, Eq)]
88pub struct RegisteredFlag {
89 pub name: String,
90 pub description: String,
91}
92
93/// A registered `on(tag)` event handler. `user_data` is the plugin's opaque
94/// context, passed back unchanged on every dispatch. `plugin` is the display
95/// name of the owning extension (host-side only — stamped from the cdylib path
96/// at registration; not part of the ABI), used for lifecycle veto diagnostics
97/// ("extension `foo` aborted `before_tui_start`").
98///
99/// `priority`/`platforms` are the owning plugin's **declaration** (P2): the
100/// snapshot sorts handlers by `priority` (smaller first, stable), and dispatch
101/// skips a handler whose non-empty `platforms` excludes the host platform.
102/// Both default to `100` / empty (all platforms); a plugin sets them via the
103/// unified C ABI `declare` callback.
104///
105/// SAFETY contract: the plugin guarantees `handler` is safe to call from any
106/// thread (the host dispatches from the async emitter thread) and `user_data`
107/// is valid for the registry's lifetime. The host never frees `user_data`
108/// (plugin-owned).
109#[derive(Clone)]
110pub struct RegisteredHandler {
111 /// Display name of the owning extension (from the cdylib file stem).
112 pub plugin: String,
113 /// Dispatch order (smaller runs first). Default `100`.
114 pub priority: i32,
115 /// Platforms this handler's plugin supports (empty = all). Matched against
116 /// [`current_platform`].
117 pub platforms: Vec<String>,
118 pub handler: EventHandlerFn,
119 pub user_data: *mut std::ffi::c_void,
120}
121// SAFETY: fn pointers are Send+Sync; `user_data` is an opaque plugin pointer the
122// plugin warrants is thread-safe to pass to `handler` from any thread. The host
123// only reads it through `handler`.
124unsafe impl Send for RegisteredHandler {}
125unsafe impl Sync for RegisteredHandler {}
126
127/// A registered `resources_discover` handler (B5b). The `out` [`StbString`] the
128/// handler produces is **plugin-owned**, so the host reclaims it via the
129/// plugin's own `plugin_free_string` traveled alongside. `user_data` is the
130/// plugin's opaque context. SAFETY: same as [`RegisteredHandler`] — the plugin
131/// warrants `handler` is callable from any thread and `user_data` is valid for
132/// the registry's lifetime; the host never frees `user_data`.
133#[derive(Clone, Copy)]
134pub struct ResourcesDiscoverHandler {
135 pub handler: ResourcesDiscoverFn,
136 pub plugin_free_string: FreeStringFn,
137 pub user_data: *mut std::ffi::c_void,
138}
139// SAFETY: fn pointers + an opaque plugin pointer the plugin warrants is
140// thread-safe; the host only reads `user_data` through `handler`.
141unsafe impl Send for ResourcesDiscoverHandler {}
142unsafe impl Sync for ResourcesDiscoverHandler {}
143
144/// A registered `before_agent_start` handler (system-prompt transform). Same
145/// ownership shape as [`ResourcesDiscoverHandler`]: the handler produces a
146/// **plugin-owned** `out` [`StbString`] the host reclaims via the plugin's own
147/// `plugin_free_string`, which therefore travels alongside. `user_data` is the
148/// plugin's opaque context.
149///
150/// SAFETY: same as [`RegisteredHandler`] — the plugin warrants `handler` is
151/// callable from any thread and `user_data` is valid for the registry's
152/// lifetime; the host never frees `user_data`.
153#[derive(Clone, Copy)]
154pub struct BeforeAgentStartHandler {
155 pub handler: BeforeAgentStartFn,
156 pub plugin_free_string: FreeStringFn,
157 pub user_data: *mut std::ffi::c_void,
158}
159// SAFETY: fn pointers + an opaque plugin pointer the plugin warrants is
160// thread-safe; the host only reads `user_data` through `handler`.
161unsafe impl Send for BeforeAgentStartHandler {}
162unsafe impl Sync for BeforeAgentStartHandler {}
163
164/// A registered custom provider (B5c). The host wraps `request_fn` in a
165/// [`PluggableProvider`](crate::PluggableProvider) impl of `rpi_ai::Provider`;
166/// its `stream_simple` drives `request_fn` on `spawn_blocking` (the sync fn
167/// can't own a chunked stream), reads the **plugin-owned** `out` JSON (a full
168/// assistant message), reclaims it via `plugin_free_string`, parses it to an
169/// [`AssistantMessage`](rpi_ai::types::AssistantMessage), and emits it as one
170/// terminal `Done` chunk (v1 one-shot, documented divergence from pi's async
171/// streaming). `provider_id`/`base_url`/`api_style` carry the provider's
172/// identity (copied from the borrowed `StbStringRef`s at registration); the fn
173/// pointers + `user_data` live as long as the plugin (keepalive-mapped).
174/// `user_data` is the plugin's opaque context, passed back on every
175/// `request_fn` call.
176///
177/// SAFETY: the plugin warrants `request_fn` is callable from any thread (the
178/// host calls it from a `spawn_blocking` pool thread) and `user_data` is valid
179/// for the plugin's lifetime; the host never frees `user_data`.
180#[derive(Clone)]
181pub struct RegisteredProvider {
182 pub provider_id: String,
183 pub base_url: String,
184 pub api_style: String,
185 pub request_fn: ProviderRequestFn,
186 pub plugin_free_string: FreeStringFn,
187 pub user_data: *mut std::ffi::c_void,
188}
189
190/// A provider-owned OAuth implementation. `manifest_json` is validated JSON
191/// copied from the plugin; the callback receives action envelopes such as
192/// `{\"action\":\"begin\"}` and returns plugin-owned JSON.
193#[derive(Clone)]
194pub struct RegisteredOAuthProvider {
195 /// The owning extension display name, used to scope credentials and
196 /// diagnostics. This is host-owned metadata and is not supplied by the
197 /// plugin.
198 pub plugin: String,
199 pub manifest_json: String,
200 pub request_fn: OAuthRequestFn,
201 pub plugin_free_string: FreeStringFn,
202 pub user_data: *mut std::ffi::c_void,
203}
204unsafe impl Send for RegisteredOAuthProvider {}
205unsafe impl Sync for RegisteredOAuthProvider {}
206// SAFETY: fn pointers + owned `String`s + an opaque plugin pointer the plugin
207// warrants is thread-safe; the host never frees `user_data`.
208unsafe impl Send for RegisteredProvider {}
209unsafe impl Sync for RegisteredProvider {}
210
211/// A registered message/markdown/entry renderer (B5c). The interactive TUI
212/// consumes all three kinds through the JSON component adapter. `render_fn` produces a
213/// **plugin-owned** `out` [`StbString`] the host reclaims via
214/// `plugin_free_string`; `user_data` is passed back on every render call. `name`
215/// is copied from the borrowed `StbStringRef` at registration.
216///
217/// SAFETY: same as [`RegisteredProvider`].
218#[derive(Clone)]
219pub struct RegisteredRenderer {
220 pub name: String,
221 pub kind: RegisteredRendererKind,
222 pub render_fn: RenderFn,
223 pub plugin_free_string: FreeStringFn,
224 pub user_data: *mut std::ffi::c_void,
225}
226
227/// Which render path this renderer targets — mirrors the three distinct
228/// `register_*` slots (`register_message_renderer` /
229/// `register_markdown_transformer` / `register_entry_renderer`).
230#[derive(Clone, Copy, PartialEq, Eq)]
231pub enum RegisteredRendererKind {
232 Message,
233 Markdown,
234 Entry,
235}
236// SAFETY: same as [`RegisteredProvider`] — fn pointers + owned `String` + an
237// opaque plugin pointer the plugin warrants is thread-safe.
238unsafe impl Send for RegisteredRenderer {}
239unsafe impl Sync for RegisteredRenderer {}
240
241/// One flat registration record, for iteration/diagnostics. Built on demand
242/// from the typed vecs in [`RegistrySnapshot`].
243#[derive(Clone)]
244pub enum RegistryEntry {
245 Tool(ExtensionTool),
246 Command(RegisteredCommand),
247 Flag(RegisteredFlag),
248}
249
250// ---------------------------------------------------------------------------
251// P2 — plugin declarations (priority + platforms)
252// ---------------------------------------------------------------------------
253
254/// Default dispatch priority for a plugin that declares none. Handlers run
255/// smallest-priority-first; ties break by registration order.
256pub const DEFAULT_PRIORITY: i32 = 100;
257
258/// The host platform, as matched against a plugin's declared `platforms`.
259/// Platform set used by extension capability declarations.
260pub fn current_platform() -> &'static str {
261 if cfg!(target_os = "android") {
262 "android"
263 } else if cfg!(target_os = "linux") {
264 // Termux reports `target_os = "android"` above; a plain Linux is here.
265 "linux"
266 } else if cfg!(target_os = "macos") {
267 "macos"
268 } else if cfg!(target_os = "windows") {
269 "windows"
270 } else {
271 "unknown"
272 }
273}
274
275/// Whether a handler with the given declared `platforms` should run on this
276/// host. Empty `platforms` = all platforms; a non-empty list requires the host
277/// platform to be present (`"all"` is accepted as a wildcard).
278pub fn platform_allows(platforms: &[String]) -> bool {
279 if platforms.is_empty() {
280 return true;
281 }
282 let here = current_platform();
283 platforms
284 .iter()
285 .any(|p| p.eq_ignore_ascii_case("all") || p.eq_ignore_ascii_case(here))
286}
287
288// ---------------------------------------------------------------------------
289// ExtensionRegistry — accumulated during register, then snapshotted
290// ---------------------------------------------------------------------------
291
292/// Accumulates registrations from one or more plugins' `rpi_plugin_register`
293/// calls. Held by [`HostApi`](crate::HostApi) during register; the host then
294/// [`take_registry`](crate::HostApi::take_registry) and builds a snapshot.
295///
296/// Merge semantics mirror pi: **first-registration-wins** on tool/command name
297/// collision (a later registration for an existing name is dropped, not an
298/// overwrite). Event handlers accumulate (multiple per tag — fan-out).
299pub struct ExtensionRegistry {
300 tools: Vec<ExtensionTool>,
301 commands: Vec<RegisteredCommand>,
302 flags: Vec<RegisteredFlag>,
303 /// Keyboard shortcuts declared by plugins (first-wins by `key`). Recorded
304 /// so the TUI can route a key press/release to the owning plugin's `Input`
305 /// event handler instead of consuming it as editor text.
306 shortcuts: Vec<RegisteredShortcut>,
307 /// `handlers[tag as usize]` — all handlers subscribed to that tag.
308 handlers: [Vec<RegisteredHandler>; EVENT_TAG_COUNT],
309 /// `resources_discover` handlers (B5b). Fan-out on discovery, in registration
310 /// order. Unlike event handlers (which key off a tag in a fixed array), these
311 /// are a single flat list — `resources_discover` has its own out-param
312 /// signature and is never dispatched through the fire-and-forget event path.
313 resources_discover: Vec<ResourcesDiscoverHandler>,
314 /// `before_agent_start` system-prompt transformers. A flat list for the same
315 /// reason as `resources_discover`: this is an out-param handler (the plugin
316 /// returns the prompt to install), not a fire-and-forget event subscription,
317 /// so it is not indexed by a tag. Chained in registration order.
318 before_agent_start: Vec<BeforeAgentStartHandler>,
319 /// Registered custom providers (B5c). The host wraps each in a
320 /// [`PluggableProvider`](crate::PluggableProvider). First-registration-wins on
321 /// `provider_id`.
322 providers: Vec<RegisteredProvider>,
323 /// Provider-owned OAuth implementations. First-registration-wins on the
324 /// manifest `id` field.
325 oauth_providers: Vec<RegisteredOAuthProvider>,
326 /// Registered renderers (B5c), split by kind at registration. First-wins on
327 /// `(kind, name)`. The host records these now; TUI consumption is B5e.
328 renderers: Vec<RegisteredRenderer>,
329 /// P2: the registering plugin's declared dispatch priority. Stamped onto
330 /// every handler it registers; a later `declare` also retro-updates them.
331 priority: i32,
332 /// P2: the registering plugin's declared platforms (empty = all).
333 platforms: Vec<String>,
334 /// A plugin's `{"ownsStdio":true}` claim: this host process's stdin/stdout
335 /// belong to the plugin (an ACP/MCP-style stdio server), so the host must
336 /// defer `read_piped_stdin` until after extension load and run the headless
337 /// `Rpc` lifecycle instead of Print/TUI. Monotonic across `absorb` (any
338 /// claiming plugin projects `true`). The scoped ActionBridge arbitrates
339 /// exclusivity before registries are merged. Default `false`.
340 owns_stdio: bool,
341 /// Shared staleness flag. `true` while the session owning this registry is
342 /// active; set `false` on swap/`/reload`. Tool/event dispatch checks it.
343 active: Arc<AtomicBool>,
344}
345
346impl Default for ExtensionRegistry {
347 fn default() -> Self {
348 Self::new()
349 }
350}
351
352impl ExtensionRegistry {
353 /// Build an empty, active registry.
354 pub fn new() -> Self {
355 let handlers: [Vec<RegisteredHandler>; EVENT_TAG_COUNT] =
356 std::array::from_fn(|_| Vec::new());
357 Self {
358 tools: Vec::new(),
359 commands: Vec::new(),
360 flags: Vec::new(),
361 shortcuts: Vec::new(),
362 handlers,
363 resources_discover: Vec::new(),
364 before_agent_start: Vec::new(),
365 providers: Vec::new(),
366 oauth_providers: Vec::new(),
367 renderers: Vec::new(),
368 priority: DEFAULT_PRIORITY,
369 platforms: Vec::new(),
370 owns_stdio: false,
371 active: Arc::new(AtomicBool::new(true)),
372 }
373 }
374
375 /// Register a tool. Returns `true` if a **prior** tool of the same name was
376 /// kept (first-wins: the new one is dropped). Returns `false` on insert.
377 pub fn register_tool(&mut self, tool: Tool, handle: PluginToolHandle) -> bool {
378 if self.tools.iter().any(|t| t.tool.name == tool.name) {
379 // First-wins: keep the prior, drop the new handle. We must destroy
380 // the dropped handle's allocation? No — `handle` is just fn pointers
381 // + a FreeStringFn (all Copy, no allocation). Dropping it is a no-op.
382 // A real per-tool plugin allocation only exists after `execute`
383 // produces a StepHandle; we never call execute for a dropped
384 // registration, so nothing to free.
385 return true;
386 }
387 self.tools.push(ExtensionTool::new(tool, handle));
388 false
389 }
390
391 /// Register a slash command (first-wins by name). `true` if a prior was kept.
392 pub fn register_command(
393 &mut self,
394 name: String,
395 description: String,
396 handler: CommandHandlerFn,
397 user_data: *mut std::ffi::c_void,
398 ) -> bool {
399 if self.commands.iter().any(|c| c.name == name) {
400 return true;
401 }
402 self.commands.push(RegisteredCommand {
403 name,
404 description,
405 handler,
406 user_data,
407 });
408 false
409 }
410
411 /// Register a CLI flag (first-wins by name). Returns `true` when an
412 /// earlier extension already declared the same flag.
413 pub fn register_flag(&mut self, name: String, description: String) -> bool {
414 if self.flags.iter().any(|flag| flag.name == name) {
415 return true;
416 }
417 self.flags.push(RegisteredFlag { name, description });
418 false
419 }
420
421 /// Declare a keyboard shortcut (first-wins by `key`). Returns `true` when an
422 /// earlier extension already claimed the same key.
423 ///
424 /// Recording a shortcut does **not** bind the key to a callback: the owning
425 /// plugin receives presses/releases through its `Input` event handler and
426 /// decides what to do. The host uses this list only to know which keys are
427 /// claimed, so it can route them to extensions instead of the editor.
428 pub fn register_shortcut(&mut self, plugin: String, key: String, description: String) -> bool {
429 if self.shortcuts.iter().any(|s| s.key == key) {
430 return true;
431 }
432 self.shortcuts.push(RegisteredShortcut {
433 key,
434 description,
435 plugin,
436 });
437 false
438 }
439
440 /// Subscribe a handler to `tag`. Multiple handlers per tag are kept
441 /// (fan-out on dispatch). Always inserts; returns `false`. `plugin` is the
442 /// owning extension's display name (host-side only, for diagnostics).
443 pub fn register_event_handler(
444 &mut self,
445 plugin: String,
446 tag: EventTag,
447 handler: EventHandlerFn,
448 user_data: *mut std::ffi::c_void,
449 ) -> bool {
450 let idx = tag as usize;
451 if idx < EVENT_TAG_COUNT {
452 // Stamp the registering plugin's current declaration (P2). A plugin
453 // typically declares BEFORE registering handlers; `apply_declaration`
454 // also retro-updates any handler registered before the declare call.
455 self.handlers[idx].push(RegisteredHandler {
456 plugin,
457 priority: self.priority,
458 platforms: self.platforms.clone(),
459 handler,
460 user_data,
461 });
462 }
463 false
464 }
465
466 /// Apply a plugin's declaration parsed from the unified C ABI `declare` JSON
467 /// (`{"priority":60,"platforms":["linux","macos"]}`). Unknown keys are
468 /// ignored; malformed values leave the corresponding field unchanged.
469 /// Retro-updates any handlers already registered (so declare order doesn't
470 /// matter). Returns the number of fields applied.
471 pub fn apply_declaration(
472 &mut self,
473 priority: Option<i32>,
474 platforms: Option<Vec<String>>,
475 ) -> usize {
476 let mut applied = 0;
477 if let Some(priority) = priority {
478 self.priority = priority;
479 for tag_handlers in self.handlers.iter_mut() {
480 for h in tag_handlers.iter_mut() {
481 h.priority = priority;
482 }
483 }
484 applied += 1;
485 }
486 if let Some(platforms) = platforms {
487 self.platforms = platforms.clone();
488 for tag_handlers in self.handlers.iter_mut() {
489 for h in tag_handlers.iter_mut() {
490 h.platforms = platforms.clone();
491 }
492 }
493 applied += 1;
494 }
495 applied
496 }
497
498 /// Parse a v3 `declare` JSON payload and apply it (P2). Recognised keys:
499 /// `priority` (integer), `platforms` (array of strings) and `ownsStdio`
500 /// (boolean; see [`ExtensionRegistry::declared_owns_stdio`]). Returns `true`
501 /// if the payload parsed as JSON (even with no recognised keys), `false` on
502 /// a parse error.
503 pub fn apply_declaration_json(&mut self, json: &str) -> bool {
504 let Ok(value) = serde_json::from_str::<serde_json::Value>(json) else {
505 return false;
506 };
507 let priority = value
508 .get("priority")
509 .and_then(|v| v.as_i64())
510 .map(|v| v as i32);
511 let platforms = value
512 .get("platforms")
513 .and_then(|v| v.as_array())
514 .map(|arr| {
515 arr.iter()
516 .filter_map(|v| v.as_str().map(|s| s.to_string()))
517 .collect::<Vec<String>>()
518 });
519 if let Some(owns) = value.get("ownsStdio").and_then(|v| v.as_bool()) {
520 // A claim, not a toggle: once a plugin owns stdio it stays claimed
521 // (and `absorb` ORs it across plugins).
522 self.owns_stdio = self.owns_stdio || owns;
523 }
524 self.apply_declaration(priority, platforms);
525 true
526 }
527
528 /// Register a `resources_discover` handler (B5b). Multiple handlers are kept
529 /// (fan-out on discovery, registration order). Always inserts; returns `false`.
530 pub fn register_resources_discover(
531 &mut self,
532 handler: ResourcesDiscoverFn,
533 plugin_free_string: FreeStringFn,
534 user_data: *mut std::ffi::c_void,
535 ) -> bool {
536 self.resources_discover.push(ResourcesDiscoverHandler {
537 handler,
538 plugin_free_string,
539 user_data,
540 });
541 false
542 }
543
544 /// Register a `before_agent_start` system-prompt transformer. Multiple
545 /// handlers are kept (chained in registration order: each sees the prompt the
546 /// previous one returned, mirroring pi `runner.ts:1329-1347`). Always
547 /// inserts; returns `false`.
548 pub fn register_before_agent_start(
549 &mut self,
550 handler: BeforeAgentStartFn,
551 plugin_free_string: FreeStringFn,
552 user_data: *mut std::ffi::c_void,
553 ) -> bool {
554 self.before_agent_start.push(BeforeAgentStartHandler {
555 handler,
556 plugin_free_string,
557 user_data,
558 });
559 false
560 }
561
562 /// Register a custom provider (B5c). First-registration-wins on `provider_id`
563 /// (a later registration for an existing id is dropped, mirroring tool/command
564 /// first-wins). Returns `true` if a prior provider of the same id was kept.
565 pub fn register_provider(&mut self, provider: RegisteredProvider) -> bool {
566 if self
567 .providers
568 .iter()
569 .any(|p| p.provider_id == provider.provider_id)
570 {
571 return true;
572 }
573 self.providers.push(provider);
574 false
575 }
576
577 /// Register a provider-owned OAuth implementation. The manifest must be a
578 /// JSON object with a non-empty string `id`; malformed manifests are
579 /// rejected. Returns `true` when an existing provider won.
580 pub fn register_oauth_provider(&mut self, provider: RegisteredOAuthProvider) -> bool {
581 let Ok(manifest) = serde_json::from_str::<serde_json::Value>(&provider.manifest_json)
582 else {
583 return true;
584 };
585 let Some(id) = manifest.get("id").and_then(serde_json::Value::as_str) else {
586 return true;
587 };
588 if id.trim().is_empty()
589 || self.oauth_providers.iter().any(|p| {
590 serde_json::from_str::<serde_json::Value>(&p.manifest_json)
591 .ok()
592 .and_then(|v| {
593 v.get("id")
594 .and_then(serde_json::Value::as_str)
595 .map(str::to_owned)
596 })
597 .as_deref()
598 == Some(id)
599 })
600 {
601 return true;
602 }
603 self.oauth_providers.push(provider);
604 false
605 }
606
607 /// Register a renderer (B5c — message/markdown/entry). First-wins on
608 /// `(kind, name)`. Returns `true` if a prior renderer of the same kind+name
609 /// was kept.
610 pub fn register_renderer(&mut self, renderer: RegisteredRenderer) -> bool {
611 if self
612 .renderers
613 .iter()
614 .any(|r| r.kind == renderer.kind && r.name == renderer.name)
615 {
616 return true;
617 }
618 self.renderers.push(renderer);
619 false
620 }
621
622 /// Build a snapshot the host session keeps. The registry's `active` flag is
623 /// shared (Arc) so a later `invalidate` on the registry also invalidates the
624 /// snapshot — important for cross-session staleness.
625 pub fn snapshot(&self) -> RegistrySnapshot {
626 // P2: dispatch order is priority-first (smaller first). `sort_by_key` is
627 // stable, so equal priorities keep registration order (the prior
628 // contract). Sorting per tag here means every snapshot — including the
629 // merged session snapshot built via `absorb` — is pre-ordered.
630 let mut handlers = self.handlers.clone();
631 for tag_handlers in handlers.iter_mut() {
632 tag_handlers.sort_by_key(|h| h.priority);
633 }
634 RegistrySnapshot {
635 tools: self
636 .tools
637 .iter()
638 .map(|t| ExtensionTool {
639 tool: t.tool.clone(),
640 handle: t.handle,
641 })
642 .collect(),
643 commands: self.commands.clone(),
644 flags: self.flags.clone(),
645 shortcuts: self.shortcuts.clone(),
646 handlers,
647 resources_discover: self.resources_discover.clone(),
648 before_agent_start: self.before_agent_start.clone(),
649 providers: self.providers.clone(),
650 oauth_providers: self.oauth_providers.clone(),
651 renderers: self.renderers.clone(),
652 owns_stdio: self.owns_stdio,
653 active: Arc::clone(&self.active),
654 }
655 }
656
657 /// Mark this registry (and any snapshot sharing its flag) as stale.
658 pub fn invalidate(&self) {
659 self.active.store(false, Ordering::SeqCst);
660 }
661
662 /// Whether this registry is still active (not invalidated).
663 pub fn is_active(&self) -> bool {
664 self.active.load(Ordering::SeqCst)
665 }
666
667 /// P2: the plugin's declared dispatch priority (default [`DEFAULT_PRIORITY`]).
668 pub fn declared_priority(&self) -> i32 {
669 self.priority
670 }
671
672 /// P2: the plugin's declared platforms (empty = all).
673 pub fn declared_platforms(&self) -> &[String] {
674 &self.platforms
675 }
676
677 /// Whether this plugin claimed ownership of the host process's stdio
678 /// (`declare {"ownsStdio":true}`). Default `false`.
679 pub fn declared_owns_stdio(&self) -> bool {
680 self.owns_stdio
681 }
682
683 /// Absorb another registry's registrations into this one, first-wins on
684 /// name (tools/commands) and appending (event handlers). Used by
685 /// [`load_dir`](crate::loader::load_dir)/`merge_registries` to fold
686 /// per-plugin registries into one session registry in load order. Consumes
687 /// `other` (its `active` flag is discarded — the session registry's flag
688 /// wins, since it owns session lifetime).
689 pub(crate) fn absorb(&mut self, mut other: ExtensionRegistry) {
690 for t in other.tools.drain(..) {
691 if self.tools.iter().any(|x| x.tool.name == t.tool.name) {
692 // first-wins: keep the prior, discard the new. Nothing to free
693 // (handle is fn pointers). Emit no diagnostic here — the loader
694 // may surface collisions if desired; v1 silent first-wins.
695 continue;
696 }
697 self.tools.push(t);
698 }
699 for c in other.commands.drain(..) {
700 if self.commands.iter().any(|x| x.name == c.name) {
701 continue;
702 }
703 self.commands.push(c);
704 }
705 for flag in other.flags.drain(..) {
706 if self.flags.iter().any(|existing| existing.name == flag.name) {
707 continue;
708 }
709 self.flags.push(flag);
710 }
711 for shortcut in other.shortcuts.drain(..) {
712 if self
713 .shortcuts
714 .iter()
715 .any(|existing| existing.key == shortcut.key)
716 {
717 continue;
718 }
719 self.shortcuts.push(shortcut);
720 }
721 for (tag_idx, handlers) in other.handlers.iter_mut().enumerate() {
722 self.handlers[tag_idx].append(handlers);
723 }
724 self.resources_discover
725 .append(&mut other.resources_discover);
726 self.before_agent_start
727 .append(&mut other.before_agent_start);
728 for p in other.providers.drain(..) {
729 if self
730 .providers
731 .iter()
732 .any(|x| x.provider_id == p.provider_id)
733 {
734 continue;
735 }
736 self.providers.push(p);
737 }
738 for oauth in other.oauth_providers.drain(..) {
739 if self.oauth_providers.iter().any(|existing| {
740 let existing_id =
741 serde_json::from_str::<serde_json::Value>(&existing.manifest_json)
742 .ok()
743 .and_then(|value| {
744 value
745 .get("id")
746 .and_then(serde_json::Value::as_str)
747 .map(str::to_owned)
748 });
749 let oauth_id = serde_json::from_str::<serde_json::Value>(&oauth.manifest_json)
750 .ok()
751 .and_then(|value| {
752 value
753 .get("id")
754 .and_then(serde_json::Value::as_str)
755 .map(str::to_owned)
756 });
757 existing_id.is_some() && existing_id == oauth_id
758 }) {
759 continue;
760 }
761 self.oauth_providers.push(oauth);
762 }
763 for r in other.renderers.drain(..) {
764 if self
765 .renderers
766 .iter()
767 .any(|x| x.kind == r.kind && x.name == r.name)
768 {
769 continue;
770 }
771 self.renderers.push(r);
772 }
773 // stdio ownership is a claim: any claiming plugin makes the merged
774 // session own stdio. The live bridge has already checked the exclusive
775 // owner; this flag projects that claim into the immutable snapshot.
776 self.owns_stdio |= other.owns_stdio;
777 }
778}
779
780// ---------------------------------------------------------------------------
781// RegistrySnapshot — the host session's immutable view
782// ---------------------------------------------------------------------------
783
784/// An immutable snapshot of an [`ExtensionRegistry`] the host session keeps for
785/// its lifetime. Tools are wrapped in [`ExtensionTool`] so the host can build
786/// [`PluginToolAdapter`](crate::PluginToolAdapter)s; event handlers are grouped
787/// by tag for the [`ExtensionEmitter`](crate::ExtensionEmitter) to fan out.
788///
789/// The `active` flag is shared with the source registry, so invalidating the
790/// registry (on session swap) invalidates this snapshot too.
791pub struct RegistrySnapshot {
792 tools: Vec<ExtensionTool>,
793 commands: Vec<RegisteredCommand>,
794 flags: Vec<RegisteredFlag>,
795 shortcuts: Vec<RegisteredShortcut>,
796 handlers: [Vec<RegisteredHandler>; EVENT_TAG_COUNT],
797 resources_discover: Vec<ResourcesDiscoverHandler>,
798 before_agent_start: Vec<BeforeAgentStartHandler>,
799 providers: Vec<RegisteredProvider>,
800 oauth_providers: Vec<RegisteredOAuthProvider>,
801 renderers: Vec<RegisteredRenderer>,
802 /// A plugin's `{"ownsStdio":true}` claim (merged OR across plugins). The
803 /// host reads this after `load_session` to defer `read_piped_stdin` and run
804 /// the headless `Rpc` lifecycle. See [`ExtensionRegistry::declared_owns_stdio`].
805 owns_stdio: bool,
806 active: Arc<AtomicBool>,
807}
808
809impl RegistrySnapshot {
810 /// The extension tools (schema + handle), insertion order.
811 pub fn tools(&self) -> &[ExtensionTool] {
812 &self.tools
813 }
814
815 /// Consume the snapshot into the owned tool list (for building adapters).
816 pub fn into_tools(self) -> Vec<ExtensionTool> {
817 self.tools
818 }
819
820 /// The registered slash commands.
821 pub fn commands(&self) -> &[RegisteredCommand] {
822 &self.commands
823 }
824
825 /// The registered CLI flags, insertion order.
826 pub fn flags(&self) -> &[RegisteredFlag] {
827 &self.flags
828 }
829
830 /// Whether any loaded plugin claimed ownership of the host process's stdio
831 /// (`declare {"ownsStdio":true}`). The host uses this to defer
832 /// `read_piped_stdin` and run the headless `Rpc` lifecycle.
833 pub fn owns_stdio(&self) -> bool {
834 self.owns_stdio
835 }
836
837 /// Keyboard shortcuts declared by extensions, insertion order.
838 pub fn shortcuts(&self) -> &[RegisteredShortcut] {
839 &self.shortcuts
840 }
841
842 /// Whether any extension has claimed `key` as a shortcut. Used by the TUI
843 /// to route that key to extension `Input` events instead of the editor.
844 pub fn has_shortcut(&self, key: &str) -> bool {
845 self.shortcuts.iter().any(|s| s.key == key)
846 }
847
848 /// Handlers subscribed to `tag` (empty slice if none).
849 pub fn handlers_for(&self, tag: EventTag) -> &[RegisteredHandler] {
850 let idx = tag as usize;
851 if idx < EVENT_TAG_COUNT {
852 &self.handlers[idx]
853 } else {
854 &[]
855 }
856 }
857
858 /// The `resources_discover` handlers, registration order (B5b). Empty when
859 /// no plugin registered a discovery handler.
860 pub fn resources_discover(&self) -> &[ResourcesDiscoverHandler] {
861 &self.resources_discover
862 }
863
864 /// The `before_agent_start` system-prompt transformers, registration order.
865 /// Empty when no plugin asked to transform the prompt — the common case, and
866 /// the one callers treat as "leave the prompt alone".
867 pub fn before_agent_start(&self) -> &[BeforeAgentStartHandler] {
868 &self.before_agent_start
869 }
870
871 /// The registered custom providers (B5c), registration order. The host wraps
872 /// each in a [`PluggableProvider`](crate::PluggableProvider). Empty when no
873 /// plugin registered a provider.
874 pub fn providers(&self) -> &[RegisteredProvider] {
875 &self.providers
876 }
877
878 /// All registered renderers (B5c), registration order, across all three
879 /// kinds.
880 pub fn renderers(&self) -> &[RegisteredRenderer] {
881 &self.renderers
882 }
883
884 /// OAuth providers registered by extensions, in registration order.
885 pub fn oauth_providers(&self) -> &[RegisteredOAuthProvider] {
886 &self.oauth_providers
887 }
888
889 /// Find an OAuth provider by its manifest `id`.
890 pub fn oauth_provider(&self, provider_id: &str) -> Option<&RegisteredOAuthProvider> {
891 self.oauth_providers.iter().find(|provider| {
892 serde_json::from_str::<serde_json::Value>(&provider.manifest_json)
893 .ok()
894 .and_then(|value| {
895 value
896 .get("id")
897 .and_then(serde_json::Value::as_str)
898 .map(|id| id == provider_id)
899 })
900 .unwrap_or(false)
901 })
902 }
903
904 /// The registered renderers of a specific kind (B5c). The TUI dispatches
905 /// message and entry renderers through the same JSON adapter used by the
906 /// markdown transformer.
907 pub fn renderers_of(&self, kind: RegisteredRendererKind) -> Vec<RegisteredRenderer> {
908 self.renderers
909 .iter()
910 .filter(|r| r.kind == kind)
911 .cloned()
912 .collect()
913 }
914
915 /// A flat iterator of all registrations (tools + commands), for diagnostics.
916 pub fn entries(&self) -> Vec<RegistryEntry> {
917 let mut v: Vec<RegistryEntry> = Vec::new();
918 for t in &self.tools {
919 v.push(RegistryEntry::Tool(ExtensionTool {
920 tool: t.tool.clone(),
921 handle: t.handle,
922 }));
923 }
924 for c in &self.commands {
925 v.push(RegistryEntry::Command(c.clone()));
926 }
927 for flag in &self.flags {
928 v.push(RegistryEntry::Flag(flag.clone()));
929 }
930 v
931 }
932
933 /// Whether this snapshot's session is still active.
934 pub fn is_active(&self) -> bool {
935 self.active.load(Ordering::SeqCst)
936 }
937
938 /// The shared active flag (for [`assert_active`]).
939 pub fn active_flag(&self) -> &Arc<AtomicBool> {
940 &self.active
941 }
942}
943
944/// Staleness guard: `true` while the owning session is still active. Called
945/// before dispatching an extension event or driving an extension tool so a
946/// stale registry (from a swapped-out session) can't act. Mirrors pi's
947/// `ExtensionRuntimeState` active check.
948pub fn assert_active(active: &Arc<AtomicBool>) -> bool {
949 active.load(Ordering::SeqCst)
950}
951
952/// Build an `AgentError` for a stale-registry access.
953#[allow(dead_code)]
954pub(crate) fn stale_error() -> AgentError {
955 AgentError::State("extensions registry is stale (session swapped/reloaded)".into())
956}
957
958// Suppress unused-fn warning until B3 wires dispatch through the snapshot.
959#[allow(dead_code)]
960fn _ensure_handler_accessor_used(snap: &RegistrySnapshot) {
961 let _ = snap.handlers_for(EventTag::MessageEnd);
962}
963
964#[cfg(test)]
965mod tests {
966 use super::*;
967
968 #[test]
969 fn shortcuts_first_wins_and_merge() {
970 let mut first = ExtensionRegistry::new();
971 assert!(!first.register_shortcut("voice".into(), "space".into(), "talk".into()));
972 // Second claim on the same key is rejected (first-wins).
973 assert!(first.register_shortcut("other".into(), "space".into(), "also".into()));
974 assert!(!first.register_shortcut("voice".into(), "enter".into(), "send".into()));
975
976 let mut second = ExtensionRegistry::new();
977 assert!(!second.register_shortcut("other".into(), "space".into(), "dup".into()));
978 assert!(!second.register_shortcut("other".into(), "f1".into(), "help".into()));
979
980 first.absorb(second);
981 let snap = first.snapshot();
982 assert_eq!(snap.shortcuts().len(), 3);
983 assert_eq!(snap.shortcuts()[0].key, "space");
984 assert_eq!(snap.shortcuts()[0].plugin, "voice");
985 assert_eq!(snap.shortcuts()[1].key, "enter");
986 assert_eq!(snap.shortcuts()[2].key, "f1");
987 // The claimed-key query the TUI routes on.
988 assert!(snap.has_shortcut("space"));
989 assert!(!snap.has_shortcut("tab"));
990 }
991
992 #[test]
993 fn cli_flags_snapshot_and_merge_first_wins() {
994 let mut first = ExtensionRegistry::new();
995 assert!(!first.register_flag("server".into(), "Start server".into()));
996 assert!(first.register_flag("server".into(), "Different description".into()));
997
998 let mut second = ExtensionRegistry::new();
999 assert!(!second.register_flag("server".into(), "Second server".into()));
1000 assert!(!second.register_flag("port".into(), "Listen port".into()));
1001
1002 first.absorb(second);
1003 let snapshot = first.snapshot();
1004 let flags = snapshot.flags();
1005 assert_eq!(flags.len(), 2);
1006 assert_eq!(flags[0].name, "server");
1007 assert_eq!(flags[0].description, "Start server");
1008 assert_eq!(flags[1].name, "port");
1009 }
1010
1011 extern "C" fn noop_handler(
1012 _ev: rpi_plugin_sdk::StablePluginEvent,
1013 _ud: *mut std::ffi::c_void,
1014 ) -> i32 {
1015 0
1016 }
1017
1018 fn handler_of<'a>(
1019 snap: &'a RegistrySnapshot,
1020 tag: EventTag,
1021 i: usize,
1022 ) -> &'a RegisteredHandler {
1023 &snap.handlers_for(tag)[i]
1024 }
1025
1026 /// Two plugins declaring different priorities: the merged snapshot orders
1027 /// the lower-priority plugin's handler first (smaller runs first).
1028 #[test]
1029 fn priority_orders_handlers_after_merge() {
1030 let tag = EventTag::SessionStart;
1031
1032 let mut late = ExtensionRegistry::new();
1033 late.apply_declaration(Some(200), None);
1034 late.register_event_handler("late".into(), tag, noop_handler, std::ptr::null_mut());
1035
1036 let mut early = ExtensionRegistry::new();
1037 early.apply_declaration(Some(50), None);
1038 early.register_event_handler("early".into(), tag, noop_handler, std::ptr::null_mut());
1039
1040 // Absorb in the "wrong" order — snapshot must still sort by priority.
1041 let mut session = ExtensionRegistry::new();
1042 session.absorb(late);
1043 session.absorb(early);
1044 let snap = session.snapshot();
1045 assert_eq!(handler_of(&snap, tag, 0).plugin, "early");
1046 assert_eq!(handler_of(&snap, tag, 1).plugin, "late");
1047 }
1048
1049 /// Equal priorities keep registration order (stable sort).
1050 #[test]
1051 fn equal_priority_is_stable() {
1052 let tag = EventTag::TurnEnd;
1053 let mut reg = ExtensionRegistry::new();
1054 reg.register_event_handler("a".into(), tag, noop_handler, std::ptr::null_mut());
1055 reg.register_event_handler("b".into(), tag, noop_handler, std::ptr::null_mut());
1056 let snap = reg.snapshot();
1057 assert_eq!(handler_of(&snap, tag, 0).plugin, "a");
1058 assert_eq!(handler_of(&snap, tag, 1).plugin, "b");
1059 }
1060
1061 /// A declaration applied AFTER handlers exist retro-updates them (declare
1062 /// order does not matter).
1063 #[test]
1064 fn declaration_retro_updates_existing_handlers() {
1065 let tag = EventTag::SessionStart;
1066 let mut reg = ExtensionRegistry::new();
1067 reg.register_event_handler("p".into(), tag, noop_handler, std::ptr::null_mut());
1068 assert_eq!(
1069 handler_of(®.snapshot(), tag, 0).priority,
1070 DEFAULT_PRIORITY
1071 );
1072
1073 assert!(reg.apply_declaration_json(r#"{"priority":7,"platforms":["linux"]}"#));
1074 let snap = reg.snapshot();
1075 assert_eq!(handler_of(&snap, tag, 0).priority, 7);
1076 assert_eq!(
1077 handler_of(&snap, tag, 0).platforms,
1078 vec!["linux".to_string()]
1079 );
1080 }
1081
1082 /// Malformed declaration JSON is rejected (returns `false`); recognised keys
1083 /// apply.
1084 #[test]
1085 fn apply_declaration_json_parses_and_rejects() {
1086 let mut reg = ExtensionRegistry::new();
1087 assert!(!reg.apply_declaration_json("not json"));
1088 assert!(reg.apply_declaration_json("{}"));
1089 assert!(reg.apply_declaration_json(r#"{"priority":3}"#));
1090 assert_eq!(reg.apply_declaration(Some(3), None), 1);
1091 }
1092
1093 /// `declare {"ownsStdio":true}` claims the host process's stdio; it is
1094 /// monotonic (a later `false` does not un-claim) and merges by OR.
1095 #[test]
1096 fn declaration_owns_stdio_is_monotonic_and_merges() {
1097 let mut reg = ExtensionRegistry::new();
1098 assert!(!reg.declared_owns_stdio());
1099 assert!(!reg.snapshot().owns_stdio());
1100
1101 assert!(reg.apply_declaration_json(r#"{"ownsStdio":true}"#));
1102 assert!(reg.declared_owns_stdio());
1103 assert!(reg.snapshot().owns_stdio());
1104
1105 // A later `false` must not release an established claim.
1106 assert!(reg.apply_declaration_json(r#"{"ownsStdio":false}"#));
1107 assert!(reg.declared_owns_stdio());
1108
1109 // `absorb` ORs ownership across plugins (any claiming plugin wins).
1110 let mut plain = ExtensionRegistry::new();
1111 let mut claiming = ExtensionRegistry::new();
1112 assert!(claiming.apply_declaration_json(r#"{"ownsStdio":true}"#));
1113 plain.absorb(claiming);
1114 assert!(plain.declared_owns_stdio());
1115 assert!(plain.snapshot().owns_stdio());
1116 }
1117
1118 #[test]
1119 fn platform_allows_matches_host_and_wildcard() {
1120 assert!(platform_allows(&[]));
1121 assert!(platform_allows(&["all".to_string()]));
1122 assert!(platform_allows(&["ALL".to_string()]));
1123 assert!(platform_allows(&[current_platform().to_string()]));
1124 assert!(!platform_allows(&["nonexistent-os".to_string()]));
1125 // Empty host subset is fine; a non-matching list is not.
1126 assert!(!platform_allows(&["definitely-not-this-host".to_string()]));
1127 }
1128
1129 extern "C" fn noop_oauth(
1130 _request: rpi_plugin_sdk::StbStringRef,
1131 _out: *mut rpi_plugin_sdk::StbString,
1132 _user_data: *mut std::ffi::c_void,
1133 ) -> i32 {
1134 0
1135 }
1136
1137 extern "C" fn noop_free(_value: rpi_plugin_sdk::StbString) {}
1138
1139 #[test]
1140 fn oauth_manifest_registers_and_deduplicates() {
1141 let mut reg = ExtensionRegistry::new();
1142 let make = |manifest: &str| RegisteredOAuthProvider {
1143 plugin: "test-plugin".to_string(),
1144 manifest_json: manifest.to_string(),
1145 request_fn: noop_oauth,
1146 plugin_free_string: noop_free,
1147 user_data: std::ptr::null_mut(),
1148 };
1149 assert!(!reg.register_oauth_provider(make(r#"{"id":"acme","displayName":"Acme"}"#)));
1150 assert!(reg.register_oauth_provider(make(r#"{"id":"acme"}"#)));
1151 assert!(reg.register_oauth_provider(make(r#"{"displayName":"missing-id"}"#)));
1152 assert_eq!(reg.snapshot().oauth_providers().len(), 1);
1153 }
1154}