Skip to content

Instantly share code, notes, and snippets.

@possibilities
Last active April 10, 2026 00:00
Show Gist options
  • Select an option

  • Save possibilities/d823dfe4a597f2b2a5d5bca2e6d9873e to your computer and use it in GitHub Desktop.

Select an option

Save possibilities/d823dfe4a597f2b2a5d5bca2e6d9873e to your computer and use it in GitHub Desktop.
Prise Plug System — Developer Guide (protocol, Lua API, CLI, examples)

Prise Plug System — Developer Guide

Extracted from the prise(5) manual page. This documents the plug protocol, Lua API, CLI, and includes complete working examples.

PLUG SYSTEM

Plugs are external processes that extend prise with capabilities that would block the single-threaded UI event loop — file watching, database queries, network listeners, etc. Plugs communicate with the server via msgpack-RPC over the same Unix socket used by TUI clients.

Architecture

  • Plugs are server-side: they persist across client reconnects
  • The server is the sole manager: plugs are spawned via spawn_plug, not by connecting externally
  • Plugs are language-agnostic: any process that speaks msgpack-RPC over Unix sockets can be a plug
  • Plugs can block freely: they run in their own process, isolated from the UI event loop
  • Plug crashes are non-fatal: the server can auto-restart them
  • Plugs are isolated: they cannot see each other's events or notifications. Cross-plug coordination happens through Lua, which receives all notifications and can call any plug.
  • Plugs use token-based authentication: the server generates a unique token per spawn, passed via environment variable. The plug must present this token when registering. This decouples authentication from the spawned PID, allowing wrapper commands (e.g. npx, uv run) that fork a child process.

Lifecycle

  1. Lua config calls prise.spawn_plug({name="myplugin", cmd={"python3", "plugin.py"}}, callback?)
  2. Server generates a random token and forks a child process with PRISE_SOCKET and PRISE_PLUG_TOKEN environment variables
  3. Child (or a grandchild forked by a wrapper) connects to the Unix socket at PRISE_SOCKET
  4. Child sends register_plug RPC with its name, token, and event subscriptions
  5. Server validates the token (timing-safe comparison) and registers the plug
  6. Plug is now live: it can receive events, handle calls, and send notifications
  7. On exit, server broadcasts plug_disconnected and optionally restarts

Hybrid liveness

Plug liveness is determined by two independent signals: the process (PID) and the socket connection (registration). When a wrapper command like npx or uv run spawns a child that owns the socket, the wrapper's PID may exit before the child disconnects. The server handles this gracefully:

  • If the process exits while the plug is still registered (socket connected), cleanup and restart are deferred until the socket closes.
  • If the process exits and the plug never registered, cleanup and restart proceed immediately.
  • This means prise plug list may show a plug as connected with no PID — the wrapper exited but the child is still serving.

Limits

Resource Limit Constant
Maximum plugs 16 PLUGS_MAX
Plug name length 64 bytes PLUG_NAME_MAX
Plug token length 32 hex chars PLUG_TOKEN_HEX_LEN
Call forward timeout 30 seconds CALL_FORWARD_TIMEOUT_MS
Max pending call forwards 256 PENDING_FORWARDS_MAX

PLUG PROTOCOL

Plugs communicate using standard msgpack-RPC (same as TUI clients):

  • Request: [0, msgid, method, params]
  • Response: [1, msgid, error, result]
  • Notification: [2, method, params]

register_plug (request, plug → server)

Register this connection as a named plug. Must be the first RPC call. Only connections presenting a valid token (received via PRISE_PLUG_TOKEN environment variable) are accepted.

Params:

{
  "name": "watchdog",
  "token": "a1b2c3d4e5f6...",
  "subscribe": ["pty_exited", "cwd_changed"]
}
  • name (string, required): Unique plug identifier. Max 64 bytes.
  • token (string, required): The 32-character hex token from PRISE_PLUG_TOKEN. Validated with timing-safe comparison against the server's stored token.
  • subscribe (array of strings, optional): Server-side event names to receive. Use "*" to subscribe to all server events. Note: plugs can only subscribe to server events (pty_exited, pty_spawned, cwd_changed, rename_tab), not to other plugs' notifications.

Result: "ok" on success.

Errors: DuplicatePlugName, InvalidPlugName, MissingPlugName, MissingPlugToken, PlugLimitReached, PermissionDenied, AlreadyRegistered

spawn_plug (request, client → server)

Spawn a managed plug process. Returns proper RPC errors on failure.

Params:

{
  "name": "watchdog",
  "cmd": ["python3", "watchdog.py"],
  "restart": true,
  "restart_delay_ms": 2000
}

Result: "ok" on success.

Errors: InvalidParams, InvalidPlugName, PlugConfigConflict, PlugLimitReached

Idempotent: spawning a plug with the same name and identical config returns "ok" without creating a duplicate. Mismatched config returns PlugConfigConflict. A plug in the deferred state (wrapper exited, child still connected) is treated as active for idempotency checks.

Subscribable events

Event Params Description
pty_exited [pty_id, exit_status] A PTY process exited
pty_spawned {id, cwd, session?, tab?, title?, focus?} A new PTY was created
cwd_changed {pty_id, cwd} A PTY's working directory changed
rename_tab {pty_id, title} A tab was renamed

These events are delivered to plugs that subscribe to them. Plug-to-plug notifications (plug_notification, plug_connected, plug_disconnected) are delivered only to TUI clients, not to plugs.

Sending notifications (plug → server → Lua)

Plugs send notifications with a plug. method prefix:

[2, "plug.watchdog.file_changed", {"file": "/tmp/x", "event": "modify"}]

The server repackages this as a plug_notification for TUI clients:

[2, "plug_notification", {"plug": "watchdog", "method": "watchdog.file_changed", "params": {"file": "/tmp/x", "event": "modify"}}]

In Lua, this arrives as an event in update():

{type = "plug_notification", data = {plug = "watchdog", method = "watchdog.file_changed", params = {file = "/tmp/x", event = "modify"}}}

Handling calls (Lua → server → plug)

Plugs receive standard RPC requests forwarded by the server:

[0, forward_msgid, "ping", {}]

The plug responds with a standard RPC response:

[1, forward_msgid, nil, "pong"]

The server routes this back to the originating Lua callback.

LUA PLUG API

All functions are available on the prise module.

prise.spawn_plug(opts, callback?)

Spawn a managed plug process. An optional callback receives (err, result) when the server responds, allowing Lua to detect spawn failures.

-- Fire-and-forget (no callback)
prise.spawn_plug({
    name = "watchdog",
    cmd = {"python3", "watchdog.py"},
    restart = true,
    restart_delay_ms = 2000,
})

-- With error handling (callback)
prise.spawn_plug({
    name = "watchdog",
    cmd = {"python3", "watchdog.py"},
    restart = true,
}, function(err, result)
    if err then
        prise.log.error("spawn_plug failed: " .. tostring(err))
        return
    end
    prise.log.info("spawn_plug ok: " .. tostring(result))
end)

Options:

  • name (string, required): Unique plug name. Max 64 bytes.
  • cmd (table, required): Command to execute as {program, arg1, arg2, ...}.
  • restart (bool, optional): Auto-restart on exit. Default: false.
  • restart_delay_ms (number, optional): Milliseconds before restart. Default: 1000.

Callback errors: InvalidParams, InvalidPlugName, PlugConfigConflict, PlugLimitReached

The child process receives PRISE_SOCKET and PRISE_PLUG_TOKEN environment variables. Spawning is asynchronous — wait for a plug_connected event before calling the plug.

If arg 2 is provided, it must be a function (or nil). Passing a non-function value raises a Lua type error.

prise.call_plug(name, method, params, callback)

Send an RPC request to a plug and receive the response via callback.

prise.call_plug("echo", "ping", {timestamp = os.time()}, function(err, result)
    if err then
        prise.log.error("call failed: " .. tostring(err))
        return
    end
    prise.log.info("got response: " .. tostring(result))
end)
  • name (string): Target plug name
  • method (string): RPC method name
  • params (table): Parameters (sequential/empty tables become msgpack arrays, keyed tables become msgpack maps)
  • callback (function): Called with (err, result) when the plug responds

Timeout: 30 seconds. On timeout, callback receives err = "CallForwardTimeout".

prise.notify_plug(name, method, params)

Send a fire-and-forget notification to a plug (no response).

prise.notify_plug("watchdog", "set_watch_paths", {"/tmp", "/var/log"})

prise.list_plugs(callback)

List all managed plugs and their status.

prise.list_plugs(function(result)
    for _, plug in ipairs(result.plugs) do
        prise.log.info(plug.name .. ": " .. (plug.registered and "connected" or "disconnected"))
    end
end)

Each plug entry contains: name, registered (bool), pid (number or nil), restart_count (number), restart (bool).

Plug events in update()

The tiling window manager handles plug_notification, plug_connected, and plug_disconnected events automatically — each triggers a redraw via prise.request_frame(). Multiple events within 8ms are coalesced into a single render pass.

To act on these events, wrap ui.update in your config:

function M.update(event)
    if event.type == "plug_notification" then
        local plug = event.data.plug        -- plug name
        local method = event.data.method    -- notification method
        local params = event.data.params    -- notification payload
        -- Reduce into your own state, trigger side effects
    elseif event.type == "plug_connected" then
        local plug = event.data.plug        -- plug name
        -- Handle connect (plug registered and ready for calls)
    elseif event.type == "plug_disconnected" then
        local plug = event.data.plug        -- plug name
        -- Handle disconnect
    end
end

State management is a config-level concern — the tiling module provides event forwarding and redraws, your config owns the state and reduction logic. This keeps plug state out of serialization and gives you full control over how data from multiple plugs is composed.

PLUG CLI

prise plug list

List all managed plugs and their status.

$ prise plug list
NAME                 STATUS       PID        RESTARTS   AUTO-RESTART
----                 ------       ---        --------   ------------
watchdog             connected    12345      0          yes
socket-listener      connected    -          1          no

Status values:

  • connected: Plug has registered via socket (may or may not have a running PID — wrapper commands can exit while the child keeps the connection)
  • starting: Plug process is running but hasn't registered yet
  • stopped: Plug process has exited and no socket connection remains

EXAMPLE: PYTHON ECHO PLUG

A minimal plug that registers, handles calls, and sends periodic notifications:

#!/usr/bin/env python3
"""Echo plug for prise — responds to ping, sends periodic heartbeats."""

import os
import socket
import msgpack
import time
import threading

lock = threading.Lock()

def connect():
    sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
    sock.connect(os.environ["PRISE_SOCKET"])
    return sock

def send_rpc(sock, msg):
    with lock:
        sock.sendall(msgpack.packb(msg))

def recv_rpc(sock):
    unpacker = msgpack.Unpacker(raw=False)
    while True:
        data = sock.recv(4096)
        if not data:
            break
        unpacker.feed(data)
        for msg in unpacker:
            yield msg

def heartbeat(sock):
    """Send periodic notifications to the Lua config."""
    while True:
        time.sleep(10)
        send_rpc(sock, [2, "plug.echo.heartbeat", {"time": int(time.time())}])

def main():
    sock = connect()
    reader = recv_rpc(sock)

    # Register with token from environment
    send_rpc(sock, [0, 1, "register_plug", {
        "name": "echo",
        "token": os.environ["PRISE_PLUG_TOKEN"],
        "subscribe": ["pty_exited"],
    }])

    # Wait for registration response
    response = next(reader)
    assert response[0] == 1 and response[2] is None, f"register failed: {response}"

    # Start heartbeat thread
    threading.Thread(target=heartbeat, args=(sock,), daemon=True).start()

    # Handle messages
    for msg in reader:
        if msg[0] == 0:  # Request
            msgid, method, params = msg[1], msg[2], msg[3]
            if method == "ping":
                send_rpc(sock, [1, msgid, None, "pong"])
            else:
                send_rpc(sock, [1, msgid, f"unknown method: {method}", None])

        elif msg[0] == 2:  # Notification (subscribed event)
            method, params = msg[1], msg[2]
            print(f"Event: {method} {params}", flush=True)

if __name__ == "__main__":
    main()

EXAMPLE: LUA CONFIG INTEGRATION

local prise = require("prise")
local ui = prise.tiling()

-- Config-level state for plug data. The tiling module handles redraws;
-- your config owns the state and decides how to reduce notifications.
local plug_state = {}

ui.setup({
    keybinds = {
        -- Ctrl-P: ping the echo plug
        ["<C-p>"] = function()
            prise.call_plug("echo", "ping", {key = "value"}, function(err, result)
                if err then
                    prise.log.error("ping failed: " .. tostring(err))
                else
                    prise.log.info("ping response: " .. tostring(result))
                end
            end)
        end,
    },
})

local orig_update = ui.update
function ui.update(event)
    if event.type == "init" then
        -- Spawn the echo plug with error handling
        prise.spawn_plug({
            name = "echo",
            cmd = {"python3", os.getenv("HOME") .. "/plugs/echo_plug.py"},
            restart = true,
        }, function(err, result)
            if err then
                prise.log.error("Failed to spawn echo plug: " .. tostring(err))
            end
        end)
    elseif event.type == "plug_notification" then
        -- Reduce notification data into config-level state
        if event.data.method == "echo.heartbeat" then
            plug_state.last_heartbeat = event.data.params.time
        end
    elseif event.type == "plug_connected" then
        prise.log.info("Plug connected: " .. event.data.plug)
    elseif event.type == "plug_disconnected" then
        prise.log.warn("Plug disconnected: " .. event.data.plug)
    end
    return orig_update(event)
end

return ui
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment