141 lines
4.5 KiB
Go
141 lines
4.5 KiB
Go
// This file implements plugin discovery and lifecycle management (US-016, #132):
|
|
// finding plugin executables under a config directory, loading each, and
|
|
// aggregating their tools. Loading is fault-tolerant — one plugin that fails to
|
|
// start or handshake is logged and skipped so the rest still load.
|
|
package plugin
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
|
|
"github.com/smallnest/pigo/internal/agentcore"
|
|
)
|
|
|
|
// Manager owns a set of loaded plugins and their aggregated tools. It is not safe
|
|
// for concurrent modification; load once at startup, then read.
|
|
type Manager struct {
|
|
plugins []*Plugin
|
|
}
|
|
|
|
// Discover finds and loads every plugin under dir. A plugin is any executable
|
|
// regular file directly inside dir (non-executable files and subdirectories are
|
|
// ignored). Each plugin is launched and handshaked; a failure is written to
|
|
// warnLog (when non-nil) and that plugin is skipped. A missing dir is not an
|
|
// error — it yields an empty Manager. pluginStderr, when non-nil, receives every
|
|
// plugin's stderr.
|
|
func Discover(dir string, warnLog, pluginStderr io.Writer) (*Manager, error) {
|
|
m := &Manager{}
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return m, nil // no plugins directory → no plugins
|
|
}
|
|
return nil, fmt.Errorf("plugin: read dir %q: %w", dir, err)
|
|
}
|
|
// Deterministic load order for stable tool ordering and diagnostics.
|
|
sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() })
|
|
|
|
for _, e := range entries {
|
|
if e.IsDir() {
|
|
continue
|
|
}
|
|
info, err := e.Info()
|
|
if err != nil || !isExecutable(info.Mode()) {
|
|
continue
|
|
}
|
|
path := filepath.Join(dir, e.Name())
|
|
p, err := Load(path, nil, pluginStderr)
|
|
if err != nil {
|
|
if warnLog != nil {
|
|
fmt.Fprintf(warnLog, "pigo: plugin %q failed to load: %v\n", e.Name(), err)
|
|
}
|
|
continue
|
|
}
|
|
m.plugins = append(m.plugins, p)
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// isExecutable reports whether the file mode has any execute bit set.
|
|
func isExecutable(mode os.FileMode) bool {
|
|
return mode&0o111 != 0
|
|
}
|
|
|
|
// Tools returns the aggregated tools of every loaded plugin, in load order.
|
|
func (m *Manager) Tools() []agentcore.AgentTool {
|
|
var out []agentcore.AgentTool
|
|
for _, p := range m.plugins {
|
|
out = append(out, p.Tools()...)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Plugins returns the loaded plugins (for command aggregation and diagnostics).
|
|
func (m *Manager) Plugins() []*Plugin { return m.plugins }
|
|
|
|
// PluginCommand pairs a plugin-declared slash command with the plugin that owns
|
|
// it, so a caller can dispatch the command back to its plugin via
|
|
// Plugin.CallCommand.
|
|
type PluginCommand struct {
|
|
// Plugin is the plugin that declared and handles this command.
|
|
Plugin *Plugin
|
|
// Spec is the command's declaration from the owning plugin's manifest.
|
|
Spec CommandSpec
|
|
}
|
|
|
|
// Commands returns the aggregated slash commands of every loaded plugin, in load
|
|
// order (and, within a plugin, in manifest order). Each carries the owning
|
|
// plugin so the caller can dispatch it via Plugin.CallCommand.
|
|
func (m *Manager) Commands() []PluginCommand {
|
|
var out []PluginCommand
|
|
for _, p := range m.plugins {
|
|
for _, spec := range p.Manifest.Commands {
|
|
out = append(out, PluginCommand{Plugin: p, Spec: spec})
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Subscribers reports whether any loaded plugin subscribes to the given event
|
|
// type. It lets a caller skip building an event payload when nobody is listening
|
|
// (US-017, #133).
|
|
func (m *Manager) Subscribers(eventType string) bool {
|
|
for _, p := range m.plugins {
|
|
if p.Subscribes(eventType) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// DispatchEvent delivers one lifecycle event to every plugin subscribed to its
|
|
// type (US-017, #133). Delivery is best-effort and isolated: each plugin's send
|
|
// is bounded by eventTimeout, and a delivery failure to one plugin (timeout,
|
|
// dead process) is written to warnLog when non-nil and does not stop delivery to
|
|
// the others. It never blocks the agent loop beyond the per-plugin timeout.
|
|
func (m *Manager) DispatchEvent(params EventParams, warnLog io.Writer) {
|
|
for _, p := range m.plugins {
|
|
if !p.Subscribes(params.Type) {
|
|
continue
|
|
}
|
|
if err := p.SendEvent(params); err != nil && warnLog != nil {
|
|
fmt.Fprintf(warnLog, "pigo: plugin %q event %q: %v\n", p.Manifest.Name, params.Type, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Close shuts down every loaded plugin, returning the first error encountered
|
|
// (all plugins are attempted regardless).
|
|
func (m *Manager) Close() error {
|
|
var firstErr error
|
|
for _, p := range m.plugins {
|
|
if err := p.Close(); err != nil && firstErr == nil {
|
|
firstErr = err
|
|
}
|
|
}
|
|
return firstErr
|
|
}
|