Files
owlry/docs/superpowers/plans/2026-03-26-runtime-integration.md
vikingowl bd69f8eafe perf(ui): use ListBox::remove_all() instead of per-child loop
Replaces five while-loop child removal patterns with the batched
remove_all() method available since GTK 4.12. Avoids per-removal
layout invalidation.
2026-03-29 20:43:41 +02:00

25 KiB

Script Runtime Integration Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Enable the owlry-core daemon to discover and load Lua/Rune user plugins from ~/.config/owlry/plugins/, with automatic hot-reload on file changes.

Architecture: Fix ABI mismatches between core and runtimes, wire LoadedRuntime into ProviderManager::new_with_config(), add filesystem watcher for automatic plugin reload. Runtimes are external .so libraries loaded from /usr/lib/owlry/runtimes/.

Tech Stack: Rust 1.90+, notify 7, notify-debouncer-mini 0.5, libloading 0.8

Repos:

  • Core: /home/cnachtigall/ssd/git/archive/owlibou/owlry
  • Plugins (docs only): /home/cnachtigall/ssd/git/archive/owlibou/owlry-plugins

Task 1: Fix Lua RuntimeInfo ABI and vtable init signature

Files:

  • Modify: crates/owlry-lua/src/lib.rs:42-74,260-279,322-336
  • Modify: crates/owlry-rune/src/lib.rs:42-46,73-84,90-95,97-146,215-229
  • Modify: crates/owlry-core/src/plugins/runtime_loader.rs:55-68,84-146,267-277

1a. Shrink Lua RuntimeInfo to 2 fields

  • Step 1: Update RuntimeInfo struct and runtime_info() in owlry-lua

In crates/owlry-lua/src/lib.rs:

Remove the LUA_RUNTIME_API_VERSION constant (line 43).

Replace the RuntimeInfo struct (lines 67-74):

/// Runtime info returned by the runtime
#[repr(C)]
pub struct RuntimeInfo {
    pub name: RString,
    pub version: RString,
}

Replace runtime_info() (lines 260-268):

extern "C" fn runtime_info() -> RuntimeInfo {
    RuntimeInfo {
        name: RString::from("Lua"),
        version: RString::from(env!("CARGO_PKG_VERSION")),
    }
}

Remove unused constants RUNTIME_ID and RUNTIME_DESCRIPTION (lines 37, 40) if no longer referenced.

1b. Add owlry_version parameter to vtable init

  • Step 2: Update ScriptRuntimeVTable in core

In crates/owlry-core/src/plugins/runtime_loader.rs, change the init field (line 59):

pub struct ScriptRuntimeVTable {
    pub info: extern "C" fn() -> RuntimeInfo,
    pub init: extern "C" fn(plugins_dir: RStr<'_>, owlry_version: RStr<'_>) -> RuntimeHandle,
    pub providers: extern "C" fn(handle: RuntimeHandle) -> RVec<ScriptProviderInfo>,
    pub refresh: extern "C" fn(handle: RuntimeHandle, provider_id: RStr<'_>) -> RVec<PluginItem>,
    pub query: extern "C" fn(
        handle: RuntimeHandle,
        provider_id: RStr<'_>,
        query: RStr<'_>,
    ) -> RVec<PluginItem>,
    pub drop: extern "C" fn(handle: RuntimeHandle),
}
  • Step 3: Update LoadedRuntime to pass version

In crates/owlry-core/src/plugins/runtime_loader.rs, update load_lua, load_rune, and load_from_path to accept and pass the version:

impl LoadedRuntime {
    pub fn load_lua(plugins_dir: &Path, owlry_version: &str) -> PluginResult<Self> {
        Self::load_from_path(
            "Lua",
            &PathBuf::from(SYSTEM_RUNTIMES_DIR).join("liblua.so"),
            b"owlry_lua_runtime_vtable",
            plugins_dir,
            owlry_version,
        )
    }

    fn load_from_path(
        name: &'static str,
        library_path: &Path,
        vtable_symbol: &[u8],
        plugins_dir: &Path,
        owlry_version: &str,
    ) -> PluginResult<Self> {
        // ... existing library loading code ...

        // Initialize the runtime with version
        let plugins_dir_str = plugins_dir.to_string_lossy();
        let handle = (vtable.init)(
            RStr::from_str(&plugins_dir_str),
            RStr::from_str(owlry_version),
        );

        // ... rest unchanged ...
    }
}

impl LoadedRuntime {
    pub fn load_rune(plugins_dir: &Path, owlry_version: &str) -> PluginResult<Self> {
        Self::load_from_path(
            "Rune",
            &PathBuf::from(SYSTEM_RUNTIMES_DIR).join("librune.so"),
            b"owlry_rune_runtime_vtable",
            plugins_dir,
            owlry_version,
        )
    }
}
  • Step 4: Update Lua runtime_init to accept version

In crates/owlry-lua/src/lib.rs, update runtime_init (line 270) and the vtable:

extern "C" fn runtime_init(plugins_dir: RStr<'_>, owlry_version: RStr<'_>) -> RuntimeHandle {
    let plugins_dir = PathBuf::from(plugins_dir.as_str());
    let mut state = Box::new(LuaRuntimeState::new(plugins_dir));

    state.discover_and_load(owlry_version.as_str());

    RuntimeHandle::from_box(state)
}

Update the LuaRuntimeVTable struct init field to match:

pub init: extern "C" fn(plugins_dir: RStr<'_>, owlry_version: RStr<'_>) -> RuntimeHandle,
  • Step 5: Update Rune runtime_init to accept version

In crates/owlry-rune/src/lib.rs, update runtime_init (line 97) and the vtable:

extern "C" fn runtime_init(plugins_dir: RStr<'_>, owlry_version: RStr<'_>) -> RuntimeHandle {
    let _ = env_logger::try_init();

    let plugins_dir = PathBuf::from(plugins_dir.as_str());
    let _version = owlry_version.as_str();
    log::info!(
        "Initializing Rune runtime with plugins from: {}",
        plugins_dir.display()
    );

    // ... rest unchanged — Rune doesn't currently do version checking ...

Update the RuneRuntimeVTable struct init field:

pub init: extern "C" fn(plugins_dir: RStr<'_>, owlry_version: RStr<'_>) -> RuntimeHandle,
  • Step 6: Build all three crates

Run: cargo check -p owlry-core && cargo check -p owlry-lua && cargo check -p owlry-rune Expected: all pass

  • Step 7: Run tests

Run: cargo test -p owlry-core && cargo test -p owlry-lua && cargo test -p owlry-rune Expected: all pass

  • Step 8: Commit
git add crates/owlry-core/src/plugins/runtime_loader.rs \
  crates/owlry-lua/src/lib.rs \
  crates/owlry-rune/src/lib.rs
git commit -m "fix: align runtime ABI — shrink Lua RuntimeInfo, pass owlry_version to init"

Task 2: Change default entry points to main and add alias

Files:

  • Modify: crates/owlry-lua/src/manifest.rs:52-54

  • Modify: crates/owlry-rune/src/manifest.rs:36-38,29

  • Step 1: Update Lua manifest default entry

In crates/owlry-lua/src/manifest.rs, change default_entry() (line 52):

fn default_entry() -> String {
    "main.lua".to_string()
}

Add serde(alias) to the entry field in PluginInfo (line 45):

    #[serde(default = "default_entry", alias = "entry_point")]
    pub entry: String,
  • Step 2: Update Rune manifest default entry

In crates/owlry-rune/src/manifest.rs, change default_entry() (line 36):

fn default_entry() -> String {
    "main.rn".to_string()
}

Add serde(alias) to the entry field in PluginInfo (line 29):

    #[serde(default = "default_entry", alias = "entry_point")]
    pub entry: String,
  • Step 3: Update tests that reference init.lua/init.rn

In crates/owlry-lua/src/manifest.rs test test_parse_minimal_manifest:

assert_eq!(manifest.plugin.entry, "main.lua");

In crates/owlry-lua/src/loader.rs test create_test_plugin:

fs::write(plugin_dir.join("main.lua"), "-- empty plugin").unwrap();

In crates/owlry-rune/src/manifest.rs test test_parse_minimal_manifest:

assert_eq!(manifest.plugin.entry, "main.rn");
  • Step 4: Build and test

Run: cargo test -p owlry-lua && cargo test -p owlry-rune Expected: all pass

  • Step 5: Commit
git add crates/owlry-lua/src/manifest.rs crates/owlry-lua/src/loader.rs \
  crates/owlry-rune/src/manifest.rs
git commit -m "feat: change default entry points to main.lua/main.rn, add entry_point alias"

Task 3: Wire runtime loading into ProviderManager

Files:

  • Modify: crates/owlry-core/src/providers/mod.rs:106-119,173-224

  • Modify: crates/owlry-core/src/plugins/runtime_loader.rs:13 (remove allow dead_code)

  • Step 1: Add runtimes field to ProviderManager

In crates/owlry-core/src/providers/mod.rs, add import and field:

use crate::plugins::runtime_loader::LoadedRuntime;

Add to the ProviderManager struct (after matcher field):

pub struct ProviderManager {
    providers: Vec<Box<dyn Provider>>,
    static_native_providers: Vec<NativeProvider>,
    dynamic_providers: Vec<NativeProvider>,
    widget_providers: Vec<NativeProvider>,
    matcher: SkimMatcherV2,
    /// Loaded script runtimes (Lua, Rune) — must stay alive to keep Library handles
    runtimes: Vec<LoadedRuntime>,
    /// Type IDs of providers that came from script runtimes (for hot-reload removal)
    runtime_type_ids: std::collections::HashSet<String>,
}

Update ProviderManager::new() to initialize the new fields:

let mut manager = Self {
    providers: core_providers,
    static_native_providers: Vec::new(),
    dynamic_providers: Vec::new(),
    widget_providers: Vec::new(),
    matcher: SkimMatcherV2::default(),
    runtimes: Vec::new(),
    runtime_type_ids: std::collections::HashSet::new(),
};
  • Step 2: Add runtime loading to new_with_config

In ProviderManager::new_with_config(), after the native plugin loading block (after line 221) and before Self::new(core_providers, native_providers) (line 223), add runtime loading:

        // Load script runtimes (Lua, Rune) for user plugins
        let mut runtime_providers: Vec<Box<dyn Provider>> = Vec::new();
        let mut runtimes: Vec<LoadedRuntime> = Vec::new();
        let mut runtime_type_ids = std::collections::HashSet::new();
        let owlry_version = env!("CARGO_PKG_VERSION");

        if let Some(plugins_dir) = crate::paths::plugins_dir() {
            // Try Lua runtime
            match LoadedRuntime::load_lua(&plugins_dir, owlry_version) {
                Ok(rt) => {
                    info!("Loaded Lua runtime with {} provider(s)", rt.providers().len());
                    for provider in rt.create_providers() {
                        let type_id = format!("{}", provider.provider_type());
                        runtime_type_ids.insert(type_id);
                        runtime_providers.push(provider);
                    }
                    runtimes.push(rt);
                }
                Err(e) => {
                    info!("Lua runtime not available: {}", e);
                }
            }

            // Try Rune runtime
            match LoadedRuntime::load_rune(&plugins_dir, owlry_version) {
                Ok(rt) => {
                    info!("Loaded Rune runtime with {} provider(s)", rt.providers().len());
                    for provider in rt.create_providers() {
                        let type_id = format!("{}", provider.provider_type());
                        runtime_type_ids.insert(type_id);
                        runtime_providers.push(provider);
                    }
                    runtimes.push(rt);
                }
                Err(e) => {
                    info!("Rune runtime not available: {}", e);
                }
            }
        }

        let mut manager = Self::new(core_providers, native_providers);
        manager.runtimes = runtimes;
        manager.runtime_type_ids = runtime_type_ids;

        // Add runtime providers to the core providers list
        for provider in runtime_providers {
            info!("Registered runtime provider: {}", provider.name());
            manager.providers.push(provider);
        }

        // Refresh runtime providers
        for provider in &mut manager.providers {
            // Only refresh the ones we just added (runtime providers)
            // They need an initial refresh to populate items
        }
        manager.refresh_all();

        manager

Note: This replaces the current Self::new(core_providers, native_providers) return. The refresh_all() at the end of new() will be called, plus we call it again — but that's fine since refresh is idempotent. Actually, new() already calls refresh_all(), so we should remove the duplicate. Let me adjust:

The cleaner approach is to construct the manager via Self::new() which calls refresh_all(), then set the runtime fields and add providers, then call refresh_all() once more for the newly added runtime providers. Or better — add runtime providers to core_providers before calling new():

        // Merge runtime providers into core providers
        let mut all_core_providers = core_providers;
        for provider in runtime_providers {
            info!("Registered runtime provider: {}", provider.name());
            all_core_providers.push(provider);
        }

        let mut manager = Self::new(all_core_providers, native_providers);
        manager.runtimes = runtimes;
        manager.runtime_type_ids = runtime_type_ids;
        manager

This way new() handles the single refresh_all() call.

  • Step 3: Remove allow(dead_code) from runtime_loader

In crates/owlry-core/src/plugins/runtime_loader.rs, remove #![allow(dead_code)] (line 13).

Fix any resulting dead code warnings by removing unused #[allow(dead_code)] attributes on individual items that are now actually used, or adding targeted #[allow(dead_code)] only on truly unused items.

  • Step 4: Build and test

Run: cargo check -p owlry-core && cargo test -p owlry-core Expected: all pass. May see info logs about runtimes loading (if installed on the build machine).

  • Step 5: Commit
git add crates/owlry-core/src/providers/mod.rs \
  crates/owlry-core/src/plugins/runtime_loader.rs
git commit -m "feat: wire script runtime loading into daemon ProviderManager"

Task 4: Filesystem watcher for hot-reload

Files:

  • Create: crates/owlry-core/src/plugins/watcher.rs

  • Modify: crates/owlry-core/src/plugins/mod.rs:23-28 (add module)

  • Modify: crates/owlry-core/src/providers/mod.rs (add reload method)

  • Modify: crates/owlry-core/src/server.rs:59-78 (start watcher)

  • Modify: crates/owlry-core/Cargo.toml (add deps)

  • Step 1: Add dependencies

In crates/owlry-core/Cargo.toml, add to [dependencies]:

# Filesystem watching for plugin hot-reload
notify = "7"
notify-debouncer-mini = "0.5"
  • Step 2: Add reload_runtimes method to ProviderManager

In crates/owlry-core/src/providers/mod.rs, add a method:

    /// Reload all script runtime providers (called by filesystem watcher)
    pub fn reload_runtimes(&mut self) {
        // Remove old runtime providers from the core providers list
        self.providers.retain(|p| {
            let type_str = format!("{}", p.provider_type());
            !self.runtime_type_ids.contains(&type_str)
        });

        // Drop old runtimes
        self.runtimes.clear();
        self.runtime_type_ids.clear();

        let owlry_version = env!("CARGO_PKG_VERSION");
        let plugins_dir = match crate::paths::plugins_dir() {
            Some(d) => d,
            None => return,
        };

        // Reload Lua runtime
        match LoadedRuntime::load_lua(&plugins_dir, owlry_version) {
            Ok(rt) => {
                info!("Reloaded Lua runtime with {} provider(s)", rt.providers().len());
                for provider in rt.create_providers() {
                    let type_id = format!("{}", provider.provider_type());
                    self.runtime_type_ids.insert(type_id);
                    self.providers.push(provider);
                }
                self.runtimes.push(rt);
            }
            Err(e) => {
                info!("Lua runtime not available on reload: {}", e);
            }
        }

        // Reload Rune runtime
        match LoadedRuntime::load_rune(&plugins_dir, owlry_version) {
            Ok(rt) => {
                info!("Reloaded Rune runtime with {} provider(s)", rt.providers().len());
                for provider in rt.create_providers() {
                    let type_id = format!("{}", provider.provider_type());
                    self.runtime_type_ids.insert(type_id);
                    self.providers.push(provider);
                }
                self.runtimes.push(rt);
            }
            Err(e) => {
                info!("Rune runtime not available on reload: {}", e);
            }
        }

        // Refresh the newly added providers
        for provider in &mut self.providers {
            provider.refresh();
        }

        info!("Runtime reload complete");
    }
  • Step 3: Create the watcher module

Create crates/owlry-core/src/plugins/watcher.rs:

//! Filesystem watcher for user plugin hot-reload
//!
//! Watches `~/.config/owlry/plugins/` for changes and triggers
//! runtime reload when plugin files are modified.

use std::path::PathBuf;
use std::sync::{Arc, RwLock};
use std::thread;
use std::time::Duration;

use log::{info, warn};
use notify_debouncer_mini::{DebouncedEventKind, new_debouncer};

use crate::providers::ProviderManager;

/// Start watching the user plugins directory for changes.
///
/// Spawns a background thread that monitors the directory and triggers
/// a full runtime reload on any file change. Returns immediately.
///
/// If the plugins directory doesn't exist or the watcher fails to start,
/// logs a warning and returns without spawning a thread.
pub fn start_watching(pm: Arc<RwLock<ProviderManager>>) {
    let plugins_dir = match crate::paths::plugins_dir() {
        Some(d) => d,
        None => {
            info!("No plugins directory configured, skipping file watcher");
            return;
        }
    };

    if !plugins_dir.exists() {
        // Create the directory so the watcher has something to watch
        if std::fs::create_dir_all(&plugins_dir).is_err() {
            warn!("Failed to create plugins directory: {}", plugins_dir.display());
            return;
        }
    }

    thread::spawn(move || {
        if let Err(e) = watch_loop(&plugins_dir, &pm) {
            warn!("Plugin watcher stopped: {}", e);
        }
    });

    info!("Plugin file watcher started for {}", plugins_dir.display());
}

fn watch_loop(
    plugins_dir: &PathBuf,
    pm: &Arc<RwLock<ProviderManager>>,
) -> Result<(), Box<dyn std::error::Error>> {
    let (tx, rx) = std::sync::mpsc::channel();

    let mut debouncer = new_debouncer(Duration::from_millis(500), tx)?;

    debouncer
        .watcher()
        .watch(plugins_dir.as_ref(), notify::RecursiveMode::Recursive)?;

    info!("Watching {} for plugin changes", plugins_dir.display());

    loop {
        match rx.recv() {
            Ok(Ok(events)) => {
                // Check if any event is relevant (not just access/metadata)
                let has_relevant_change = events.iter().any(|e| {
                    matches!(e.kind, DebouncedEventKind::Any | DebouncedEventKind::AnyContinuous)
                });

                if has_relevant_change {
                    info!("Plugin file change detected, reloading runtimes...");
                    let mut pm_guard = pm.write().unwrap_or_else(|e| e.into_inner());
                    pm_guard.reload_runtimes();
                }
            }
            Ok(Err(errors)) => {
                for e in errors {
                    warn!("File watcher error: {}", e);
                }
            }
            Err(e) => {
                // Channel closed — watcher was dropped
                return Err(Box::new(e));
            }
        }
    }
}
  • Step 4: Register the watcher module

In crates/owlry-core/src/plugins/mod.rs, add after line 28 (pub mod runtime_loader;):

pub mod watcher;
  • Step 5: Start watcher in Server::run

In crates/owlry-core/src/server.rs, in the run() method, before the accept loop, add:

pub fn run(&self) -> io::Result<()> {
    // Start filesystem watcher for user plugin hot-reload
    crate::plugins::watcher::start_watching(Arc::clone(&self.provider_manager));

    info!("Server entering accept loop");
    for stream in self.listener.incoming() {
  • Step 6: Build and test

Run: cargo check -p owlry-core && cargo test -p owlry-core Expected: all pass

  • Step 7: Manual smoke test
# Start the daemon
RUST_LOG=info cargo run -p owlry-core

# In another terminal, create a test plugin
mkdir -p ~/.config/owlry/plugins/hotreload-test
cat > ~/.config/owlry/plugins/hotreload-test/plugin.toml << 'EOF'
[plugin]
id = "hotreload-test"
name = "Hot Reload Test"
version = "0.1.0"
EOF
cat > ~/.config/owlry/plugins/hotreload-test/main.lua << 'EOF'
owlry.provider.register({
    name = "hotreload-test",
    refresh = function()
        return {{ id = "hr1", name = "Hot Reload Works!", command = "echo yes" }}
    end,
})
EOF

# Watch daemon logs — should see "Plugin file change detected, reloading runtimes..."
# Clean up after testing
rm -rf ~/.config/owlry/plugins/hotreload-test
  • Step 8: Commit
git add crates/owlry-core/Cargo.toml \
  crates/owlry-core/src/plugins/watcher.rs \
  crates/owlry-core/src/plugins/mod.rs \
  crates/owlry-core/src/providers/mod.rs \
  crates/owlry-core/src/server.rs
git commit -m "feat: add filesystem watcher for automatic user plugin hot-reload"

Task 5: Update plugin development documentation

Files:

  • Modify: /home/cnachtigall/ssd/git/archive/owlibou/owlry-plugins/docs/PLUGIN_DEVELOPMENT.md

  • Step 1: Update Lua plugin section

In docs/PLUGIN_DEVELOPMENT.md, update the Lua Quick Start section (around line 101):

Change entry_point = "init.lua" to entry = "main.lua" in the manifest example.

Replace the Lua code example with the owlry.provider.register() API:

owlry.provider.register({
    name = "myluaprovider",
    display_name = "My Lua Provider",
    type_id = "mylua",
    default_icon = "application-x-executable",
    prefix = ":mylua",
    refresh = function()
        return {
            { id = "item-1", name = "Hello from Lua", command = "echo 'Hello Lua!'" },
        }
    end,
})

Remove local owlry = require("owlry") — the owlry table is pre-registered globally.

  • Step 2: Update Rune plugin section

Update the Rune manifest example to use entry = "main.rn" instead of entry_point = "main.rn".

  • Step 3: Update manifest reference

In the Lua Plugin API manifest section (around line 325), change entry_point to entry and add a note:

[plugin]
id = "my-plugin"
name = "My Plugin"
version = "1.0.0"
description = "Plugin description"
entry = "main.lua"            # Default: main.lua (Lua) / main.rn (Rune)
                               # Alias: entry_point also accepted
owlry_version = ">=1.0.0"     # Optional version constraint
  • Step 4: Add hot-reload documentation

Add a new section after "Best Practices" (before "Publishing to AUR"):

## Hot Reload

User plugins in `~/.config/owlry/plugins/` are automatically reloaded when files change.
The daemon watches the plugins directory and reloads all script runtimes when any file
is created, modified, or deleted. No daemon restart is needed.

**What triggers a reload:**
- Creating a new plugin directory with `plugin.toml`
- Editing a plugin's script files (`main.lua`, `main.rn`, etc.)
- Editing a plugin's `plugin.toml`
- Deleting a plugin directory

**What does NOT trigger a reload:**
- Changes to native plugins (`.so` files) — these require a daemon restart
- Changes to runtime libraries in `/usr/lib/owlry/runtimes/` — daemon restart needed

**Reload behavior:**
- All script runtimes (Lua, Rune) are fully reloaded
- Existing search results may briefly show stale data during reload
- Errors in plugins are logged but don't affect other plugins
  • Step 5: Update Lua provider functions section

Replace the bare refresh()/query() examples (around line 390) with the register API:

-- Static provider: called once at startup and on reload
owlry.provider.register({
    name = "my-provider",
    display_name = "My Provider",
    prefix = ":my",
    refresh = function()
        return {
            { id = "id1", name = "Item 1", command = "command1" },
            { id = "id2", name = "Item 2", command = "command2" },
        }
    end,
})

-- Dynamic provider: called on each keystroke
owlry.provider.register({
    name = "my-search",
    display_name = "My Search",
    prefix = "?my",
    query = function(q)
        if q == "" then return {} end
        return {
            { id = "result", name = "Result for: " .. q, command = "echo " .. q },
        }
    end,
})
  • Step 6: Commit
cd /home/cnachtigall/ssd/git/archive/owlibou/owlry-plugins
git add docs/PLUGIN_DEVELOPMENT.md
git commit -m "docs: update plugin development guide for main.lua/rn defaults, register API, hot-reload"

Task 6: Update hello-test plugin and clean up

Files:

  • Modify: ~/.config/owlry/plugins/hello-test/plugin.toml
  • Modify: ~/.config/owlry/plugins/hello-test/init.lua → rename to main.lua

This is a local-only task, not committed to either repo.

  • Step 1: Update hello-test plugin
# Rename entry point
mv ~/.config/owlry/plugins/hello-test/init.lua ~/.config/owlry/plugins/hello-test/main.lua

# Update manifest to use entry field
cat > ~/.config/owlry/plugins/hello-test/plugin.toml << 'EOF'
[plugin]
id = "hello-test"
name = "Hello Test"
version = "0.1.0"
description = "Minimal test plugin for verifying Lua runtime loading"
EOF
  • Step 2: End-to-end verification
# Rebuild and restart daemon
cargo build -p owlry-core
RUST_LOG=info cargo run -p owlry-core

# Expected log output should include:
# - "Loaded Lua runtime with 1 provider(s)" (hello-test)
# - "Loaded Rune runtime with 1 provider(s)" (hyprshutdown)
# - "Plugin file watcher started for ..."