Skip to content

Latest commit

 

History

History
979 lines (785 loc) · 25.2 KB

File metadata and controls

979 lines (785 loc) · 25.2 KB

Adding a New Provider to aimux

Overview

A provider is how aimux learns about a type of AI coding agent. Each provider implements the provider.Provider interface, which gives aimux the ability to:

  • Discover running agent processes and recent sessions automatically
  • Display agents in the dashboard with status, model, cost, and metadata
  • Resume sessions by zooming into an embedded PTY or jumping to an external terminal
  • Show traces by resolving session/conversation log files
  • Launch new agent sessions from the :new launcher with model and mode selection
  • Track costs via token-based pricing in the cost dashboard

Once you implement the interface and register your provider, all of these features work without touching any view or TUI code.

Quick Start (5 minutes)

Copy internal/provider/gemini.go, rename it, and adjust. This gets you a compilable stub that shows up in the launcher.

1. Create the file:

// internal/provider/aider.go
package provider

import (
	"os/exec"

	"github.com/zanetworker/aimux/internal/agent"
)

type Aider struct{}

func (a *Aider) Name() string                         { return "aider" }
func (a *Aider) Discover() ([]agent.Agent, error)     { return nil, nil }
func (a *Aider) ResumeCommand(ag agent.Agent) *exec.Cmd { return nil }
func (a *Aider) CanEmbed() bool                        { return false }
func (a *Aider) FindSessionFile(ag agent.Agent) string { return "" }
func (a *Aider) RecentDirs(max int) []RecentDir        { return nil }

func (a *Aider) SpawnCommand(dir, model, mode string) *exec.Cmd {
	bin := findBinary("aider")
	cmd := exec.Command(bin)
	cmd.Dir = dir
	return cmd
}

func (a *Aider) SpawnArgs() SpawnArgs {
	return SpawnArgs{
		Models: []string{"default"},
		Modes:  []string{"default"},
	}
}

2. Register in internal/tui/app.go -- add to the allProviders slice in NewApp():

allProviders := []provider.Provider{
	&provider.Claude{},
	&provider.Codex{},
	&provider.Gemini{},
	&provider.Aider{},  // <-- add this
}

3. Add to internal/config/config.go -- add to Default() providers map:

Providers: map[string]ProviderConfig{
	"claude": {Enabled: true},
	"codex":  {Enabled: true},
	"gemini": {Enabled: true},
	"aider":  {Enabled: true},  // <-- add this
},

4. Add tests (see Testing Checklist below).

5. Build and run:

go build -o aimux ./cmd/aimux
go test ./... -timeout 30s

Your provider now appears in the :new launcher. Flesh out methods as you learn how the agent stores its data.

The Provider Interface

Defined in internal/provider/provider.go:

type Provider interface {
	Name() string
	Discover() ([]agent.Agent, error)
	ResumeCommand(a agent.Agent) *exec.Cmd
	CanEmbed() bool
	FindSessionFile(a agent.Agent) string
	RecentDirs(max int) []RecentDir
	SpawnCommand(dir, model, mode string) *exec.Cmd
	SpawnArgs() SpawnArgs
}

Name() string

Returns a unique lowercase identifier for this provider. Used as a key everywhere: config lookup, provider matching, display labels.

When called: On every discovery cycle (every 2 seconds), during provider registration, and when resolving which provider owns an agent.

Rules:

  • Must be unique across all providers
  • Must be lowercase, no spaces
  • Must match the key you add to config.go Default()
func (a *Aider) Name() string { return "aider" }

Discover() ([]agent.Agent, error)

Scans for running agent processes and recent session files. Returns a slice of agent.Agent structs. This is the core discovery mechanism -- it runs every 2 seconds via the orchestrator.

When called: Every tick (2 seconds) by discovery.Orchestrator.Discover(), which calls all providers in parallel.

What to do:

  1. Scan ps aux output for processes matching your agent's binary name
  2. Resolve each process's working directory (via lsof or /proc)
  3. Find and parse session/trace files for metadata (model, tokens, status)
  4. Optionally discover recent idle sessions (no running process but recent trace files)
  5. Set ProviderName on every returned agent

Return nil, nil for a stub. That is valid and means "no agents found."

Reference: See claude.go Discover() for the full pattern (process scan + session enrichment + idle session discovery + deduplication). See codex.go Discover() for a mid-complexity version.

func (a *Aider) Discover() ([]agent.Agent, error) {
	// Minimal: scan ps for "aider" processes
	out, err := exec.Command("ps", "aux").Output()
	if err != nil {
		return nil, fmt.Errorf("ps aux: %w", err)
	}

	var agents []agent.Agent
	for _, line := range strings.Split(string(out), "\n") {
		if !strings.Contains(line, "aider") {
			continue
		}
		// Parse PID, build agent.Agent, etc.
		// ...
	}
	return agents, nil
}

Key agent.Agent fields to populate:

Field Required Description
PID Yes (for running) Process ID. Use 0 for idle/session-only entries.
ProviderName Yes Must equal your Name() return value.
Status Yes agent.StatusActive, StatusIdle, or StatusUnknown.
Source Yes agent.SourceCLI, SourceVSCode, or SourceSDK.
WorkingDir Recommended Absolute path to the project directory.
SessionID If available Unique session identifier for resumption.
SessionFile If available Path to the conversation trace file.
Model If available Model name string (e.g. "gpt-4o", "claude-opus-4-6").
Name Recommended Display name -- usually filepath.Base(WorkingDir).
TokensIn / TokensOut If available For cost calculation.
EstCostUSD If available Pre-calculated via cost.Calculate().
GroupCount Default 1 Set to 1 for single agents.
GroupPIDs Default []int{} List of PIDs if grouping multiple processes.

ResumeCommand(a agent.Agent) *exec.Cmd

Builds the exec.Cmd that resumes an existing session. The TUI runs this command inside a PTY when the user presses Enter on an agent.

When called: When the user selects an agent and presses Enter (zoom in) or J (jump out).

Return nil if the agent cannot be resumed (no session ID, no working directory). The TUI handles nil gracefully by showing a status hint.

func (a *Aider) ResumeCommand(ag agent.Agent) *exec.Cmd {
	bin := findBinary("aider")
	if ag.WorkingDir == "" {
		return nil
	}
	cmd := exec.Command(bin)
	cmd.Dir = ag.WorkingDir
	return cmd
}

Reference: Claude uses --resume <sessionID> or --continue. Codex uses resume --no-alt-screen <sessionID>.

CanEmbed() bool

Reports whether this agent's TUI can run inside aimux's embedded PTY. If true, pressing Enter opens a split view (trace on left, live session on right). If false, pressing Enter opens the trace-only view, and the user presses J to jump out to a tmux or iTerm split pane.

When called: When the user presses Enter on an agent, to decide the layout mode.

Guidelines:

  • Return true if the agent uses a standard terminal (no alternate screen buffer fighting, no raw mode conflicts). Claude works.
  • Return false if the agent has its own TUI framework that conflicts with Bubble Tea's alternate screen. Codex and Gemini return false.
  • When in doubt, return false. It is the safer option.
func (a *Aider) CanEmbed() bool { return true }

FindSessionFile(a agent.Agent) string

Resolves the path to the session's trace/conversation file. Each provider knows its own storage layout (Claude uses ~/.claude/projects/, Codex uses ~/.codex/sessions/).

When called: When opening the trace viewer (l key or Enter on a non-embeddable provider), and during preview pane updates.

Return "" if no session file exists. The TUI shows "No trace data yet" to the user.

func (a *Aider) FindSessionFile(ag agent.Agent) string {
	if ag.WorkingDir == "" {
		return ""
	}
	home, err := os.UserHomeDir()
	if err != nil {
		return ""
	}
	// Aider stores chat history in .aider.chat.history.md in the project dir
	candidate := filepath.Join(ag.WorkingDir, ".aider.chat.history.md")
	if _, err := os.Stat(candidate); err == nil {
		return candidate
	}
	return ""
}

RecentDirs(max int) []RecentDir

Returns recently-used project directories from this provider's session history, sorted most-recent first, capped at max. These populate the directory picker in the :new launcher.

When called: When the user opens the launcher (:new command). All providers' recent dirs are merged and deduplicated.

Return nil if you have no session history to scan. The launcher still works -- it just won't show directories from your provider.

type RecentDir struct {
	Path     string
	LastUsed time.Time
}
func (a *Aider) RecentDirs(max int) []RecentDir {
	home, err := os.UserHomeDir()
	if err != nil {
		return nil
	}
	// Scan ~/.aider/ for recent session dirs
	// ...
	return nil // stub
}

SpawnCommand(dir, model, mode string) *exec.Cmd

Builds the exec.Cmd to launch a brand-new agent session. Called from the launcher overlay after the user picks a directory, model, and mode.

When called: When the user completes the launcher flow (:new -> pick dir -> pick provider -> pick model -> pick mode).

Parameters:

  • dir -- absolute path to the project directory. Always set cmd.Dir = dir.
  • model -- the model string from SpawnArgs().Models. May be "" or "default" -- skip adding a --model flag in that case.
  • mode -- the mode string from SpawnArgs().Modes. May be "" or "default".
func (a *Aider) SpawnCommand(dir, model, mode string) *exec.Cmd {
	bin := findBinary("aider")
	var args []string

	if model != "" && model != "default" {
		args = append(args, "--model", model)
	}

	switch mode {
	case "architect":
		args = append(args, "--architect")
	case "ask":
		args = append(args, "--ask")
	}

	cmd := exec.Command(bin, args...)
	cmd.Dir = dir
	return cmd
}

SpawnArgs() SpawnArgs

Returns the available models and modes for the launcher UI. These populate the model and mode selection dropdowns.

When called: When building the launcher overlay. The first entry in each slice is the default selection.

type SpawnArgs struct {
	Models []string // e.g., ["default", "gpt-4o", "claude-sonnet"]
	Modes  []string // e.g., ["default", "architect", "ask"]
}
func (a *Aider) SpawnArgs() SpawnArgs {
	return SpawnArgs{
		Models: []string{"default", "gpt-4o", "claude-3.5-sonnet", "deepseek-r1"},
		Modes:  []string{"default", "architect", "ask"},
	}
}

Registration

Two files need a one-line change each:

1. internal/tui/app.go -- NewApp()

Add your provider to the allProviders slice:

allProviders := []provider.Provider{
	&provider.Claude{},
	&provider.Codex{},
	&provider.Gemini{},
	&provider.Aider{},  // <-- add here
}

The orchestrator and all views pick it up automatically from this single registration point.

2. internal/config/config.go -- Default()

Add your provider to the default config so it is enabled out of the box:

Providers: map[string]ProviderConfig{
	"claude": {Enabled: true},
	"codex":  {Enabled: true},
	"gemini": {Enabled: true},
	"aider":  {Enabled: true},  // <-- add here
},

Users can disable it in ~/.aimux/config.yaml:

providers:
  aider:
    enabled: false

Note: providers not listed in the config map are enabled by default (IsProviderEnabled returns true for unknown names), so the config entry is not strictly required for functionality. But adding it makes the provider visible in the default config and documents its existence.

Cost Tracking

To show cost estimates for your provider's agents, add model pricing to internal/cost/tracker.go.

Add to the pricing map:

var pricing = map[string]ModelPricing{
	// ... existing entries ...

	// Aider-supported models (example)
	"gpt-4o": {
		Input:  2.50,
		Output: 10.00,
	},
	"deepseek-r1": {
		Input:  0.55,
		Output: 2.19,
	},
}

Add short aliases (optional):

var aliases = map[string]string{
	// ... existing entries ...
	"4o": "gpt-4o",
}

How it works: During Discover(), when you set agent.TokensIn and agent.TokensOut, call cost.Calculate() to populate agent.EstCostUSD:

import "github.com/zanetworker/aimux/internal/cost"

a.EstCostUSD = cost.Calculate(
	a.Model,        // e.g. "gpt-4o"
	info.tokensIn,
	info.tokensOut,
	info.cacheRead,  // 0 if not applicable
	info.cacheWrite, // 0 if not applicable
)

The pricing map uses per-million-token rates in USD. Calculate() normalizes model names (strips version suffixes, resolves aliases) before lookup. Unknown models return $0.00.

Testing Checklist

Every provider needs these tests. See internal/provider/stubs_test.go and internal/provider/claude_test.go for the patterns.

Required Tests

1. Compile-time interface check (in your test file or stubs_test.go):

var _ Provider = (*Aider)(nil)

This fails at compile time if Aider is missing any interface methods. No runtime cost.

2. Name returns the correct string:

func TestAiderName(t *testing.T) {
	a := &Aider{}
	if got := a.Name(); got != "aider" {
		t.Errorf("Aider.Name() = %q, want %q", got, "aider")
	}
}

3. CanEmbed returns the expected value:

func TestAiderCanEmbed(t *testing.T) {
	a := &Aider{}
	if !a.CanEmbed() {
		t.Error("Aider.CanEmbed() = false, want true")
	}
}

4. SpawnArgs returns valid, non-empty slices:

func TestAiderSpawnArgs(t *testing.T) {
	a := &Aider{}
	sa := a.SpawnArgs()

	if len(sa.Models) == 0 {
		t.Fatal("SpawnArgs.Models is empty")
	}
	if sa.Models[0] != "default" {
		t.Errorf("SpawnArgs.Models[0] = %q, want %q", sa.Models[0], "default")
	}
	if len(sa.Modes) == 0 {
		t.Fatal("SpawnArgs.Modes is empty")
	}
	if sa.Modes[0] != "default" {
		t.Errorf("SpawnArgs.Modes[0] = %q, want %q", sa.Modes[0], "default")
	}
}

5. SpawnCommand with various model/mode combinations:

func TestAiderSpawnCommand_Default(t *testing.T) {
	a := &Aider{}
	cmd := a.SpawnCommand("/tmp/myproject", "", "")
	if cmd == nil {
		t.Fatal("SpawnCommand returned nil")
	}
	if cmd.Dir != "/tmp/myproject" {
		t.Errorf("cmd.Dir = %q, want %q", cmd.Dir, "/tmp/myproject")
	}
}

func TestAiderSpawnCommand_WithModel(t *testing.T) {
	a := &Aider{}
	cmd := a.SpawnCommand("/tmp/myproject", "gpt-4o", "")
	if cmd == nil {
		t.Fatal("SpawnCommand returned nil")
	}
	assertArgsContain(t, cmd.Args, "--model", "gpt-4o")
}

func TestAiderSpawnCommand_DefaultModelSkipped(t *testing.T) {
	a := &Aider{}
	cmd := a.SpawnCommand("/tmp/myproject", "default", "")
	if cmd == nil {
		t.Fatal("SpawnCommand returned nil")
	}
	assertArgAbsent(t, cmd.Args, "--model")
}

func TestAiderSpawnCommand_ArchitectMode(t *testing.T) {
	a := &Aider{}
	cmd := a.SpawnCommand("/tmp/myproject", "", "architect")
	if cmd == nil {
		t.Fatal("SpawnCommand returned nil")
	}
	assertArgPresent(t, cmd.Args, "--architect")
}

6. FindSessionFile with empty agent does not panic:

func TestAiderFindSessionFile_Empty(t *testing.T) {
	a := &Aider{}
	if got := a.FindSessionFile(agent.Agent{}); got != "" {
		t.Errorf("FindSessionFile(empty) = %q, want empty", got)
	}
}

7. RecentDirs does not panic:

func TestAiderRecentDirs(t *testing.T) {
	a := &Aider{}
	_ = a.RecentDirs(5) // must not panic
}

8. Discover does not error on a clean system:

func TestAiderDiscover(t *testing.T) {
	a := &Aider{}
	_, err := a.Discover()
	if err != nil {
		t.Errorf("Aider.Discover() error = %v, want nil", err)
	}
}

Test helpers

The test helpers assertArgPresent, assertArgAbsent, and assertArgsContain are defined in internal/provider/claude_test.go and available to all test files in the package.

Run tests:

go test ./internal/provider/ -v -timeout 30s
go test ./... -timeout 30s

Complete Example: Adding an "aider" Provider

This walkthrough adds a hypothetical aider provider with process discovery, session file resolution, and cost tracking.

Step 1: Create internal/provider/aider.go

package provider

import (
	"fmt"
	"os"
	"os/exec"
	"path/filepath"
	"sort"
	"strconv"
	"strings"
	"time"

	"github.com/zanetworker/aimux/internal/agent"
	"github.com/zanetworker/aimux/internal/cost"
)

// Aider is a Provider implementation for the aider AI pair programming tool.
type Aider struct{}

func (a *Aider) Name() string { return "aider" }

// Discover finds running aider processes.
func (a *Aider) Discover() ([]agent.Agent, error) {
	out, err := exec.Command("ps", "aux").Output()
	if err != nil {
		return nil, fmt.Errorf("ps aux: %w", err)
	}

	var agents []agent.Agent
	for _, line := range strings.Split(string(out), "\n") {
		line = strings.TrimSpace(line)
		if line == "" {
			continue
		}
		if !a.isAiderProcess(line) {
			continue
		}

		fields := strings.Fields(line)
		if len(fields) < 11 {
			continue
		}
		pid, err := strconv.Atoi(fields[1])
		if err != nil {
			continue
		}

		ag := agent.Agent{
			PID:          pid,
			ProviderName: "aider",
			Status:       agent.StatusUnknown,
			Source:        agent.SourceCLI,
			LastActivity:  time.Now(),
			GroupCount:    1,
			GroupPIDs:     []int{pid},
		}

		// Resolve working directory
		if cwd, err := exec.Command("lsof", "-a", "-p",
			strconv.Itoa(pid), "-d", "cwd", "-Fn").Output(); err == nil {
			for _, l := range strings.Split(string(cwd), "\n") {
				if strings.HasPrefix(l, "n/") {
					ag.WorkingDir = l[1:]
					break
				}
			}
		}

		if ag.WorkingDir != "" {
			ag.Name = filepath.Base(ag.WorkingDir)
		} else {
			ag.Name = fmt.Sprintf("aider-%d", pid)
		}

		// Extract model from command line
		cmd := strings.Join(fields[10:], " ")
		if m := extractFlag(cmd, "--model"); m != "" {
			ag.Model = m
		}

		agents = append(agents, ag)
	}

	return agents, nil
}

func (a *Aider) isAiderProcess(line string) bool {
	fields := strings.Fields(line)
	if len(fields) < 11 {
		return false
	}
	binary := fields[10]
	cmd := strings.Join(fields[10:], " ")

	if strings.Contains(cmd, "grep") {
		return false
	}
	if strings.Contains(cmd, "aimux") {
		return false
	}

	return strings.HasSuffix(binary, "/aider") || binary == "aider" ||
		(strings.Contains(binary, "python") && strings.Contains(cmd, "aider"))
}

func (a *Aider) ResumeCommand(ag agent.Agent) *exec.Cmd {
	bin := findBinary("aider")
	if ag.WorkingDir == "" {
		return nil
	}
	// aider auto-resumes from .aider.chat.history.md in the project dir
	cmd := exec.Command(bin)
	cmd.Dir = ag.WorkingDir
	return cmd
}

// CanEmbed returns true because aider uses a standard terminal interface.
func (a *Aider) CanEmbed() bool { return true }

// FindSessionFile looks for aider's chat history in the project directory.
func (a *Aider) FindSessionFile(ag agent.Agent) string {
	if ag.WorkingDir == "" {
		return ""
	}
	candidate := filepath.Join(ag.WorkingDir, ".aider.chat.history.md")
	if _, err := os.Stat(candidate); err == nil {
		return candidate
	}
	return ""
}

// RecentDirs scans for directories containing .aider.chat.history.md files.
func (a *Aider) RecentDirs(max int) []RecentDir {
	home, err := os.UserHomeDir()
	if err != nil {
		return nil
	}

	// Check common project locations
	searchDirs := []string{
		filepath.Join(home, "projects"),
		filepath.Join(home, "src"),
		filepath.Join(home, "go", "src"),
	}

	seen := make(map[string]bool)
	var dirs []RecentDir

	for _, searchDir := range searchDirs {
		_ = filepath.Walk(searchDir, func(path string, info os.FileInfo, err error) error {
			if err != nil {
				return filepath.SkipDir
			}
			if info.IsDir() {
				return nil
			}
			if info.Name() != ".aider.chat.history.md" {
				return nil
			}
			dir := filepath.Dir(path)
			if seen[dir] {
				return nil
			}
			seen[dir] = true
			dirs = append(dirs, RecentDir{
				Path:     dir,
				LastUsed: info.ModTime(),
			})
			return nil
		})
	}

	sort.Slice(dirs, func(i, j int) bool {
		return dirs[i].LastUsed.After(dirs[j].LastUsed)
	})

	if max > 0 && len(dirs) > max {
		dirs = dirs[:max]
	}
	return dirs
}

// SpawnCommand builds the exec.Cmd to launch a new aider session.
func (a *Aider) SpawnCommand(dir, model, mode string) *exec.Cmd {
	bin := findBinary("aider")
	var args []string

	if model != "" && model != "default" {
		args = append(args, "--model", model)
	}

	switch mode {
	case "architect":
		args = append(args, "--architect")
	case "ask":
		args = append(args, "--ask")
	}

	cmd := exec.Command(bin, args...)
	cmd.Dir = dir
	return cmd
}

// SpawnArgs returns available models and modes for the launcher.
func (a *Aider) SpawnArgs() SpawnArgs {
	return SpawnArgs{
		Models: []string{"default", "gpt-4o", "claude-3.5-sonnet", "deepseek-r1"},
		Modes:  []string{"default", "architect", "ask"},
	}
}

// extractFlag extracts the value following a CLI flag from a command string.
func extractFlag(args, flag string) string {
	fields := strings.Fields(args)
	for i, f := range fields {
		if f == flag && i+1 < len(fields) {
			return fields[i+1]
		}
	}
	return ""
}

Step 2: Register in internal/tui/app.go

In NewApp(), add to the allProviders slice:

allProviders := []provider.Provider{
	&provider.Claude{},
	&provider.Codex{},
	&provider.Gemini{},
	&provider.Aider{},
}

Step 3: Register in internal/config/config.go

In Default(), add to the providers map:

"aider": {Enabled: true},

Step 4: Add cost tracking in internal/cost/tracker.go

Add any models aider uses that are not already in the pricing map:

// In the pricing map:
"gpt-4o": {
	Input:  2.50,
	Output: 10.00,
},
"deepseek-r1": {
	Input:  0.55,
	Output: 2.19,
},

// In the aliases map:
"4o": "gpt-4o",

Step 5: Add tests in internal/provider/aider_test.go

package provider

import (
	"path/filepath"
	"testing"

	"github.com/zanetworker/aimux/internal/agent"
)

// Compile-time interface check.
var _ Provider = (*Aider)(nil)

func TestAiderName(t *testing.T) {
	a := &Aider{}
	if got := a.Name(); got != "aider" {
		t.Errorf("Aider.Name() = %q, want %q", got, "aider")
	}
}

func TestAiderCanEmbed(t *testing.T) {
	a := &Aider{}
	if !a.CanEmbed() {
		t.Error("Aider.CanEmbed() = false, want true")
	}
}

func TestAiderDiscover(t *testing.T) {
	a := &Aider{}
	_, err := a.Discover()
	if err != nil {
		t.Errorf("Aider.Discover() error = %v, want nil", err)
	}
}

func TestAiderResumeCommand_NoWorkingDir(t *testing.T) {
	a := &Aider{}
	cmd := a.ResumeCommand(agent.Agent{})
	if cmd != nil {
		t.Errorf("ResumeCommand(empty) = %v, want nil", cmd)
	}
}

func TestAiderResumeCommand_WithWorkingDir(t *testing.T) {
	a := &Aider{}
	cmd := a.ResumeCommand(agent.Agent{WorkingDir: "/tmp/project"})
	if cmd == nil {
		t.Skip("aider binary not found")
	}
	if cmd.Dir != "/tmp/project" {
		t.Errorf("cmd.Dir = %q, want %q", cmd.Dir, "/tmp/project")
	}
}

func TestAiderFindSessionFile_Empty(t *testing.T) {
	a := &Aider{}
	if got := a.FindSessionFile(agent.Agent{}); got != "" {
		t.Errorf("FindSessionFile(empty) = %q, want empty", got)
	}
}

func TestAiderRecentDirs(t *testing.T) {
	a := &Aider{}
	_ = a.RecentDirs(5) // must not panic
}

func TestAiderSpawnCommand_Default(t *testing.T) {
	a := &Aider{}
	cmd := a.SpawnCommand("/tmp/myproject", "", "")
	if cmd == nil {
		t.Fatal("SpawnCommand returned nil")
	}
	if cmd.Dir != "/tmp/myproject" {
		t.Errorf("cmd.Dir = %q, want %q", cmd.Dir, "/tmp/myproject")
	}
	if base := filepath.Base(cmd.Args[0]); base != "aider" {
		t.Errorf("binary = %q, want %q", base, "aider")
	}
}

func TestAiderSpawnCommand_DefaultModelSkipped(t *testing.T) {
	a := &Aider{}
	cmd := a.SpawnCommand("/tmp/myproject", "default", "")
	if cmd == nil {
		t.Fatal("SpawnCommand returned nil")
	}
	assertArgAbsent(t, cmd.Args, "--model")
}

func TestAiderSpawnCommand_WithModel(t *testing.T) {
	a := &Aider{}
	cmd := a.SpawnCommand("/tmp/myproject", "gpt-4o", "")
	if cmd == nil {
		t.Fatal("SpawnCommand returned nil")
	}
	assertArgsContain(t, cmd.Args, "--model", "gpt-4o")
}

func TestAiderSpawnCommand_ArchitectMode(t *testing.T) {
	a := &Aider{}
	cmd := a.SpawnCommand("/tmp/myproject", "", "architect")
	if cmd == nil {
		t.Fatal("SpawnCommand returned nil")
	}
	assertArgPresent(t, cmd.Args, "--architect")
}

func TestAiderSpawnCommand_AskMode(t *testing.T) {
	a := &Aider{}
	cmd := a.SpawnCommand("/tmp/myproject", "", "ask")
	if cmd == nil {
		t.Fatal("SpawnCommand returned nil")
	}
	assertArgPresent(t, cmd.Args, "--ask")
}

func TestAiderSpawnCommand_ModelAndMode(t *testing.T) {
	a := &Aider{}
	cmd := a.SpawnCommand("/tmp/myproject", "gpt-4o", "architect")
	if cmd == nil {
		t.Fatal("SpawnCommand returned nil")
	}
	assertArgsContain(t, cmd.Args, "--model", "gpt-4o")
	assertArgPresent(t, cmd.Args, "--architect")
}

func TestAiderSpawnArgs(t *testing.T) {
	a := &Aider{}
	sa := a.SpawnArgs()

	if len(sa.Models) == 0 {
		t.Fatal("SpawnArgs.Models is empty")
	}
	if sa.Models[0] != "default" {
		t.Errorf("SpawnArgs.Models[0] = %q, want %q", sa.Models[0], "default")
	}
	if len(sa.Modes) == 0 {
		t.Fatal("SpawnArgs.Modes is empty")
	}
	if sa.Modes[0] != "default" {
		t.Errorf("SpawnArgs.Modes[0] = %q, want %q", sa.Modes[0], "default")
	}
}

Step 6: Build and verify

go test ./internal/provider/ -v -timeout 30s
go test ./... -timeout 30s
go build -o aimux ./cmd/aimux
./aimux  # verify aider appears in :new launcher

That is everything. The agent list, preview pane, trace viewer, cost dashboard, and launcher all pick up your provider automatically from the interface contract and registration.