Current model (closed):
type AgentRole = 'planner' | 'implementor' | 'reviewer';
type AgentRun = PlannerRun | ImplementorRun | ReviewerRun;
// Discriminated unions everywhere — adding a role is a cross-cutting change
Plugin model (open):
interface AgentDefinition {
role: string; // 'planner', 'implementor', 'reviewer', 'researcher', ...
trigger: HandlerPredicate; // when should this agent run?
contextAssembly: ContextAssembler; // what context does it need?
resultSchema: ZodSchema; // what does it produce?
resultHandler: ResultCommandFactory; // how to translate output → commands
concurrencyKey: ConcurrencyKeyFn; // what resource does it lock?
uiConfig: AgentUIConfig; // how to display in TUI
}
interface AgentUIConfig {
displayName: string; // 'Researcher'
activeVerb: string; // 'Researching'
badge: string; // 'RESEARCH'
section: 'active' | 'pending' | 'action'; // where in the TUI layout
detailRenderer: 'stream' | 'body' | 'diff'; // what to show in detail pane
}
The TUI mapping problem solves itself if each agent definition carries its own UI config. The TUI doesn't need to know about specific agent types — it renders based on AgentUIConfig. Your
display status derivation becomes generic:
// Instead of hardcoded: if (hasActiveImplementorRun) → 'implementing'
// Generic: if (hasActiveRunOfRole(role)) → role.uiConfig.activeVerb
What this buys you:
- Add a researcher agent by defining a new AgentDefinition — no changes to engine, state store, or TUI
- Handlers become generic: "when trigger matches, dispatch agent with context"
- Result handling stays type-safe via the Zod schema
What you'd lose:
- Exhaustive pattern matching on agent types (ts-pattern .exhaustive()) — you'd need runtime validation instead
- Some compile-time guarantees on handler completeness