plugins

package
v0.0.0-...-20cd8ba Latest Latest
Warning

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

Go to latest
Published: Jun 30, 2026 License: MIT Imports: 21 Imported by: 0

Documentation

Overview

Package plugins implements tau's process-isolated plugin host.

The plugin protocol is gRPC over an anonymous Unix-domain socket, with hashicorp/go-plugin handling process lifecycle (spawn, heartbeat, crash detection, graceful shutdown). A panicking plugin cannot panic the host.

Two sides:

  • Host side (this package): spawns plugin subprocesses, dispatches tool calls, observes crashes, restarts per the policy.
  • Plugin side (an external Go binary): calls plugin.Serve with a Server implementation of the proto.Plugin service.

Both sides reference HandshakeConfig and PluginMap. Host-side code uses HostClient (host.go) to wrap a single spawned plugin. Manager (manager.go) discovers, owns, and recovers HostClients.

Index

Constants

View Source
const (
	MagicCookieKey   = "TAU_PLUGIN_MAGIC_COOKIE"
	MagicCookieValue = "tau-plugin-protocol-v1"
)

MagicCookieKey is the environment variable name the host sets before spawning a plugin. The plugin MUST verify the value matches MagicCookieValue before initializing; this prevents random binaries from being treated as plugins when misused as go-plugin servers.

View Source
const (
	RestartAlwaysMaxAttempts = 5
	RestartAlwaysWindow      = 30 * time.Second
)

RestartAlwaysMaxAttempts caps the respawn attempts under RestartAlways within RestartAlwaysWindow. Prevents an infinite loop on a panic-at-startup plugin.

View Source
const PluginName = "tau"

PluginName is the key the host and plugin use to register the gRPC service in their respective PluginMaps. go-plugin supports multiple plugins per process; tau ships exactly one (the Plugin service) so a fixed name suffices.

View Source
const PluginPrefix = "tau-plugin-"

PluginPrefix is the executable-name prefix the spec requires for discovery. Files in a plugins directory that do not start with this prefix are ignored.

View Source
const ProtocolVersion uint = 1

ProtocolVersion is the wire-protocol version this host build expects. Plugins announce their version via Plugin.Handshake; the host rejects mismatches. Bumping this constant is a breaking change and requires a tau minor-version bump.

History:

1 = initial release.

The type is `uint` to satisfy go-plugin's HandshakeConfig.ProtocolVersion field; handshake comparisons cast to int32 to match the proto response.

View Source
const SpawnDefaultTimeout = 15 * time.Second

SpawnDefaultTimeout is the default HostConfig.StartTimeout. Generous because plugin startup may include BPE encoding table loads or schema reflection.

Variables

HandshakeConfig is the go-plugin handshake shared by host and plugin. Both sides reference this exact value.

View Source
var PluginFileExt = windowsExt()

PluginFileExt is the Windows executable extension. On non-Windows it is empty; on Windows it is appended to PluginPrefix when matching.

View Source
var PluginMap = map[string]plugin.Plugin{
	PluginName: &GRPCPlugin{},
}

PluginMap is the host-side PluginMap. The plugin side builds its own equivalent via NewPluginAdapter (server.go).

Functions

func Handshake

func Handshake(ctx context.Context, pluginName string, client tauproto.PluginClient) error

Handshake calls Plugin.Handshake on the client and verifies the version. Returns ErrProtocolMismatch on version drift, errNoHandshake on RPC failure. Caller is responsible for emitting diagnostics and deciding whether to skip-and-continue or fail-fast.

func HostGRPCRegistrar

func HostGRPCRegistrar(host tauproto.HostServer) func(opts []grpc.ServerOption) *grpc.Server

HostGRPCRegistrar is the hook go-plugin's GRPCServer uses to register the host-side Host service on the same gRPC server as the broker. Without this, plugin→host RPCs fail with "unknown service Host".

The Manager sets this up by wrapping plugin.DefaultGRPCServer with a registrar that calls tauproto.RegisterHostServer.

Types

type ConfigSource

type ConfigSource interface {
	// GetConfig returns the value for the dotted key, or found=false
	// if not present.
	GetConfig(key string) (value string, found bool)
}

ConfigSource resolves a dotted-path configuration key (e.g. "compaction.reserveTokens") to a string value. Returns found=false if the key is absent. Implementations are responsible for redacting sensitive keys (e.g. API keys, OAuth tokens) — they should return found=false rather than leak the value.

func NoopConfigSource

func NoopConfigSource() ConfigSource

NoopConfigSource returns found=false for every key. Used in tests and as a safe default when no real ConfigSource is available.

type ConfigSourceFunc

type ConfigSourceFunc func(key string) (string, bool)

ConfigSourceFunc is the func adapter for ConfigSource.

func (ConfigSourceFunc) GetConfig

func (f ConfigSourceFunc) GetConfig(key string) (string, bool)

GetConfig delegates to the underlying func.

type DiscoveredPath

type DiscoveredPath struct {
	ShortName string // e.g. "git"; what follows PluginPrefix
	AbsPath   string // absolute path to the executable
	Source    PluginSource
}

DiscoveredPath is one entry returned by Discover's enumeration. The Manager uses these to spawn HostClients after applying the project-shadows-global rule.

type ErrAlreadyShutdown

type ErrAlreadyShutdown struct{ Name string }

ErrAlreadyShutdown is returned by Spawn (and other stateful methods) after Shutdown has been called on a HostClient. The client is dead.

func (ErrAlreadyShutdown) Error

func (e ErrAlreadyShutdown) Error() string

type ErrNotRunning

type ErrNotRunning struct {
	Name  string
	State string
}

ErrNotRunning is returned when a method requires PluginStateRunning but the subprocess is not currently alive. The Manager may interpret this as a signal to apply the on-next-call restart policy.

func (ErrNotRunning) Error

func (e ErrNotRunning) Error() string

type ErrProtocolMismatch

type ErrProtocolMismatch struct {
	PluginName string
	PluginVer  int32
	HostVer    int32
}

ErrProtocolMismatch is returned by Handshake when a plugin announces a protocol version incompatible with this host's ProtocolVersion.

func (*ErrProtocolMismatch) Error

func (e *ErrProtocolMismatch) Error() string

type ErrUnknownPluginTool

type ErrUnknownPluginTool struct{ Name string }

ErrUnknownPluginTool is returned by Execute when no plugin owns the requested tool name. The agent loop surfaces this to the model as a tool-call failure.

func (ErrUnknownPluginTool) Error

func (e ErrUnknownPluginTool) Error() string

type GRPCPlugin

type GRPCPlugin struct {
	plugin.NetRPCUnsupportedPlugin

	// ServerImpl is the proto.PluginServer the plugin process exposes.
	// nil on the host side.
	ServerImpl tauproto.PluginServer
}

GRPCPlugin is the bridge between go-plugin's plugin.Plugin interface and the proto Plugin service. The same type is registered in both the host's and the plugin's PluginMap:

  • On the plugin side, ServerImpl returns the proto.PluginServer implementation. The plugin author passes it via NewPluginAdapter.
  • On the host side, GRPCClient returns a Client that wraps the gRPC connection; the host uses it via HostClient (host.go).

The split exists because go-plugin's net/rpc and gRPC subtypes want one factory per "plugin name", but the host and plugin processes occupy opposite ends of the same wire.

func (*GRPCPlugin) GRPCClient

func (p *GRPCPlugin) GRPCClient(_ context.Context, _ *plugin.GRPCBroker, c *grpc.ClientConn) (any, error)

GRPCClient is called by go-plugin on the host to wrap the dispensed gRPC client. Plugin side never calls this. The returned value is what HostClient.pluginClient holds.

func (*GRPCPlugin) GRPCServer

func (p *GRPCPlugin) GRPCServer(_ *plugin.GRPCBroker, s *grpc.Server) error

GRPCServer is called by go-plugin on the plugin process to register the gRPC service. Host side never calls this.

type HostClient

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

HostClient owns the lifecycle of one plugin subprocess. It is the boundary between the Manager (which coordinates discovery, restart, and tool registration) and go-plugin's per-process primitives.

HostClient is goroutine-safe: concurrent Execute and ListTools calls are serialized by an internal mutex, and crash detection runs in its own goroutine.

func NewHostClient

func NewHostClient(cfg HostConfig) (*HostClient, error)

NewHostClient constructs a HostClient in PluginStateNew. Spawn must be called separately to launch the subprocess.

func (*HostClient) CrashSignals

func (h *HostClient) CrashSignals() <-chan struct{}

CrashSignals returns a channel that receives one signal per unexpected subprocess exit. The Manager uses this to apply the restart policy. The channel is buffered(1) and never closes; observers should select non-blocking or drain in a loop.

func (*HostClient) Handshake

func (h *HostClient) Handshake(ctx context.Context) error

Handshake verifies the plugin's announced protocol version. Must be called after Spawn; calling before Spawn returns ErrNotRunning.

func (*HostClient) ListTools

func (h *HostClient) ListTools(ctx context.Context) ([]*PluginTool, error)

ListTools calls Plugin.ListTools and caches the result. Subsequent calls return the cache without an RPC. The cache is cleared on re-spawn so schema drift is detected on restart.

Returns PluginTool instances whose clientProvider points back at this HostClient's live gRPC client.

func (*HostClient) Name

func (h *HostClient) Name() string

Name returns the plugin's short identifier.

func (*HostClient) Path

func (h *HostClient) Path() string

Path returns the plugin executable path.

func (*HostClient) Shutdown

func (h *HostClient) Shutdown(ctx context.Context) error

Shutdown calls Plugin.Shutdown, waits up to grace for a clean exit, then SIGKILLs the subprocess if still alive and transitions to PluginStateShutdown. Idempotent; second and later calls are no-ops.

func (*HostClient) Spawn

func (h *HostClient) Spawn(ctx context.Context) error

Spawn launches the subprocess and establishes the gRPC channel. It is idempotent for PluginStateNew and PluginStateCrashed; on PluginStateRunning it is a no-op; on PluginStateShutdown it returns ErrAlreadyShutdown.

func (*HostClient) State

func (h *HostClient) State() PluginState

State returns the current lifecycle state. Safe to call from any goroutine; the value may change asynchronously as the subprocess runs.

type HostConfig

type HostConfig struct {
	// Name is the plugin's short identifier (e.g. "git"). Used in
	// diagnostics, tool namespacing ("<name>.<localName>"), and the
	// PluginMap key.
	Name string
	// Path is the absolute path to the plugin executable.
	Path string
	// HostServer is the host-side Host service implementation the plugin
	// will call back to for Log/Notify/GetConfig. Required: plugins
	// legitimately fail when their Host callbacks return "not
	// implemented".
	HostServer tauproto.HostServer
	// Stderr captures the plugin's raw stderr stream. Plugin logs (via
	// the host's Host.Log RPC) and go-plugin's own diagnostics land
	// here. Defaults to io.Discard.
	Stderr io.Writer
	// Restart is the policy applied when the subprocess exits
	// unexpectedly. See RestartPolicy docs for semantics.
	Restart RestartPolicy
	// StartTimeout caps how long HostClient.Spawn waits for go-plugin's
	// handshake (magic cookie negotiation) before giving up. Defaults
	// to SpawnDefaultTimeout.
	StartTimeout time.Duration
}

HostConfig carries the host-side inputs to a HostClient. It is captured at construction time and reused across restarts so a recovered plugin comes back with the same environment.

type HostServer

type HostServer struct {
	tauproto.UnimplementedHostServer

	// LogWriter is the destination for Host.Log calls. Defaults to
	// io.Discard; tests inject io.Discard or a buffer to assert.
	LogWriter io.Writer
	// Logger is structured logger used when LogWriter is nil but
	// Logger is non-nil. If both are nil, Host.Log is silently
	// discarded.
	Logger *log.Logger
	// ConfigSource resolves dotted config keys for Host.GetConfig.
	// Plugins should not read secrets through this RPC; the
	// ConfigSource is responsible for redacting sensitive keys.
	ConfigSource ConfigSource
	// NotifyHandler is invoked per Host.Notify call. NotifyHandler
	// should be non-blocking; long work should be queued off-thread.
	NotifyHandler func(severity, message string)
	// contains filtered or unexported fields
}

HostServer implements the proto Host service that the host exposes to plugins. Plugins call Host.Log, Host.Notify, and Host.GetConfig to surface diagnostics, request user notifications, and read host config.

HostServer is registered on the same gRPC server that go-plugin sets up for plugin→host callbacks via the broker. The plugin side dials it through the broker-supplied connection.

func NewHostServer

func NewHostServer(logWriter io.Writer, cfg ConfigSource, notify func(string, string)) *HostServer

NewHostServer constructs a HostServer. nil ConfigSource becomes NoopConfigSource; nil NotifyHandler is a no-op.

func (*HostServer) GetConfig

GetConfig implements Host.GetConfig. Returns the value or found=false if the key is absent (or redacted by the ConfigSource).

func (*HostServer) Log

Log implements Host.Log. Plugins call this for diagnostic output. The level controls prefix only; the host does not rate-limit.

func (*HostServer) Notify

Notify implements Host.Notify. Routes a user-facing notification to the host's event bus via the configured handler.

func (*HostServer) SetLogWriter

func (s *HostServer) SetLogWriter(w io.Writer)

SetLogWriter swaps the log destination. Safe to call after the host has started; concurrent Log calls serialize on the internal mutex.

type Manager

type Manager struct {
	// ProjectPluginsDir is the absolute path to <cwd>/plugins/. May be
	// empty if the cwd has no plugins/ dir; Manager still scans
	// GlobalPluginsDir.
	ProjectPluginsDir string
	// GlobalPluginsDir is the absolute path to ~/.config/tau/plugins/.
	// May be empty.
	GlobalPluginsDir string

	// HostServer is the host-side Host service implementation passed
	// to every HostClient. Required.
	HostServer tauproto.HostServer

	// HostServerLogWriter is the writer HostServer uses for plugin
	// log lines. May be nil (defaults to io.Discard via NewHostServer).
	HostServerLogWriter io.Writer

	// DefaultRestartPolicy is applied to plugins that don't override
	// it. Defaults to RestartOnNextCall — the least-surprising policy
	// for a long-lived agent.
	DefaultRestartPolicy RestartPolicy

	// Diagnostics receives one message per significant lifecycle event
	// (collision, shadow, version mismatch, restart, crash). May be nil;
	// the Manager silently drops diagnostics then. Each message is a
	// single line ending in \n.
	Diagnostics chan<- string
	// contains filtered or unexported fields
}

Manager owns the set of HostClients for a session. It is the public boundary the agent runtime and CLI use: callers Discover() once at startup, then call Tools() to get a flat list of namespaced tools to register with the agent's tool registry, then call Execute() for each model-issued tool call.

Manager also coordinates restart policies, collision detection, and clean shutdown across all plugins.

func NewManager

func NewManager(projectDir, globalDir string, hostServer tauproto.HostServer) (*Manager, error)

NewManager constructs a Manager in New state. Discover must be called separately. Returns an error if required fields are missing.

func (*Manager) Discover

func (m *Manager) Discover() ([]DiscoveredPath, error)

Discover scans ProjectPluginsDir and GlobalPluginsDir for executable files whose names start with PluginPrefix. Project-local shadows global: when both directories have a same-named plugin, the project one wins and a diagnostic is emitted. Non-matching and non-executable files are silently skipped.

Discover is the canonical "is the on-disk layout what the spec requires" check. Callers should run it once at startup; the result is cached by SpawnAll.

func (*Manager) Execute

func (m *Manager) Execute(ctx context.Context, call tools.ToolCall) (tools.ToolResult, error)

Execute routes a tool call to the plugin that owns its name. Namespaced names ("git.status") are decomposed into plugin name + local name; the plugin receives the local name (PluginTool.Execute strips the prefix before sending the RPC).

When the full name was advertised via ListTools, the cached PluginTool is used. When the full name was not advertised but the prefix matches a known plugin, Execute still routes the call to that plugin — the plugin is responsible for rejecting unknown tool names. This matches the spec invariant that the host treats plugin tools uniformly whether or not they appear in ListTools (ListTools is informational, not a gate). Returns ErrUnknownPluginTool only when no plugin's short name matches the prefix.

func (*Manager) Shutdown

func (m *Manager) Shutdown(ctx context.Context) error

Shutdown terminates every plugin subprocess. Caller passes a context whose deadline bounds total shutdown (per-plugin shutdown grace is shutdownGrace=5s). Returns the first error encountered; remaining plugins are still attempted.

func (*Manager) SpawnAll

func (m *Manager) SpawnAll(ctx context.Context) (spawned int, firstErr error)

SpawnAll discovers plugins and spawns a HostClient for each. Tools are loaded (via HostClient.ListTools) so Tools() can return them without further RPCs. Returns the number of plugins spawned and the first error encountered (subsequent plugins are still attempted — a single bad plugin should not poison the rest).

func (*Manager) Tools

func (m *Manager) Tools() []tools.HeadlessTool

Tools returns the namespaced tools across all plugins, sorted by name. Tools from the same plugin retain their natural order. Collisions across plugins are resolved first-wins; subsequent registrants are dropped and a diagnostic is emitted.

The return type is []tools.HeadlessTool so the runtime can merge plugin tools onto a HeadlessTool-capable registry without conversion. Plugin-dispatched tools are gRPC proxies and never implement the TUI rendering hooks, so HeadlessTool is the correct contract.

type PluginAdapter

type PluginAdapter struct {
	// Server is the plugin's proto.PluginServer implementation.
	Server tauproto.PluginServer
	// HostCallbacks is dialed at startup via the go-plugin broker; the
	// plugin uses it to call Host.Log/Notify/GetConfig. May be nil;
	// plugin code should nil-check before calling.
	HostCallbacks tauproto.HostClient
}

PluginAdapter is the plugin-side glue between a Server implementation of proto.PluginServer and go-plugin's plugin.Serve entrypoint. Plugin authors construct one with their Server and pass it to plugin.Serve.

func (*PluginAdapter) PluginMap

func (a *PluginAdapter) PluginMap() map[string]plugin.Plugin

PluginMap returns the plugin-side PluginMap for plugin.Serve. Exactly one entry keyed by PluginName; its GRPCPlugin carries the Server.

func (*PluginAdapter) Serve

func (a *PluginAdapter) Serve()

Serve is the convenience entrypoint. It calls plugin.Serve with the adapter's config and never returns (plugin.Serve exits the process). Authors who need a different exit behavior should use ServeConfig() directly.

func (*PluginAdapter) ServeConfig

func (a *PluginAdapter) ServeConfig() plugin.ServeConfig

ServeConfig assembles the plugin.Serve config. Plugins call:

adapter := &plugins.PluginAdapter{Server: myServer}
plugin.Serve(&adapter.ServeConfig())

or use the Serve helper below.

func (*PluginAdapter) ServerFactory

func (a *PluginAdapter) ServerFactory() *GRPCPlugin

ServerFactory is the name go-plugin dispenses (we registered a single "tau" plugin in PluginMap on the host side; the plugin's PluginMap uses the same name so the wire handshake matches).

type PluginSource

type PluginSource int

PluginSource identifies which scan directory a plugin came from.

const (
	// PluginSourceGlobal means the plugin came from ~/.config/tau/plugins/.
	PluginSourceGlobal PluginSource = iota
	// PluginSourceProject means the plugin came from <cwd>/plugins/.
	PluginSourceProject
)

func (PluginSource) String

func (s PluginSource) String() string

String returns "global" or "project" for diagnostics.

type PluginState

type PluginState int

PluginState reports where in its lifecycle a HostClient's subprocess is. The Manager uses this to decide whether to restart, fail the call, or proceed.

const (
	// PluginStateNew means the HostClient has been constructed but Spawn
	// has not been called (or the previous subprocess was killed).
	PluginStateNew PluginState = iota
	// PluginStateRunning means the subprocess is alive and the gRPC
	// channel is established. Handshake may or may not have completed.
	PluginStateRunning
	// PluginStateCrashed means the subprocess exited unexpectedly. The
	// Manager's restart policy decides whether Spawn is called again.
	PluginStateCrashed
	// PluginStateShutdown means Shutdown was called and the subprocess
	// was killed cleanly. The HostClient should not be restarted.
	PluginStateShutdown
)

func (PluginState) String

func (s PluginState) String() string

String returns a lowercase identifier matching the diagnostic vocabulary.

type PluginTool

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

PluginTool is the host-side tools.Tool implementation that routes Execute to a Plugin.Execute RPC. The Registry treats it identically to built-in tools; the agent loop is unaware a tool is remote.

PluginTool instances are constructed by HostClient after ListTools returns. They hold an indirect clientProvider so a restarted plugin can swap its HostClient without rebuilding every PluginTool instance.

Naming convention: the plugin advertises fully-qualified names like "git.status" in its ToolSchema; the host stores the tool under that name verbatim. Cross-plugin collisions (two plugins expose the same qualified name) are resolved first-wins by the Manager, with a diagnostic emitted for the loser.

func NewPluginTool

func NewPluginTool(pluginShortName, toolName, description, jsonSchema string, provider func() (tauproto.PluginClient, error)) *PluginTool

NewPluginTool constructs a PluginTool. toolName is the fully-qualified name the plugin advertised (e.g. "git.status"); pluginShortName is the plugin's discovery name for routing and diagnostics. The clientProvider returns the live gRPC client; it is called on every Execute so restarts swap transparently.

func (*PluginTool) Description

func (t *PluginTool) Description() string

Description returns the plugin-advertised description verbatim.

func (*PluginTool) Execute

func (t *PluginTool) Execute(ctx context.Context, call tools.ToolCall) (tools.ToolResult, error)

Execute routes the call to the plugin via the gRPC client. RPC errors (network, plugin crash, context cancellation) are converted to a ToolResult with IsError=true; the agent loop treats them as tool failures. The model sees the error text and can retry or proceed.

Context cancellation propagates to the plugin via gRPC; the plugin is responsible for honoring ctx.Done() promptly.

func (*PluginTool) LocalName

func (t *PluginTool) LocalName() string

LocalName returns the un-namespaced name sent to the plugin on the wire. Everything after the first "." in the advertised name; if there is no ".", the full name is returned as-is. The spec mandates the plugin receive the un-namespaced form.

func (*PluginTool) Name

func (t *PluginTool) Name() string

Name returns the fully-qualified tool name the plugin advertised.

func (*PluginTool) Parameters

func (t *PluginTool) Parameters() jsonschema.Schema

Parameters parses the plugin-advertised JSON Schema string into the jsonschema.Schema shape. The parse is cached after the first call.

func (*PluginTool) PluginShortName

func (t *PluginTool) PluginShortName() string

PluginShortName returns the plugin's discovery short name. Used by the Manager for routing and diagnostics.

func (*PluginTool) RenderCall

func (t *PluginTool) RenderCall(args json.RawMessage, theme *tools.Theme) string

RenderCall renders the invocation for the TUI. Plugin tools render their namespaced name plus the raw args JSON, matching the style of the built-in tools.

func (*PluginTool) RenderResult

func (t *PluginTool) RenderResult(result tools.ToolResult, theme *tools.Theme) string

RenderResult renders the result for the TUI. Errors get an "error: " prefix; otherwise the content blocks are concatenated via the shared tools.RenderContentBlocks helper.

type RestartPolicy

type RestartPolicy string

RestartPolicy governs how the Manager reacts when a plugin's subprocess exits unexpectedly. Matches the spec values exactly.

const (
	// RestartNever marks the plugin's tools as permanently unavailable
	// after a crash. Subsequent Execute calls fail with ErrNotRunning.
	RestartNever RestartPolicy = "never"
	// RestartOnNextCall leaves the plugin down until a tool of its is
	// invoked; the Manager re-spawns on the next Execute call.
	RestartOnNextCall RestartPolicy = "on-next-call"
	// RestartAlways triggers an immediate respawn when the subprocess
	// exits, capped at RestartAlwaysMaxAttempts in RestartAlwaysWindow.
	RestartAlways RestartPolicy = "always"
)

Jump to

Keyboard shortcuts

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