plugin

package
v0.17.4 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 16, 2026 License: MIT Imports: 24 Imported by: 0

Documentation

Overview

Package plugin implements a Lua-based plugin host for hygge.

Overview

Plugins are portable .lua scripts (or directories containing one) that register into hygge's existing tool / hook / command / subagent registries through a neovim-style hygge.* global module. Sources are declared in config.toml as github: or local: URIs and managed via `hygge plugins install/update/remove/list/show`.

Architecture

The core abstractions are:

  • Plugin: one loaded plugin. Implementations: luaPlugin today, subprocessPlugin reserved for later. The Registry owns plugins and dispatches lifecycle calls into them.
  • Host: the bridge from a plugin into hygge's running state.
  • Loader: the extension point that decides which runtime to use for a given plugin manifest.
  • Registry: manages the set of installed plugins and their lifecycle.

Lua plugin lifecycle

When a Lua plugin is loaded the script is executed top-to-bottom inside a sandboxed gopher-lua LState. During that initial execution the script calls hygge.register_tool / hygge.register_hook / etc. to declare its contributions. After Load returns, the registered adapters are live in the host registries. The LState is kept alive for the process lifetime; each adapter invocation acquires a per-plugin mutex before touching the LState.

Concurrency

gopher-lua's LState is NOT safe for concurrent use. Each luaPlugin holds a single LState and a sync.Mutex that serialises all calls into it. Concurrent tool/hook/command invocations are queued per plugin. This is by design: plugin code is rarely the bottleneck, and the alternative (one LState per goroutine with shared state through channels) is vastly more complex.

Error handling

Any Lua runtime error during a handler causes the handler to fail-open (return Allow / pass-through) with a slog.Warn — mirroring the shell-hook contract established in the hook package (T1.4). Errors during Load surface as proper Go errors so the Registry can skip the offending plugin without crashing the others.

Forward compatibility: subprocess plugins

The Plugin and Host interfaces are implementation-agnostic. A future subprocess JSON-RPC loader can implement Plugin / Host and slot in without touching the Registry or any caller. The Loader interface is the declared extension point. A stub SubprocessLoader is included in this package to keep the abstraction honest; it always errors.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func LuaLSTypeStub added in v0.4.1

func LuaLSTypeStub() []byte

LuaLSTypeStub returns Hygge's LuaLS/LuaCATS definition file for plugin authors. Callers receive a copy so the embedded bytes cannot be mutated.

Types

type CapabilityDecl

type CapabilityDecl struct {
	Tools     bool `toml:"tools"`
	Hooks     bool `toml:"hooks"`
	Commands  bool `toml:"commands"`
	Subagents bool `toml:"subagents"`
	Messages  bool `toml:"messages"`
}

CapabilityDecl is the optional [capabilities] block in plugin.toml.

type CommandRegistration

type CommandRegistration struct {
	// Name is the command name without the leading slash.
	Name string

	// Description is the one-line summary.
	Description string

	// Args declares the named arguments.
	Args []command.ArgSpec

	// Execute runs the command.  Returns the outcome text (surfaced as
	// Outcome.Message).
	Execute func(ctx context.Context, input string) (string, error)
}

CommandRegistration describes a slash command being registered by a plugin.

type ExecOptions

type ExecOptions struct {
	// Env is extra environment variables to pass.  Merged on top of
	// the parent's environment filtered through procenv.Allowlist.
	Env map[string]string

	// Dir is the working directory; empty means the session's pwd.
	Dir string

	// Timeout caps the subprocess runtime.  Zero means 30 s.
	Timeout time.Duration
}

ExecOptions configures a plugin Exec call.

type ExecResult

type ExecResult struct {
	Stdout string
	Stderr string
	Code   int
}

ExecResult holds the result of a plugin Exec call.

type HookRegistration

type HookRegistration struct {
	// Name is the unique hook name.
	Name string

	// Event is the hook.Event this hook fires for.
	Event hook.Event

	// Mode is the hook execution mode.  Defaults to hook.ModeSync.
	Mode hook.Mode

	// Timeout is the per-invocation timeout.  Defaults to 5 s.
	Timeout time.Duration

	// Handler is the Go function to invoke.
	Handler func(ctx context.Context, in hook.Input) (hook.Action, error)
}

HookRegistration describes a hook being registered by a plugin.

type Host

type Host interface {
	// PluginName returns the calling plugin's name, used for logging and
	// rate-limit accounting.  Each plugin's bindings carry their own Host
	// wrapper that fills this in.
	PluginName() string

	RegisterTool(PluginTool) error
	RegisterHook(HookRegistration) error
	RegisterCommand(CommandRegistration) error
	RegisterSubagent(SubagentRegistration) error

	// SendMessage injects a message into sessionID with the given role and
	// content.  Role must be "user" or "assistant".  Plugin calls are rate-
	// limited to 10 per turn to prevent runaway loops.
	SendMessage(ctx context.Context, sessionID, role, content string) error

	// Notify emits a user-visible notification.  level is "info", "warn",
	// or "error".
	Notify(level, message string)

	// Log writes a structured log entry under the plugin's name.
	Log(level, message string, fields map[string]any)

	// Exec runs a subprocess.  The call goes through the permission engine
	// under CategoryShell so a plugin cannot bypass user policy.
	Exec(ctx context.Context, command string, args []string, opts ExecOptions) (ExecResult, error)

	// Config returns the [plugins.<plugin-name>] TOML table as a generic
	// map.  Empty when no overrides are set.
	Config() map[string]any

	// ProfileDir returns the resolved active profile directory, or "" when
	// no named profile is active.  Plugins can use this to load files that
	// live adjacent to the active profile config.
	ProfileDir() string
}

Host is the bridge from a plugin into hygge's running state.

Every Plugin implementation receives a Host at Load time. The Lua loader implements gopher-lua bindings that route to Host; a future subprocess loader would implement a JSON-RPC dispatcher that does the same.

Host MUST be safe for concurrent use.

type Loader

type Loader interface {
	// CanLoad inspects the manifest and the files at dir and returns true
	// if this loader handles them.  Must not read the entrypoint file.
	CanLoad(dir string, m Manifest) bool

	// Load creates and initialises a Plugin.  Called after CanLoad returns
	// true.  The returned Plugin has not yet had Load(ctx, h) called on it;
	// that is the caller's responsibility.
	Load(name, source, dir string, m Manifest) (Plugin, error)
}

Loader is the extension point that decides which plugin runtime to use.

For v0.3 only LuaLoader is provided. A future SubprocessLoader would handle non-.lua entrypoints via a JSON-RPC subprocess protocol.

The Registry tries each registered Loader in order and uses the first one that reports CanLoad = true. If no loader matches, plugin load fails with a clear error.

type LuaLoader

type LuaLoader struct{}

LuaLoader is the concrete loader for .lua-based plugins.

func (LuaLoader) CanLoad

func (l LuaLoader) CanLoad(_ string, m Manifest) bool

CanLoad returns true when the manifest's Entrypoint ends in ".lua", or when no manifest exists and a plugin.lua file is present in dir.

func (LuaLoader) Load

func (l LuaLoader) Load(name, source, dir string, m Manifest) (Plugin, error)

Load creates a luaPlugin. The script is not executed until Plugin.Load is called.

type Manifest

type Manifest struct {
	// Name is the unique plugin identifier.  Derived from the manifest
	// file or synthesised from the source URI.
	Name string `toml:"name"`

	// Version is a human-facing version string (e.g. "1.2.3").
	// Optional.
	Version string `toml:"version"`

	// Description is the one-line summary.  Optional.
	Description string `toml:"description"`

	// Entrypoint is the path to the entry .lua file, relative to the
	// manifest directory.  Defaults to "plugin.lua" when absent.
	Entrypoint string `toml:"entrypoint"`

	// Capabilities is an optional declaration of which hygge APIs the
	// plugin uses.  UX-only; registrations happen at runtime regardless.
	Capabilities CapabilityDecl `toml:"capabilities"`
	// contains filtered or unexported fields
}

Manifest is the parsed content of a plugin.toml manifest file.

Single-file plugins (a lone plugin.lua at the root of a repo) do not require a manifest; the loader synthesises a trivial one with Name = basename of the source URI and Entrypoint = "plugin.lua".

func ParseManifest

func ParseManifest(data []byte) (Manifest, error)

ParseManifest reads and validates a plugin.toml file.

func SynthesiseManifest

func SynthesiseManifest(name string) Manifest

SynthesiseManifest creates a minimal Manifest for a single-file plugin. name is the plugin name (typically the source URI basename).

func (Manifest) Synthesised

func (m Manifest) Synthesised() bool

Synthesised reports whether this manifest was created by the loader (single-file plugin, no explicit plugin.toml).

type OwnedRegistration

type OwnedRegistration struct {
	Owner string // plugin name; "" for builtins
	Name  string
	Kind  string // "tool", "hook", "command", "subagent"
}

OwnedRegistration tracks which plugin registered a tool/hook/command/subagent so they can be cleaned up in a future reload operation.

type PackageManager

type PackageManager struct {
	// contains filtered or unexported fields
}

PackageManager handles plugin source resolution and cache management.

func NewPackageManager

func NewPackageManager(cacheDir string) *PackageManager

NewPackageManager constructs a PackageManager using the given cacheDir. Typically $XDG_STATE_HOME/hygge/plugins.

func NewPackageManagerWithGitRunner added in v0.4.0

func NewPackageManagerWithGitRunner(cacheDir string, runner gitexec.Runner) *PackageManager

NewPackageManagerWithGitRunner constructs a PackageManager with an injected git runner. Passing nil uses the production non-interactive runner.

func (*PackageManager) CacheDir

func (pm *PackageManager) CacheDir(src Source) string

CacheDir returns the cache directory for the given source.

func (*PackageManager) Remove

func (pm *PackageManager) Remove(src Source) error

Remove deletes the plugin's cache directory. For local sources it's a no-op (we never copy local dirs into the cache).

func (*PackageManager) Resolve

func (pm *PackageManager) Resolve(ctx context.Context, src Source) (dir string, err error)

Resolve ensures the plugin source is available on disk and returns the directory containing the plugin files (plugin.toml + entry .lua).

For local: sources, the path is returned directly (no copy). For github: sources, the repo is cloned into the cache on first call and the cache path is returned.

func (*PackageManager) Update

func (pm *PackageManager) Update(ctx context.Context, src Source) error

Update pulls the latest changes for a plugin in the cache. For local sources it's a no-op.

type Plugin

type Plugin interface {
	// Name returns the plugin's unique identifier (derived from the manifest
	// or the source URI basename).
	Name() string

	// Source returns the URI the plugin was installed from, e.g.
	// "github:cfbender/hygge-policy-guard" or
	// "local:/Users/cfb/code/my-plugin".
	Source() string

	// Manifest returns the parsed manifest for this plugin.
	Manifest() Manifest

	// Load runs the plugin's initialisation.  For Lua this executes the
	// script top-to-bottom in its sandbox.  A future subprocess plugin
	// would spawn the process and exchange the initialize handshake.
	//
	// Load is called exactly once, before any dispatch.  Calling Load twice
	// is a programmer error.
	Load(ctx context.Context, h Host) error

	// Close releases resources.  For Lua, closes the LState; for a
	// subprocess, sends shutdown and force-kills on timeout.  Idempotent.
	Close(ctx context.Context) error
}

Plugin is one loaded plugin.

Implementations: luaPlugin today, subprocessPlugin tomorrow. The Registry owns plugins and dispatches lifecycle calls into them.

Implementations need not be safe for concurrent calls to non-event methods (Load, Close). Execute/handler dispatch methods MUST be safe for concurrent use since multiple sessions may invoke a plugin simultaneously — the luaPlugin implementation satisfies this through a per-plugin mutex.

type PluginTool

type PluginTool struct {
	// Name is the stable identifier the model uses to invoke this tool.
	// Must match [a-z][a-z0-9_]*.
	Name string

	// Description is the human-language summary surfaced to the model.
	Description string

	// InputSchema is a JSON Schema object for the model.  When nil, an
	// empty object schema is synthesised.
	InputSchema json.RawMessage

	// Parallelizable controls whether the tool may be invoked concurrently
	// with other parallelizable tools in the same turn.  Defaults to false
	// for safety — plugin tools should only set this to true when their
	// Execute function has no observable side effects beyond reading state
	// (e.g. searching a local index, calling a read-only external API).
	//
	// Plugin tools registered with Parallelizable = true are subject to the
	// same concurrency semantics as built-in parallel tools: their bus events
	// arrive in undefined order relative to sibling parallel calls, and the
	// gopher-lua LState mutex serialises execution within a single Lua plugin
	// even when Parallelizable is true.
	Parallelizable bool

	// Execute runs the tool.
	Execute func(ctx context.Context, input json.RawMessage) (PluginToolResult, error)
}

PluginTool describes a tool being registered by a plugin.

type PluginToolResult

type PluginToolResult struct {
	Content string
	IsError bool
}

PluginToolResult is the outcome of a plugin tool call.

type Registry

type Registry struct {
	// contains filtered or unexported fields
}

Registry manages the set of installed plugins and dispatches lifecycle calls.

func NewRegistry

func NewRegistry(opts RegistryOptions) (*Registry, error)

NewRegistry constructs an empty Registry.

func (*Registry) Close

func (r *Registry) Close(ctx context.Context) error

Close closes all loaded plugins.

func (*Registry) Get

func (r *Registry) Get(name string) (Plugin, bool)

Get returns the plugin with the given name, or (nil, false).

func (*Registry) Install

func (r *Registry) Install(ctx context.Context, uri string) error

Install resolves, validates, and loads a plugin from the given source URI. Unlike LoadAll (which silently skips failures), Install returns an error so the CLI can surface it to the user.

func (*Registry) List

func (r *Registry) List() []Plugin

List returns all loaded plugins, sorted by name.

func (*Registry) LoadAll

func (r *Registry) LoadAll(ctx context.Context, sourceURIs []string)

LoadAll loads every plugin from the given list of source URIs. Failures in individual plugins are logged and skipped — one bad plugin should not block the rest from loading.

func (*Registry) OwnedRegistrations

func (r *Registry) OwnedRegistrations() []OwnedRegistration

OwnedRegistrations returns a copy of the owned registration list. Useful for auditing which plugin contributed which tool/hook/command/subagent.

func (*Registry) PM

func (r *Registry) PM() *PackageManager

PM returns the underlying PackageManager, for CLI commands that need to inspect cache directories.

func (*Registry) Remove

func (r *Registry) Remove(ctx context.Context, name string) error

Remove closes a plugin and deletes its cache.

func (*Registry) SetInjectMessage

func (r *Registry) SetInjectMessage(fn func(ctx context.Context, sessionID, role, content string) error)

SetInjectMessage wires the InjectMessage callback after construction. This allows the plugin registry to be created before the agent is available (avoiding a circular dependency), with the callback set once the agent is constructed.

func (*Registry) Source

func (r *Registry) Source(name string) (Source, bool)

Source returns the Source for a plugin name, or zero-value and false.

func (*Registry) UnregisterAll added in v0.4.0

func (r *Registry) UnregisterAll(pluginName string)

UnregisterAll removes every host registration owned by pluginName from the tool, hook, slash-command, and subagent registries. It is safe to call for a plugin that has no tracked registrations.

func (*Registry) Update

func (r *Registry) Update(ctx context.Context, name string) error

Update re-fetches a plugin's source and reloads it.

type RegistryOptions

type RegistryOptions struct {
	// CacheDir is the root directory for plugin caches.
	// Defaults to $XDG_STATE_HOME/hygge/plugins.
	CacheDir string

	// ToolRegistry receives plugin-registered tools.  Required.
	Tools *tool.Registry

	// HookRegistry receives plugin-registered hooks.  Required.
	Hooks *hook.Registry

	// CommandRegistry receives plugin-registered commands.  Required.
	Commands *command.Registry

	// SubagentRegistry receives plugin-registered subagent types.  Required.
	Subagents *subagent.Registry

	// Permission is the engine used by Exec calls from plugins.  Required.
	Permission *permission.Engine

	// InjectMessage, when non-nil, is called by plugins that invoke
	// hygge.send_message.  Typically wired to agent.Agent.InjectMessage.
	InjectMessage func(ctx context.Context, sessionID, role, content string) error

	// PluginConfigs maps plugin names to their [plugins.<name>] TOML tables.
	PluginConfigs map[string]map[string]any

	// ProfileDir is the resolved active profile directory.  Plugins can use
	// this to load files that live next to the profile config.  May be empty
	// when no profile is active or the profile is the unnamed default.
	ProfileDir string

	// Pwd is the working directory for Exec calls.
	Pwd string

	// Loaders is the ordered set of loaders to try.  When nil,
	// defaultLoaders() is used.
	Loaders []Loader
}

RegistryOptions configures a plugin Registry.

type Source

type Source struct {
	Kind   SourceKind
	Raw    string // the original URI string
	User   string // github only: owner
	Repo   string // github only: repository
	Path   string // local only: filesystem path (absolute)
	Ref    string // github only: tag, branch, or commit sha. "" = default branch
	Branch bool   // true when Ref was specified with # (known branch, not tag)
}

Source represents a parsed plugin source URI.

func ParseSource

func ParseSource(uri string) (Source, error)

ParseSource parses a plugin source URI.

Grammar:

github:USER/REPO             → default branch
github:USER/REPO@REF        → tag, commit, or branch via @ separator
github:USER/REPO#BRANCH     → explicitly a branch (use # to distinguish from tags)
local:/abs/path             → no clone, use path directly
npm:...                     → error: not supported in v0.3

func (Source) CacheDirName

func (s Source) CacheDirName() string

CacheDirName returns a safe filesystem directory name for this source. Format: <scheme>-<user-or-hash>-<repo>-<ref>

func (Source) CloneURL

func (s Source) CloneURL() string

CloneURL returns the HTTPS clone URL for a GitHub source.

type SourceKind

type SourceKind string

SourceKind is the type of plugin source URI.

const (
	// SourceGitHub is a plugin sourced from a GitHub repository.
	SourceGitHub SourceKind = "github"
	// SourceLocal is a plugin sourced from a local filesystem path.
	SourceLocal SourceKind = "local"
)

Source kind constants.

type SubagentRegistration

type SubagentRegistration struct {
	// Name is the stable identifier.
	Name string

	// Description is the one-line summary.
	Description string

	// SystemPrompt is the full system prompt.
	SystemPrompt string

	// Tools is the tool-name allowlist.  Empty means "default sub-agent
	// tools".
	Tools []string

	// Model, when non-empty, overrides the parent's provider/model.
	// Shape: "<provider>/<model-id>".
	Model string
}

SubagentRegistration describes a sub-agent type being registered by a plugin.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL