// 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 }