ADR-0017: Pluggable Agent SPI
Context
ADR-0013 positioned Aileron as a local policy-enforced shell that intercepts agent commands via SHELL=aileron-sh. The initial implementation hardcoded agent-specific behavior:
-
Command normalization lived in
aileron-shas aswitchstatement. Claude Code wraps commands ineval '...'; other agents don’t. Adding a new agent required editing the shim binary. -
Shell configuration was Claude-specific. The launcher unconditionally installed a wrapper script at
~/.aileron/bashand setCLAUDE_CODE_SHELL— an env var only Claude Code reads. Other agents that ignore$SHELL(e.g. Pi, which hardcodes/bin/bashand reads its shell from a settings file) had no hook to configure their shell resolution. -
The
Agentinterface had four methods (Name,BinaryNames,Args,Env) — enough to launch an agent but not enough to handle the behavioral differences between agents.
The community requested Pi.dev support (#98), which exposed all three gaps: Pi wraps commands differently (not at all), resolves its shell differently (settings file, not $SHELL), and needs agent-specific pre-launch setup.
Decision
Extend the Agent interface into a Service Provider Interface (SPI) with three additional methods:
1. NormalizeCommand(raw string) (command string, evaluate bool)
Each agent owns its command-unwrapping logic. The shim (aileron-sh) calls agents.NormalizeCommand(agentName, raw) which delegates to the registered agent’s implementation. No switch statements.
- Claude: Unwraps
eval '...'from theshopt ... && eval 'cmd' < /dev/null && pwd ...template. Returns(cmd, true)for user commands,(raw, false)for infrastructure commands (snapshots, etc.). - Pi: Passthrough —
(raw, true). Pi doesn’t wrap commands. - Unknown agents: Passthrough —
(raw, true). Safe default.
2. ConfigureShell(shimPath, dir string) error
Agents that need pre-launch setup to use aileron-sh implement this. Called by the launcher before spawning the agent process.
- Claude: Installs a wrapper script at
~/.aileron/bash(path must contain “bash” for Claude Code’s shell validation). SetsCLAUDE_CODE_SHELLviaEnv(). - Pi: Writes
.pi/settings.jsonwith"shellPath": "/path/to/aileron-sh". Pi ignores$SHELLand reads its shell from this file. - Agents that respect
$SHELL: Returnnil(no-op).
3. Normalizer registry (bridge pattern)
aileron-sh is a separate binary that cannot hold Go references to Agent structs. It only knows the agent name from the AILERON_AGENT env var. The agents.NormalizeCommand(name, raw) function bridges this gap with a package-level registry mapping agent names to their normalizer implementations.
Consequences
Adding a new agent
One file (core/launch/agents/<name>.go) implementing the seven-method Agent interface, plus one Register() call in cmd/aileron/main.go and one entry in the normalizer registry. No edits to aileron-sh, the launcher, or any existing agent.
Agent-specific behavior is self-contained
Claude’s eval unwrapping, wrapper installation, and CLAUDE_CODE_SHELL env var are all defined in claude.go. Pi’s settings.json writing is in pi.go. The launcher and shim are agent-agnostic.
The normalizer registry is a pragmatic compromise
A pure SPI would have zero static registrations. The normalizer registry (agents/normalize.go) exists because aileron-sh is a compiled binary that resolves agents by string name at runtime. The registry is the thinnest possible bridge — a map of name to interface. If Aileron later supports plugin-based agents (shared libraries, external binaries), this registry becomes the integration point.
$SHELL is not universal
The assumption in ADR-0013 that “any agent that respects $SHELL” would work was optimistic. Pi hardcodes /bin/bash. Other agents may resolve their shell from config files, env vars, or CLI flags. ConfigureShell absorbs this variance without complicating the launcher.