From 8e135e8a44ba124773fc0f56cf5a1786446611b0 Mon Sep 17 00:00:00 2001 From: Robin White Date: Thu, 19 Mar 2026 10:17:58 -0400 Subject: [PATCH 01/41] docs: add agent pool design for dynamic persona swarm Design for DJ to manage multiple concurrent Codex processes, each typed with a roster persona. Covers orchestrator bridge, command parsing, cross-agent communication, and dynamic mid-session spawning. --- docs/plans/2026-03-19-agent-pool-design.md | 424 +++++++++++++++++++++ 1 file changed, 424 insertions(+) create mode 100644 docs/plans/2026-03-19-agent-pool-design.md diff --git a/docs/plans/2026-03-19-agent-pool-design.md b/docs/plans/2026-03-19-agent-pool-design.md new file mode 100644 index 0000000..9fcb3c1 --- /dev/null +++ b/docs/plans/2026-03-19-agent-pool-design.md @@ -0,0 +1,424 @@ +# Agent Pool Design — Dynamic Persona Swarm + +## Overview + +DJ evolves from a single-process Codex visualizer into a multi-process agent swarm orchestrator. Roster generates persona definitions from repo analysis. DJ reads those personas, spawns multiple Codex processes (each persona-typed), and visualizes the full swarm on a canvas grid. A designated orchestrator Codex session routes tasks to personas, while any agent can request specialist spawns mid-session. + +## Decisions + +- **Control model**: Hybrid — DJ manages process lifecycle, an orchestrator Codex session handles task routing +- **Spawn model**: Tiered — a few top-level Codex processes for parallelism, each can spawn internal sub-agents +- **Roster integration**: Read output files (`.roster/personas/*.md`, `.roster/signals.json`) — no compile-time dependency +- **Orchestrator communication**: Output-stream parsing — orchestrator emits `dj-command` fenced code blocks that DJ parses from delta events +- **Scope**: Full dynamic swarm — orchestrator routing, multi-process pool, dynamic mid-session spawning, cross-agent communication + +## Section 1: Core Data Model + +### PersonaDefinition + +Loaded from `.roster/personas/*.md` at startup. Parsed from YAML frontmatter. + +```go +type PersonaDefinition struct { + ID string + Name string + Description string + Triggers []string + Content string // full markdown body — becomes the system prompt +} +``` + +### AgentProcess + +Represents a running Codex process with a persona identity. + +```go +type AgentProcess struct { + ID string // unique agent ID (e.g. "architect-1") + PersonaID string // links to PersonaDefinition.ID + ThreadID string // Codex thread ID from thread/started + Client *appserver.Client // the Codex process + Role AgentRole // orchestrator | worker + Task string // the task assigned to this agent + Status string // spawning | active | completed | error + ParentID string // which agent requested this spawn +} +``` + +### AgentRole + +Two types of agents in the swarm: + +- **Orchestrator**: Has all persona definitions in its system prompt. Analyzes tasks, emits spawn commands. Exactly one per DJ instance. +- **Worker**: Has a single persona's system prompt. Does the actual work. Can request specialist spawns via `dj-command` blocks. + +### ThreadState changes + +The existing `ThreadState` gains a new field linking it to the agent pool: + +```go +AgentProcessID string // links to AgentProcess.ID +``` + +This lets the canvas show persona-specific styling per card — color, icon, role label all driven by the persona definition. + +## Section 2: AgentPool — Process Manager + +New `internal/pool/` package. Manages the lifecycle of multiple Codex processes. + +### Responsibilities + +1. **Spawn** — Start a new Codex process with a persona-specific system prompt +2. **Route events** — Each process has its own `ReadLoop`. The pool multiplexes all event streams into a single channel that the TUI consumes +3. **Stop** — Gracefully shut down individual agents or the entire pool +4. **Lookup** — Find agents by ID, persona, or thread ID + +### Core structure + +```go +type AgentPool struct { + agents map[string]*AgentProcess // keyed by agent ID + mu sync.RWMutex + events chan PoolEvent // multiplexed events from all processes + command string // base codex command + args []string // base codex args + personas map[string]PersonaDefinition + idCounter atomic.Int64 +} +``` + +### PoolEvent + +Wraps a JSON-RPC message with the source agent ID so the TUI knows which process it came from: + +```go +type PoolEvent struct { + AgentID string + Message appserver.JSONRPCMessage +} +``` + +### Spawn flow + +``` +pool.Spawn(personaID, task, parentAgentID) + -> create AgentProcess + -> appserver.NewClient(command, args...) + -> client.Start(ctx) + -> client.Initialize() with persona system prompt injected + -> client.SendUserInput(task) + -> goroutine: client.ReadLoop -> wrap events as PoolEvent -> pool.events channel +``` + +The persona's `Content` (full markdown body) is sent as the system prompt during the `initialize` handshake. This is how the Codex process becomes persona-typed. + +### Integration with AppModel + +Today `AppModel` has a single `client *appserver.Client`. This becomes: + +```go +pool *pool.AgentPool // replaces client +poolEvents chan pool.PoolEvent // replaces events chan +``` + +The `listenForEvents()` method reads from `pool.Events()` instead of a single client's event channel. The `PoolEvent.AgentID` maps each event to the right agent process, which maps to the right `ThreadState` via `AgentProcessID`. + +### Orchestrator bootstrap + +On startup, the pool auto-spawns one orchestrator agent: + +```go +pool.SpawnOrchestrator(allPersonas, repoSignals) +``` + +This creates an agent whose system prompt contains all persona definitions, their triggers, and the repo signals. No task yet — it waits for the user's first input. + +## Section 3: Orchestrator Bridge — Command Parsing + +The orchestrator emits structured commands in its response stream. DJ detects and parses them from chunked `item/agentMessage/delta` events. + +### Command protocol + +The orchestrator (and any worker agent requesting a specialist) emits fenced code blocks with a `dj-command` language tag: + +```` +```dj-command +{"action": "spawn", "persona": "architect", "task": "Design the API boundary for the auth module"} +``` +```` + +### Supported commands + +```go +type DJCommand struct { + Action string `json:"action"` // spawn | message | complete + Persona string `json:"persona"` // persona ID to spawn (for spawn) + Task string `json:"task"` // task description (for spawn) + Target string `json:"target"` // target agent ID (for message) + Content string `json:"content"` // message content (for message) +} +``` + +Three actions: + +- **spawn** — Request DJ to spawn a new persona agent with a task +- **message** — Send a message from one agent to another (cross-agent communication via DJ as hub) +- **complete** — Agent signals it's done with its task. DJ updates status and notifies the orchestrator + +### Delta buffer and parser + +New `internal/orchestrator/` package: + +```go +type CommandParser struct { + buffer strings.Builder + inBlock bool // currently inside a dj-command fence + commands chan DJCommand // parsed commands emitted here +} + +func (parser *CommandParser) Feed(delta string) +func (parser *CommandParser) Commands() <-chan DJCommand +``` + +Parsing logic: + +1. Append each delta to the buffer +2. Scan for opening fence `` ```dj-command `` +3. When found, set `inBlock = true`, start accumulating command content +4. Scan for closing fence `` ``` `` +5. When found, parse the accumulated JSON, emit `DJCommand` on the channel +6. Strip the command block from the text that gets displayed (internal commands should not pollute the UI) + +### Per-agent parsers + +Each `AgentProcess` gets its own `CommandParser`. When the pool receives an `item/agentMessage/delta` event, it feeds the delta to the corresponding parser. Commands flow from parser to pool to TUI as Bubble Tea messages: + +```go +type SpawnRequestMsg struct { + SourceAgentID string + Persona string + Task string +} +``` + +The TUI handles `SpawnRequestMsg` by calling `pool.Spawn(persona, task, sourceAgentID)`. + +## Section 4: Persona Loader — Roster Integration + +New `internal/roster/` package. Reads `.roster/` output files at startup — no compile-time dependency on roster. + +### What DJ reads + +1. **`.roster/signals.json`** — Repo signals (languages, frameworks, CI, etc.). Passed to the orchestrator's system prompt for context-aware routing. +2. **`.roster/personas/*.md`** — Persona templates with YAML frontmatter. Each becomes a `PersonaDefinition`. + +### Loader + +```go +package roster + +func LoadPersonas(dir string) ([]PersonaDefinition, error) +func LoadSignals(path string) (*RepoSignals, error) +``` + +`LoadPersonas` walks `.roster/personas/`, splits YAML frontmatter from markdown body (`---` delimiters), unmarshals into `PersonaDefinition`. The `Content` field holds the full markdown body that becomes the agent's system prompt. + +`LoadSignals` reads and unmarshals the JSON file. DJ defines its own `RepoSignals` struct — mirrors roster's but decoupled. + +### Startup flow + +``` +1. config.Load() +2. roster.LoadPersonas(".roster/personas/") +3. roster.LoadSignals(".roster/signals.json") +4. pool.NewAgentPool(config, personas) +5. pool.SpawnOrchestrator(personas, signals) +6. tui.NewAppModel(store, pool) +``` + +### Graceful degradation + +If `.roster/` does not exist (roster was not run), DJ falls back to current behavior — single Codex process, no personas, no orchestrator. The pool is optional. This keeps DJ usable standalone. + +### Config additions + +New section in `dj.toml`: + +```toml +[roster] +path = ".roster" # where to find roster output +auto_orchestrate = true # spawn orchestrator on startup + +[pool] +max_agents = 10 # maximum concurrent agent processes +``` + +## Section 5: Cross-Agent Communication & Dynamic Spawning + +### DJ as message bus + +Agents cannot talk directly — they are separate Codex processes with isolated stdio pipes. DJ is the router. When an agent emits a `message` command: + +```` +```dj-command +{"action": "message", "target": "architect-1", "content": "The auth module needs a rate limiter."} +``` +```` + +DJ receives this as a `DJCommand`, looks up the target agent in the pool, and injects the message into that agent's Codex process via `client.SendUserInput()`. The target agent sees it as a new turn with sender context. + +### Message injection format + +When DJ delivers a cross-agent message, it wraps it with sender context: + +``` +[From: test-1 (Test Engineer)] The auth module needs a rate limiter. What's your recommended pattern? +``` + +The receiving agent knows who is talking and can respond via its own `dj-command` message block. + +### Dynamic mid-session spawning + +Any worker agent can request a specialist. The flow: + +1. Agent outputs a `spawn` dj-command +2. DJ parses it, creates `SpawnRequestMsg` with `SourceAgentID` +3. Pool spawns a new persona agent with `ParentID` set to the requesting agent +4. Canvas renders it as a child of the requesting agent's card +5. When the spawned agent completes, it emits a `complete` command +6. DJ notifies the parent agent that the specialist finished (via message injection) + +### Completion flow + +When an agent finishes its task: + +```` +```dj-command +{"action": "complete", "content": "Security review complete. Found 2 issues: [details]"} +``` +```` + +DJ handles this by: + +1. Updating the agent's status to `completed` +2. If the agent has a parent, injecting the completion content into the parent's process +3. Notifying the orchestrator so it can decide next steps + +### Orchestrator awareness + +The orchestrator stays informed of the full swarm state. When agents spawn, complete, or fail, DJ injects status updates into the orchestrator's process: + +``` +[DJ System] Agent security-1 (Security) completed task: "Review auth API" +Result: Found 2 issues: [details] +Active agents: architect-1 (active), test-1 (active) +``` + +### Ordering and concurrency + +- Message delivery is async — DJ queues messages per agent and delivers them between turns (not mid-turn) +- Spawn requests are serialized through the pool's mutex +- Completion notifications are delivered after the agent's process stops producing output + +## Section 6: Canvas & UI Changes + +### Persona-aware cards + +Cards gain: + +- **Persona badge** — Short label from `PersonaDefinition.Name` +- **Persona color** — Each persona ID maps to a distinct color (architect=blue, test=green, security=red, reviewer=yellow, performance=cyan, design=magenta, devops=orange) +- **Role subtitle** — Shows the assigned task (truncated to card width) +- **Orchestrator indicator** — Double-line or bold border to distinguish from workers + +### Canvas layout modes + +- **Grid** (existing) — Works for small swarms (< 8 agents) +- **Tree** (existing) — Left sidebar hierarchy +- **Swarm view** (new) — Orchestrator centered at top, workers in a row below, children below that. Org chart layout. Toggled with `s` key. + +### Header bar + +- **Active persona count** — "4 agents" with breakdown +- **Swarm status** — "Orchestrating..." / "3 active, 1 completed" + +### New keybindings + +| Key | Action | +|-----|--------| +| `n` | Submit task to orchestrator (prompts for task input) | +| `p` | Manual persona picker — spawn specific persona without orchestrator | +| `m` | Send message to selected agent (prompts for text) | +| `s` | Toggle swarm view layout | +| `K` | Kill selected agent (stop its Codex process) | + +### Session panel + +When viewing an agent's session output, `dj-command` blocks are stripped. Users see reasoning and work output, not internal plumbing. + +## Section 7: End-to-End Flow + +### Example: "Add user authentication with JWT" + +``` +1. User starts DJ + -> Load config, personas, signals + -> Spawn orchestrator (system prompt has all personas + repo signals) + -> Canvas: one card "Orchestrator" (bold border, idle) + +2. User presses 'n', types task + -> DJ sends to orchestrator via SendUserInput() + -> Orchestrator analyzes, outputs spawn commands: + spawn architect: "Design JWT auth module structure" + spawn security: "Define token expiry and secret management" + +3. DJ parses commands, spawns two Codex processes + -> Canvas: orchestrator top, architect + security below with connectors + +4. Architect works, needs test coverage + -> Outputs spawn command for test persona + -> DJ spawns test agent as child of architect + -> Canvas: test card under architect + +5. Security agent completes + -> Result injected into orchestrator and architect + +6. All agents complete + -> Orchestrator synthesizes final summary + -> All cards show completed (blue) + +7. User clicks any card to see full session output +``` + +### Error handling + +- **Agent crash**: Pool detects via `client.Running()`, sets status to error, notifies orchestrator. Orchestrator can retry. +- **Orchestrator crash**: DJ detects, shows error in status bar, auto-respawns with fresh context. Workers keep running. +- **Malformed dj-command**: Parser logs error, skips command, continues processing. Does not crash. +- **Unknown persona ID**: Pool rejects, notifies orchestrator with available persona list. +- **Runaway spawning**: `max_agents` config limit. Pool rejects when at capacity. + +### Graceful shutdown + +When user quits (`q` or `Ctrl+C`): + +1. Stop all worker agents (close stdin, wait for exit) +2. Stop orchestrator last +3. Clean up PTY sessions + +## New Packages + +| Package | Purpose | +|---------|---------| +| `internal/pool/` | AgentPool, AgentProcess, PoolEvent — multi-process management | +| `internal/orchestrator/` | CommandParser, DJCommand — delta stream parsing | +| `internal/roster/` | PersonaDefinition, RepoSignals, LoadPersonas, LoadSignals | + +## Modified Packages + +| Package | Changes | +|---------|---------| +| `internal/tui/` | AppModel gains pool, swarm view layout, persona-aware cards, new keybindings | +| `internal/state/` | ThreadState gains AgentProcessID field | +| `internal/config/` | New roster and pool config sections | +| `cmd/dj/` | Startup flow adds persona loading and pool initialization | From a026b7cc6783a5a5996a7a2a6ce48ebf0d9c80a5 Mon Sep 17 00:00:00 2001 From: Robin White Date: Thu, 19 Mar 2026 10:22:01 -0400 Subject: [PATCH 02/41] docs: add agent pool implementation plan 18-task TDD plan covering roster loader, command parser, agent pool, pool event multiplexing, persona-aware UI, and startup integration. --- docs/plans/2026-03-19-agent-pool-plan.md | 2138 ++++++++++++++++++++++ 1 file changed, 2138 insertions(+) create mode 100644 docs/plans/2026-03-19-agent-pool-plan.md diff --git a/docs/plans/2026-03-19-agent-pool-plan.md b/docs/plans/2026-03-19-agent-pool-plan.md new file mode 100644 index 0000000..b6cd17f --- /dev/null +++ b/docs/plans/2026-03-19-agent-pool-plan.md @@ -0,0 +1,2138 @@ +# Agent Pool Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Transform DJ from a single-process Codex visualizer into a multi-process agent swarm orchestrator that reads roster personas and dynamically spawns, routes, and visualizes persona-typed Codex agents. + +**Architecture:** DJ reads `.roster/personas/*.md` and `.roster/signals.json` at startup. An AgentPool manages multiple Codex processes. An orchestrator Codex session analyzes tasks and emits structured `dj-command` blocks that DJ parses from delta streams to spawn workers, route messages, and coordinate completions. + +**Tech Stack:** Go 1.25, Bubble Tea, Lipgloss, JSON-RPC 2.0 over JSONL, YAML v3 for frontmatter parsing. + +**Design doc:** `docs/plans/2026-03-19-agent-pool-design.md` + +--- + +### Task 1: Roster Persona Loader — Types + +**Files:** +- Create: `internal/roster/types.go` +- Test: `internal/roster/types_test.go` + +**Step 1: Write the failing test** + +```go +package roster + +import "testing" + +func TestPersonaDefinitionFields(t *testing.T) { + persona := PersonaDefinition{ + ID: "architect", + Name: "Architect", + Description: "System architecture", + Triggers: []string{"new service", "API boundary"}, + Content: "## Principles\n\nFavour simplicity.", + } + + if persona.ID != "architect" { + t.Errorf("expected ID architect, got %s", persona.ID) + } + if len(persona.Triggers) != 2 { + t.Errorf("expected 2 triggers, got %d", len(persona.Triggers)) + } +} + +func TestRepoSignalsFields(t *testing.T) { + signals := RepoSignals{ + RepoName: "myapp", + Languages: []string{"Go", "TypeScript"}, + } + + if signals.RepoName != "myapp" { + t.Errorf("expected myapp, got %s", signals.RepoName) + } + if len(signals.Languages) != 2 { + t.Errorf("expected 2 languages, got %d", len(signals.Languages)) + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `go test ./internal/roster/ -run TestPersonaDefinition -v` +Expected: FAIL — package does not exist + +**Step 3: Write minimal implementation** + +```go +package roster + +type PersonaDefinition struct { + ID string `yaml:"id"` + Name string `yaml:"name"` + Description string `yaml:"description"` + Triggers []string `yaml:"triggers"` + Content string `yaml:"-"` +} + +type RepoSignals struct { + RepoName string `json:"repo_name"` + Languages []string `json:"languages"` + Frameworks []string `json:"frameworks"` + CIProvider string `json:"ci_provider,omitempty"` + LintConfig string `json:"lint_config,omitempty"` + IsMonorepo bool `json:"is_monorepo"` + HasDocker bool `json:"has_docker"` + HasE2E bool `json:"has_e2e"` + FileCount int `json:"file_count"` +} +``` + +**Step 4: Run test to verify it passes** + +Run: `go test ./internal/roster/ -v -race` +Expected: PASS + +**Step 5: Commit** + +```bash +git add internal/roster/types.go internal/roster/types_test.go +git commit -m "feat(roster): add PersonaDefinition and RepoSignals types" +``` + +--- + +### Task 2: Roster Persona Loader — Parse Frontmatter + +**Files:** +- Create: `internal/roster/loader.go` +- Test: `internal/roster/loader_test.go` + +**Step 1: Write the failing test** + +```go +package roster + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadPersonas(t *testing.T) { + dir := t.TempDir() + personaDir := filepath.Join(dir, "personas") + os.MkdirAll(personaDir, 0o755) + + content := "---\nid: architect\nname: Architect\ndescription: System architecture\ntriggers:\n - new service\n - API boundary\n---\n\n## Principles\n\nFavour simplicity." + os.WriteFile(filepath.Join(personaDir, "architect.md"), []byte(content), 0o644) + + personas, err := LoadPersonas(personaDir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(personas) != 1 { + t.Fatalf("expected 1 persona, got %d", len(personas)) + } + if personas[0].ID != "architect" { + t.Errorf("expected ID architect, got %s", personas[0].ID) + } + if personas[0].Name != "Architect" { + t.Errorf("expected name Architect, got %s", personas[0].Name) + } + if len(personas[0].Triggers) != 2 { + t.Errorf("expected 2 triggers, got %d", len(personas[0].Triggers)) + } + hasContent := personas[0].Content != "" + if !hasContent { + t.Error("expected non-empty content") + } +} + +func TestLoadPersonasEmptyDir(t *testing.T) { + dir := t.TempDir() + personas, err := LoadPersonas(dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(personas) != 0 { + t.Errorf("expected 0 personas, got %d", len(personas)) + } +} + +func TestLoadPersonasMissingDir(t *testing.T) { + _, err := LoadPersonas("/nonexistent/path") + if err == nil { + t.Error("expected error for missing directory") + } +} + +func TestLoadSignals(t *testing.T) { + dir := t.TempDir() + signalsJSON := `{"repo_name":"myapp","languages":["Go"],"frameworks":[],"ci_provider":"GitHub Actions","file_count":50}` + path := filepath.Join(dir, "signals.json") + os.WriteFile(path, []byte(signalsJSON), 0o644) + + signals, err := LoadSignals(path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if signals.RepoName != "myapp" { + t.Errorf("expected myapp, got %s", signals.RepoName) + } + if len(signals.Languages) != 1 { + t.Errorf("expected 1 language, got %d", len(signals.Languages)) + } + if signals.CIProvider != "GitHub Actions" { + t.Errorf("expected GitHub Actions, got %s", signals.CIProvider) + } +} + +func TestLoadSignalsMissingFile(t *testing.T) { + _, err := LoadSignals("/nonexistent/signals.json") + if err == nil { + t.Error("expected error for missing file") + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `go test ./internal/roster/ -run TestLoad -v` +Expected: FAIL — LoadPersonas and LoadSignals not defined + +**Step 3: Write minimal implementation** + +```go +package roster + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "go.yaml.in/yaml/v3" +) + +const frontmatterDelimiter = "---" + +func LoadPersonas(dir string) ([]PersonaDefinition, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return nil, fmt.Errorf("read persona dir: %w", err) + } + + var personas []PersonaDefinition + for _, entry := range entries { + isMarkdown := !entry.IsDir() && strings.HasSuffix(entry.Name(), ".md") + if !isMarkdown { + continue + } + persona, err := loadPersonaFile(filepath.Join(dir, entry.Name())) + if err != nil { + return nil, fmt.Errorf("load persona %s: %w", entry.Name(), err) + } + personas = append(personas, persona) + } + return personas, nil +} + +func loadPersonaFile(path string) (PersonaDefinition, error) { + data, err := os.ReadFile(path) + if err != nil { + return PersonaDefinition{}, fmt.Errorf("read file: %w", err) + } + + frontmatter, body, err := splitFrontmatter(string(data)) + if err != nil { + return PersonaDefinition{}, fmt.Errorf("parse frontmatter: %w", err) + } + + var persona PersonaDefinition + if err := yaml.Unmarshal([]byte(frontmatter), &persona); err != nil { + return PersonaDefinition{}, fmt.Errorf("unmarshal frontmatter: %w", err) + } + persona.Content = strings.TrimSpace(body) + return persona, nil +} + +func splitFrontmatter(content string) (string, string, error) { + trimmed := strings.TrimSpace(content) + if !strings.HasPrefix(trimmed, frontmatterDelimiter) { + return "", "", fmt.Errorf("missing opening frontmatter delimiter") + } + rest := trimmed[len(frontmatterDelimiter):] + endIndex := strings.Index(rest, "\n"+frontmatterDelimiter) + if endIndex == -1 { + return "", "", fmt.Errorf("missing closing frontmatter delimiter") + } + frontmatter := rest[:endIndex] + body := rest[endIndex+len("\n"+frontmatterDelimiter):] + return frontmatter, body, nil +} + +func LoadSignals(path string) (*RepoSignals, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read signals file: %w", err) + } + + var signals RepoSignals + if err := json.Unmarshal(data, &signals); err != nil { + return nil, fmt.Errorf("unmarshal signals: %w", err) + } + return &signals, nil +} +``` + +**Step 4: Run test to verify it passes** + +Run: `go test ./internal/roster/ -v -race` +Expected: PASS + +**Step 5: Commit** + +```bash +git add internal/roster/loader.go internal/roster/loader_test.go +git commit -m "feat(roster): add persona and signals file loaders" +``` + +--- + +### Task 3: Orchestrator Command Parser — Types and Parsing + +**Files:** +- Create: `internal/orchestrator/command.go` +- Test: `internal/orchestrator/command_test.go` + +**Step 1: Write the failing test** + +```go +package orchestrator + +import "testing" + +func TestCommandParserSingleBlock(t *testing.T) { + parser := NewCommandParser() + parser.Feed("Some text before\n```dj-command\n") + parser.Feed(`{"action":"spawn","persona":"architect","task":"Design API"}`) + parser.Feed("\n```\nSome text after") + + commands := parser.Flush() + if len(commands) != 1 { + t.Fatalf("expected 1 command, got %d", len(commands)) + } + if commands[0].Action != "spawn" { + t.Errorf("expected spawn, got %s", commands[0].Action) + } + if commands[0].Persona != "architect" { + t.Errorf("expected architect, got %s", commands[0].Persona) + } + if commands[0].Task != "Design API" { + t.Errorf("expected Design API, got %s", commands[0].Task) + } +} + +func TestCommandParserMultipleBlocks(t *testing.T) { + parser := NewCommandParser() + parser.Feed("```dj-command\n{\"action\":\"spawn\",\"persona\":\"architect\",\"task\":\"A\"}\n```\n") + parser.Feed("```dj-command\n{\"action\":\"spawn\",\"persona\":\"test\",\"task\":\"B\"}\n```\n") + + commands := parser.Flush() + if len(commands) != 2 { + t.Fatalf("expected 2 commands, got %d", len(commands)) + } + if commands[0].Persona != "architect" { + t.Errorf("expected architect, got %s", commands[0].Persona) + } + if commands[1].Persona != "test" { + t.Errorf("expected test, got %s", commands[1].Persona) + } +} + +func TestCommandParserChunkedDelta(t *testing.T) { + parser := NewCommandParser() + parser.Feed("```dj-") + parser.Feed("command\n{\"action\":") + parser.Feed("\"message\",\"target\":\"arch-1\"") + parser.Feed(",\"content\":\"hello\"}\n`") + parser.Feed("``\n") + + commands := parser.Flush() + if len(commands) != 1 { + t.Fatalf("expected 1 command, got %d", len(commands)) + } + if commands[0].Action != "message" { + t.Errorf("expected message, got %s", commands[0].Action) + } + if commands[0].Target != "arch-1" { + t.Errorf("expected arch-1, got %s", commands[0].Target) + } +} + +func TestCommandParserNoCommands(t *testing.T) { + parser := NewCommandParser() + parser.Feed("Just regular text with no commands at all.") + + commands := parser.Flush() + if len(commands) != 0 { + t.Errorf("expected 0 commands, got %d", len(commands)) + } +} + +func TestCommandParserMalformedJSON(t *testing.T) { + parser := NewCommandParser() + parser.Feed("```dj-command\n{invalid json}\n```\n") + + commands := parser.Flush() + if len(commands) != 0 { + t.Errorf("expected 0 commands for malformed JSON, got %d", len(commands)) + } +} + +func TestCommandParserStripsCommands(t *testing.T) { + parser := NewCommandParser() + parser.Feed("Before\n```dj-command\n{\"action\":\"complete\",\"content\":\"done\"}\n```\nAfter") + + _ = parser.Flush() + cleaned := parser.CleanedText() + if cleaned != "Before\nAfter" { + t.Errorf("expected 'Before\\nAfter', got %q", cleaned) + } +} + +func TestCommandParserCompleteAction(t *testing.T) { + parser := NewCommandParser() + parser.Feed("```dj-command\n{\"action\":\"complete\",\"content\":\"Task finished with 2 findings\"}\n```\n") + + commands := parser.Flush() + if len(commands) != 1 { + t.Fatalf("expected 1 command, got %d", len(commands)) + } + if commands[0].Action != "complete" { + t.Errorf("expected complete, got %s", commands[0].Action) + } + if commands[0].Content != "Task finished with 2 findings" { + t.Errorf("unexpected content: %s", commands[0].Content) + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `go test ./internal/orchestrator/ -v` +Expected: FAIL — package does not exist + +**Step 3: Write minimal implementation** + +```go +package orchestrator + +import ( + "encoding/json" + "strings" +) + +const ( + fenceOpen = "```dj-command\n" + fenceClose = "\n```" +) + +type DJCommand struct { + Action string `json:"action"` + Persona string `json:"persona,omitempty"` + Task string `json:"task,omitempty"` + Target string `json:"target,omitempty"` + Content string `json:"content,omitempty"` +} + +type CommandParser struct { + buffer strings.Builder + commands []DJCommand + cleanedText strings.Builder +} + +func NewCommandParser() *CommandParser { + return &CommandParser{} +} + +func (parser *CommandParser) Feed(delta string) { + parser.buffer.WriteString(delta) +} + +func (parser *CommandParser) Flush() []DJCommand { + parser.commands = nil + parser.cleanedText.Reset() + + text := parser.buffer.String() + parser.buffer.Reset() + + for { + openIndex := strings.Index(text, fenceOpen) + if openIndex == -1 { + parser.cleanedText.WriteString(text) + break + } + + parser.cleanedText.WriteString(text[:openIndex]) + rest := text[openIndex+len(fenceOpen):] + + closeIndex := strings.Index(rest, fenceClose) + if closeIndex == -1 { + parser.buffer.WriteString(text[openIndex:]) + break + } + + jsonBlock := strings.TrimSpace(rest[:closeIndex]) + var command DJCommand + if err := json.Unmarshal([]byte(jsonBlock), &command); err == nil { + parser.commands = append(parser.commands, command) + } + + remaining := rest[closeIndex+len(fenceClose):] + trimmedRemaining := strings.TrimPrefix(remaining, "\n") + text = trimmedRemaining + } + + return parser.commands +} + +func (parser *CommandParser) CleanedText() string { + return parser.cleanedText.String() +} +``` + +**Step 4: Run test to verify it passes** + +Run: `go test ./internal/orchestrator/ -v -race` +Expected: PASS + +**Step 5: Commit** + +```bash +git add internal/orchestrator/command.go internal/orchestrator/command_test.go +git commit -m "feat(orchestrator): add dj-command delta stream parser" +``` + +--- + +### Task 4: AgentPool — Types and Constructor + +**Files:** +- Create: `internal/pool/types.go` +- Create: `internal/pool/pool.go` +- Test: `internal/pool/pool_test.go` + +**Step 1: Write the failing test** + +```go +package pool + +import "testing" + +func TestNewAgentPool(t *testing.T) { + pool := NewAgentPool("codex", []string{"proto"}, nil, DefaultMaxAgents) + + if pool == nil { + t.Fatal("expected non-nil pool") + } + + agents := pool.All() + if len(agents) != 0 { + t.Errorf("expected 0 agents, got %d", len(agents)) + } +} + +func TestAgentPoolGet(t *testing.T) { + pool := NewAgentPool("codex", []string{"proto"}, nil, DefaultMaxAgents) + + _, exists := pool.Get("nonexistent") + if exists { + t.Error("expected agent to not exist") + } +} + +func TestAgentRoleConstants(t *testing.T) { + if RoleOrchestrator != "orchestrator" { + t.Errorf("expected orchestrator, got %s", RoleOrchestrator) + } + if RoleWorker != "worker" { + t.Errorf("expected worker, got %s", RoleWorker) + } +} + +func TestAgentStatusConstants(t *testing.T) { + if AgentStatusSpawning != "spawning" { + t.Errorf("expected spawning, got %s", AgentStatusSpawning) + } + if AgentStatusActive != "active" { + t.Errorf("expected active, got %s", AgentStatusActive) + } + if AgentStatusCompleted != "completed" { + t.Errorf("expected completed, got %s", AgentStatusCompleted) + } + if AgentStatusError != "error" { + t.Errorf("expected error, got %s", AgentStatusError) + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `go test ./internal/pool/ -v` +Expected: FAIL — package does not exist + +**Step 3: Write types** + +`internal/pool/types.go`: + +```go +package pool + +import ( + "github.com/robinojw/dj/internal/appserver" + "github.com/robinojw/dj/internal/orchestrator" + "github.com/robinojw/dj/internal/roster" +) + +const ( + RoleOrchestrator = "orchestrator" + RoleWorker = "worker" +) + +const ( + AgentStatusSpawning = "spawning" + AgentStatusActive = "active" + AgentStatusCompleted = "completed" + AgentStatusError = "error" +) + +type AgentProcess struct { + ID string + PersonaID string + ThreadID string + Client *appserver.Client + Role string + Task string + Status string + ParentID string + Persona *roster.PersonaDefinition + Parser *orchestrator.CommandParser +} + +type PoolEvent struct { + AgentID string + Message appserver.JSONRPCMessage +} +``` + +`internal/pool/pool.go`: + +```go +package pool + +import ( + "sync" + "sync/atomic" + + "github.com/robinojw/dj/internal/roster" +) + +const DefaultMaxAgents = 10 + +const poolEventChannelSize = 128 + +type AgentPool struct { + agents map[string]*AgentProcess + mu sync.RWMutex + events chan PoolEvent + command string + args []string + personas map[string]roster.PersonaDefinition + maxAgents int + idCounter atomic.Int64 +} + +func NewAgentPool(command string, args []string, personas []roster.PersonaDefinition, maxAgents int) *AgentPool { + personaMap := make(map[string]roster.PersonaDefinition, len(personas)) + for _, persona := range personas { + personaMap[persona.ID] = persona + } + + return &AgentPool{ + agents: make(map[string]*AgentProcess), + events: make(chan PoolEvent, poolEventChannelSize), + command: command, + args: args, + personas: personaMap, + maxAgents: maxAgents, + } +} + +func (pool *AgentPool) Events() <-chan PoolEvent { + return pool.events +} + +func (pool *AgentPool) Get(agentID string) (*AgentProcess, bool) { + pool.mu.RLock() + defer pool.mu.RUnlock() + + agent, exists := pool.agents[agentID] + return agent, exists +} + +func (pool *AgentPool) All() []*AgentProcess { + pool.mu.RLock() + defer pool.mu.RUnlock() + + result := make([]*AgentProcess, 0, len(pool.agents)) + for _, agent := range pool.agents { + result = append(result, agent) + } + return result +} + +func (pool *AgentPool) Count() int { + pool.mu.RLock() + defer pool.mu.RUnlock() + + return len(pool.agents) +} +``` + +**Step 4: Run test to verify it passes** + +Run: `go test ./internal/pool/ -v -race` +Expected: PASS + +**Step 5: Commit** + +```bash +git add internal/pool/types.go internal/pool/pool.go internal/pool/pool_test.go +git commit -m "feat(pool): add AgentPool types and constructor" +``` + +--- + +### Task 5: AgentPool — Spawn and Stop + +**Files:** +- Modify: `internal/pool/pool.go` +- Test: `internal/pool/spawn_test.go` + +**Step 1: Write the failing test** + +```go +package pool + +import ( + "testing" +) + +func TestSpawnRejectsUnknownPersona(t *testing.T) { + pool := NewAgentPool("codex", []string{"proto"}, nil, DefaultMaxAgents) + _, err := pool.Spawn("nonexistent", "some task", "") + if err == nil { + t.Error("expected error for unknown persona") + } +} + +func TestSpawnRejectsAtCapacity(t *testing.T) { + pool := NewAgentPool("codex", []string{"proto"}, nil, 0) + _, err := pool.Spawn("architect", "some task", "") + if err == nil { + t.Error("expected error when at capacity") + } +} + +func TestNextAgentID(t *testing.T) { + pool := NewAgentPool("codex", []string{"proto"}, nil, DefaultMaxAgents) + id1 := pool.nextAgentID("architect") + id2 := pool.nextAgentID("architect") + if id1 == id2 { + t.Errorf("expected unique IDs, got %s and %s", id1, id2) + } +} + +func TestStopAgentNotFound(t *testing.T) { + pool := NewAgentPool("codex", []string{"proto"}, nil, DefaultMaxAgents) + err := pool.StopAgent("nonexistent") + if err == nil { + t.Error("expected error for nonexistent agent") + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `go test ./internal/pool/ -run TestSpawn -v` +Expected: FAIL — Spawn not defined + +**Step 3: Add Spawn and Stop methods to pool.go** + +Append to `internal/pool/pool.go`: + +```go +func (pool *AgentPool) Spawn(personaID string, task string, parentAgentID string) (string, error) { + pool.mu.Lock() + defer pool.mu.Unlock() + + isAtCapacity := len(pool.agents) >= pool.maxAgents + if isAtCapacity { + return "", fmt.Errorf("agent pool at capacity (%d)", pool.maxAgents) + } + + persona, exists := pool.personas[personaID] + if !exists { + return "", fmt.Errorf("unknown persona: %s", personaID) + } + + agentID := pool.nextAgentID(personaID) + agent := &AgentProcess{ + ID: agentID, + PersonaID: personaID, + Role: RoleWorker, + Task: task, + Status: AgentStatusSpawning, + ParentID: parentAgentID, + Persona: &persona, + Parser: orchestrator.NewCommandParser(), + } + pool.agents[agentID] = agent + + return agentID, nil +} + +func (pool *AgentPool) StopAgent(agentID string) error { + pool.mu.Lock() + defer pool.mu.Unlock() + + agent, exists := pool.agents[agentID] + if !exists { + return fmt.Errorf("agent not found: %s", agentID) + } + + if agent.Client != nil { + agent.Client.Stop() + } + agent.Status = AgentStatusCompleted + delete(pool.agents, agentID) + return nil +} + +func (pool *AgentPool) StopAll() { + pool.mu.Lock() + defer pool.mu.Unlock() + + for _, agent := range pool.agents { + if agent.Client != nil { + agent.Client.Stop() + } + } + pool.agents = make(map[string]*AgentProcess) +} + +func (pool *AgentPool) nextAgentID(personaID string) string { + counter := pool.idCounter.Add(1) + return fmt.Sprintf("%s-%d", personaID, counter) +} +``` + +Add `fmt` and `"github.com/robinojw/dj/internal/orchestrator"` to the import block. + +**Step 4: Run test to verify it passes** + +Run: `go test ./internal/pool/ -v -race` +Expected: PASS + +**Step 5: Commit** + +```bash +git add internal/pool/pool.go internal/pool/spawn_test.go +git commit -m "feat(pool): add Spawn, StopAgent, and StopAll methods" +``` + +--- + +### Task 6: AgentPool — GetByThreadID and Orchestrator Lookup + +**Files:** +- Modify: `internal/pool/pool.go` +- Test: `internal/pool/lookup_test.go` + +**Step 1: Write the failing test** + +```go +package pool + +import ( + "testing" + + "github.com/robinojw/dj/internal/roster" +) + +func TestGetByThreadID(t *testing.T) { + personas := []roster.PersonaDefinition{{ID: "architect", Name: "Architect"}} + pool := NewAgentPool("codex", []string{"proto"}, personas, DefaultMaxAgents) + + agentID, _ := pool.Spawn("architect", "task", "") + agent, _ := pool.Get(agentID) + agent.ThreadID = "thread-abc" + + found, exists := pool.GetByThreadID("thread-abc") + if !exists { + t.Fatal("expected to find agent by thread ID") + } + if found.ID != agentID { + t.Errorf("expected %s, got %s", agentID, found.ID) + } +} + +func TestGetByThreadIDNotFound(t *testing.T) { + pool := NewAgentPool("codex", []string{"proto"}, nil, DefaultMaxAgents) + _, exists := pool.GetByThreadID("nonexistent") + if exists { + t.Error("expected agent to not exist") + } +} + +func TestGetOrchestrator(t *testing.T) { + pool := NewAgentPool("codex", []string{"proto"}, nil, DefaultMaxAgents) + + _, exists := pool.GetOrchestrator() + if exists { + t.Error("expected no orchestrator initially") + } +} + +func TestPersonas(t *testing.T) { + personas := []roster.PersonaDefinition{ + {ID: "architect", Name: "Architect"}, + {ID: "test", Name: "Tester"}, + } + pool := NewAgentPool("codex", []string{"proto"}, personas, DefaultMaxAgents) + + result := pool.Personas() + if len(result) != 2 { + t.Errorf("expected 2 personas, got %d", len(result)) + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `go test ./internal/pool/ -run TestGetByThreadID -v` +Expected: FAIL — GetByThreadID not defined + +**Step 3: Add lookup methods to pool.go** + +```go +func (pool *AgentPool) GetByThreadID(threadID string) (*AgentProcess, bool) { + pool.mu.RLock() + defer pool.mu.RUnlock() + + for _, agent := range pool.agents { + if agent.ThreadID == threadID { + return agent, true + } + } + return nil, false +} + +func (pool *AgentPool) GetOrchestrator() (*AgentProcess, bool) { + pool.mu.RLock() + defer pool.mu.RUnlock() + + for _, agent := range pool.agents { + if agent.Role == RoleOrchestrator { + return agent, true + } + } + return nil, false +} + +func (pool *AgentPool) Personas() map[string]roster.PersonaDefinition { + return pool.personas +} +``` + +**Step 4: Run test to verify it passes** + +Run: `go test ./internal/pool/ -v -race` +Expected: PASS + +**Step 5: Commit** + +```bash +git add internal/pool/pool.go internal/pool/lookup_test.go +git commit -m "feat(pool): add thread ID and orchestrator lookup methods" +``` + +--- + +### Task 7: Config — Add Roster and Pool Sections + +**Files:** +- Modify: `internal/config/config.go:13-63` +- Test: `internal/config/config_test.go` + +**Step 1: Write the failing test** + +Add to `internal/config/config_test.go`: + +```go +func TestDefaultRosterConfig(t *testing.T) { + cfg, err := Load("") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.Roster.Path != ".roster" { + t.Errorf("expected .roster, got %s", cfg.Roster.Path) + } + if !cfg.Roster.AutoOrchestrate { + t.Error("expected auto_orchestrate to be true by default") + } +} + +func TestDefaultPoolConfig(t *testing.T) { + cfg, err := Load("") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.Pool.MaxAgents != 10 { + t.Errorf("expected max_agents 10, got %d", cfg.Pool.MaxAgents) + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `go test ./internal/config/ -run TestDefaultRoster -v` +Expected: FAIL — cfg.Roster undefined + +**Step 3: Add RosterConfig and PoolConfig to config.go** + +Add the new types and fields: + +```go +type RosterConfig struct { + Path string + AutoOrchestrate bool +} + +type PoolConfig struct { + MaxAgents int +} +``` + +Add to `Config` struct: + +```go +Roster RosterConfig +Pool PoolConfig +``` + +Add defaults in `Load()`: + +```go +viperInstance.SetDefault("roster.path", ".roster") +viperInstance.SetDefault("roster.auto_orchestrate", true) +viperInstance.SetDefault("pool.max_agents", 10) +``` + +Add to config construction: + +```go +Roster: RosterConfig{ + Path: viperInstance.GetString("roster.path"), + AutoOrchestrate: viperInstance.GetBool("roster.auto_orchestrate"), +}, +Pool: PoolConfig{ + MaxAgents: viperInstance.GetInt("pool.max_agents"), +}, +``` + +**Step 4: Run test to verify it passes** + +Run: `go test ./internal/config/ -v -race` +Expected: PASS + +**Step 5: Commit** + +```bash +git add internal/config/config.go internal/config/config_test.go +git commit -m "feat(config): add roster and pool configuration sections" +``` + +--- + +### Task 8: ThreadState — Add AgentProcessID Field + +**Files:** +- Modify: `internal/state/thread.go:16-27` +- Test: `internal/state/thread_test.go` + +**Step 1: Write the failing test** + +Add to `internal/state/thread_test.go`: + +```go +func TestThreadStateAgentProcessID(t *testing.T) { + thread := NewThreadState("t1", "Test") + if thread.AgentProcessID != "" { + t.Error("expected empty AgentProcessID for new thread") + } + thread.AgentProcessID = "architect-1" + if thread.AgentProcessID != "architect-1" { + t.Errorf("expected architect-1, got %s", thread.AgentProcessID) + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `go test ./internal/state/ -run TestThreadStateAgentProcessID -v` +Expected: FAIL — AgentProcessID undefined + +**Step 3: Add field to ThreadState** + +In `internal/state/thread.go`, add to the `ThreadState` struct: + +```go +AgentProcessID string +``` + +**Step 4: Run test to verify it passes** + +Run: `go test ./internal/state/ -v -race` +Expected: PASS + +**Step 5: Commit** + +```bash +git add internal/state/thread.go internal/state/thread_test.go +git commit -m "feat(state): add AgentProcessID field to ThreadState" +``` + +--- + +### Task 9: TUI — New Pool-Aware Messages + +**Files:** +- Create: `internal/tui/msgs_pool.go` +- Test: `internal/tui/msgs_pool_test.go` + +**Step 1: Write the failing test** + +```go +package tui + +import "testing" + +func TestSpawnRequestMsgFields(t *testing.T) { + msg := SpawnRequestMsg{ + SourceAgentID: "orchestrator-1", + Persona: "architect", + Task: "Design API", + } + if msg.SourceAgentID != "orchestrator-1" { + t.Errorf("expected orchestrator-1, got %s", msg.SourceAgentID) + } +} + +func TestAgentMessageMsgFields(t *testing.T) { + msg := AgentMessageMsg{ + SourceAgentID: "test-1", + TargetAgentID: "architect-1", + Content: "Need rate limiter", + } + if msg.TargetAgentID != "architect-1" { + t.Errorf("expected architect-1, got %s", msg.TargetAgentID) + } +} + +func TestAgentCompleteMsgFields(t *testing.T) { + msg := AgentCompleteMsg{ + AgentID: "security-1", + Content: "Found 2 issues", + } + if msg.AgentID != "security-1" { + t.Errorf("expected security-1, got %s", msg.AgentID) + } +} + +func TestPoolEventMsgFields(t *testing.T) { + msg := PoolEventMsg{AgentID: "test-1"} + if msg.AgentID != "test-1" { + t.Errorf("expected test-1, got %s", msg.AgentID) + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `go test ./internal/tui/ -run TestSpawnRequestMsg -v` +Expected: FAIL — SpawnRequestMsg not defined + +**Step 3: Write the message types** + +```go +package tui + +import "github.com/robinojw/dj/internal/appserver" + +type SpawnRequestMsg struct { + SourceAgentID string + Persona string + Task string +} + +type AgentMessageMsg struct { + SourceAgentID string + TargetAgentID string + Content string +} + +type AgentCompleteMsg struct { + AgentID string + Content string +} + +type PoolEventMsg struct { + AgentID string + Message appserver.JSONRPCMessage +} +``` + +**Step 4: Run test to verify it passes** + +Run: `go test ./internal/tui/ -run "TestSpawnRequestMsg|TestAgentMessageMsg|TestAgentCompleteMsg|TestPoolEventMsg" -v -race` +Expected: PASS + +**Step 5: Commit** + +```bash +git add internal/tui/msgs_pool.go internal/tui/msgs_pool_test.go +git commit -m "feat(tui): add pool-aware message types" +``` + +--- + +### Task 10: TUI — Persona-Aware Card Colors + +**Files:** +- Modify: `internal/tui/card.go:10-34` +- Test: `internal/tui/card_test.go` + +**Step 1: Write the failing test** + +Add to `internal/tui/card_test.go`: + +```go +func TestPersonaColorMapping(t *testing.T) { + tests := []struct { + personaID string + expected lipgloss.Color + }{ + {"architect", PersonaColorArchitect}, + {"test", PersonaColorTest}, + {"security", PersonaColorSecurity}, + {"reviewer", PersonaColorReviewer}, + {"performance", PersonaColorPerformance}, + {"design", PersonaColorDesign}, + {"devops", PersonaColorDevOps}, + {"unknown", defaultPersonaColor}, + } + + for _, tc := range tests { + color := PersonaColor(tc.personaID) + if color != tc.expected { + t.Errorf("PersonaColor(%s) = %s, want %s", tc.personaID, color, tc.expected) + } + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `go test ./internal/tui/ -run TestPersonaColorMapping -v` +Expected: FAIL — PersonaColor not defined + +**Step 3: Add persona color palette to card.go** + +Add constants and lookup function: + +```go +var ( + PersonaColorArchitect = lipgloss.Color("33") + PersonaColorTest = lipgloss.Color("42") + PersonaColorSecurity = lipgloss.Color("196") + PersonaColorReviewer = lipgloss.Color("226") + PersonaColorPerformance = lipgloss.Color("44") + PersonaColorDesign = lipgloss.Color("201") + PersonaColorDevOps = lipgloss.Color("208") + PersonaColorDocs = lipgloss.Color("252") + PersonaColorAPI = lipgloss.Color("75") + PersonaColorData = lipgloss.Color("178") + PersonaColorAccessibility = lipgloss.Color("141") + defaultPersonaColor = lipgloss.Color("245") +) + +var personaColors = map[string]lipgloss.Color{ + "architect": PersonaColorArchitect, + "test": PersonaColorTest, + "security": PersonaColorSecurity, + "reviewer": PersonaColorReviewer, + "performance": PersonaColorPerformance, + "design": PersonaColorDesign, + "devops": PersonaColorDevOps, + "docs": PersonaColorDocs, + "api": PersonaColorAPI, + "data": PersonaColorData, + "accessibility": PersonaColorAccessibility, +} + +func PersonaColor(personaID string) lipgloss.Color { + color, exists := personaColors[personaID] + if !exists { + return defaultPersonaColor + } + return color +} +``` + +**Step 4: Run test to verify it passes** + +Run: `go test ./internal/tui/ -run TestPersonaColorMapping -v -race` +Expected: PASS + +**Step 5: Commit** + +```bash +git add internal/tui/card.go internal/tui/card_test.go +git commit -m "feat(tui): add persona color palette for agent cards" +``` + +--- + +### Task 11: TUI — Card Persona Badge and Orchestrator Border + +**Files:** +- Modify: `internal/tui/card.go:36-134` +- Test: `internal/tui/card_test.go` + +**Step 1: Write the failing test** + +Add to `internal/tui/card_test.go`: + +```go +func TestCardPersonaBadge(t *testing.T) { + thread := &state.ThreadState{ + ID: "t1", + Title: "Design API", + Status: state.StatusActive, + AgentProcessID: "architect-1", + } + card := NewCardModel(thread, false, false) + card.SetPersonaBadge("Architect") + view := card.View() + if !strings.Contains(view, "Architect") { + t.Error("expected persona badge in card view") + } +} + +func TestCardOrchestratorBorder(t *testing.T) { + thread := &state.ThreadState{ + ID: "t1", + Title: "Orchestrator", + Status: state.StatusIdle, + } + card := NewCardModel(thread, false, false) + card.SetOrchestrator(true) + view := card.View() + if view == "" { + t.Error("expected non-empty card view") + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `go test ./internal/tui/ -run TestCardPersonaBadge -v` +Expected: FAIL — SetPersonaBadge not defined + +**Step 3: Add persona badge and orchestrator flag to CardModel** + +Add fields to `CardModel`: + +```go +personaBadge string +orchestrator bool +``` + +Add setter methods: + +```go +func (card *CardModel) SetPersonaBadge(badge string) { + card.personaBadge = badge +} + +func (card *CardModel) SetOrchestrator(isOrchestrator bool) { + card.orchestrator = isOrchestrator +} +``` + +Modify `buildContent` to include the badge: + +```go +func (card CardModel) buildContent(title string, statusLine string) string { + hasBadge := card.personaBadge != "" + isSubAgent := card.thread.ParentID != "" + hasRole := isSubAgent && card.thread.AgentRole != "" + + badgeLine := "" + if hasBadge { + badgeColor := PersonaColor(strings.ToLower(card.personaBadge)) + badgeLine = lipgloss.NewStyle(). + Foreground(badgeColor). + Bold(true). + Render(card.personaBadge) + } + + roleLine := "" + if hasRole { + roleLine = lipgloss.NewStyle(). + Foreground(colorIdle). + Render(roleIndent + card.thread.AgentRole) + } + + lines := []string{title} + if badgeLine != "" { + lines = append(lines, badgeLine) + } + if roleLine != "" { + lines = append(lines, roleLine) + } + lines = append(lines, statusLine) + return strings.Join(lines, "\n") +} +``` + +Modify `buildBorderStyle` to handle orchestrator: + +```go +func (card CardModel) buildBorderStyle() lipgloss.Style { + style := lipgloss.NewStyle(). + Width(card.width). + Height(card.height). + Padding(0, 1) + + if card.orchestrator { + style = style. + Border(lipgloss.ThickBorder()). + BorderForeground(lipgloss.Color("214")) + } else if card.selected { + style = style. + Border(lipgloss.DoubleBorder()). + BorderForeground(lipgloss.Color("39")) + } else { + style = style.Border(lipgloss.RoundedBorder()) + } + return style +} +``` + +Add `"strings"` to imports. + +**Step 4: Run test to verify it passes** + +Run: `go test ./internal/tui/ -run "TestCardPersonaBadge|TestCardOrchestratorBorder" -v -race` +Expected: PASS + +**Step 5: Commit** + +```bash +git add internal/tui/card.go internal/tui/card_test.go +git commit -m "feat(tui): add persona badge and orchestrator border to cards" +``` + +--- + +### Task 12: TUI — AppModel Pool Integration + +**Files:** +- Modify: `internal/tui/app.go:1-60` +- Modify: `internal/tui/app_proto.go:1-53` +- Test: `internal/tui/app_test.go` + +**Step 1: Write the failing test** + +Add to `internal/tui/app_test.go`: + +```go +func TestNewAppModelWithPool(t *testing.T) { + store := state.NewThreadStore() + pool := pool.NewAgentPool("codex", []string{"proto"}, nil, 10) + app := NewAppModel(store, WithPool(pool)) + if app.pool == nil { + t.Error("expected pool to be set") + } +} + +func TestNewAppModelWithoutPool(t *testing.T) { + store := state.NewThreadStore() + app := NewAppModel(store) + if app.pool != nil { + t.Error("expected pool to be nil for backward compatibility") + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `go test ./internal/tui/ -run TestNewAppModelWithPool -v` +Expected: FAIL — WithPool not defined + +**Step 3: Add pool to AppModel** + +In `internal/tui/app.go`, add field: + +```go +pool *pool.AgentPool +``` + +Add option: + +```go +func WithPool(agentPool *pool.AgentPool) AppOption { + return func(app *AppModel) { + app.pool = agentPool + } +} +``` + +Add import for `"github.com/robinojw/dj/internal/pool"`. + +In `internal/tui/app_proto.go`, modify `connectClient` and `listenForEvents` to work with either a single client or a pool. When pool is set, listen on pool events instead. + +**Step 4: Run test to verify it passes** + +Run: `go test ./internal/tui/ -run "TestNewAppModelWithPool|TestNewAppModelWithoutPool" -v -race` +Expected: PASS + +**Step 5: Commit** + +```bash +git add internal/tui/app.go internal/tui/app_proto.go internal/tui/app_test.go +git commit -m "feat(tui): integrate AgentPool into AppModel" +``` + +--- + +### Task 13: TUI — Handle SpawnRequest, AgentMessage, AgentComplete + +**Files:** +- Create: `internal/tui/app_pool.go` +- Test: `internal/tui/app_pool_test.go` + +**Step 1: Write the failing test** + +```go +package tui + +import ( + "testing" + + "github.com/robinojw/dj/internal/pool" + "github.com/robinojw/dj/internal/roster" + "github.com/robinojw/dj/internal/state" +) + +func TestHandleSpawnRequestCreatesThread(t *testing.T) { + store := state.NewThreadStore() + personas := []roster.PersonaDefinition{{ID: "architect", Name: "Architect"}} + agentPool := pool.NewAgentPool("echo", []string{"hello"}, personas, 10) + + app := NewAppModel(store, WithPool(agentPool)) + msg := SpawnRequestMsg{ + SourceAgentID: "orchestrator-1", + Persona: "architect", + Task: "Design API", + } + + updated, _ := app.handleSpawnRequest(msg) + resultApp := updated.(AppModel) + threads := resultApp.store.All() + + hasThread := len(threads) > 0 + if !hasThread { + t.Error("expected at least one thread after spawn request") + } +} + +func TestHandleAgentCompleteUpdatesStatus(t *testing.T) { + store := state.NewThreadStore() + store.Add("t1", "Test Agent") + + agentPool := pool.NewAgentPool("echo", []string{}, nil, 10) + app := NewAppModel(store, WithPool(agentPool)) + + msg := AgentCompleteMsg{ + AgentID: "test-1", + Content: "Done", + } + + updated, _ := app.handleAgentComplete(msg) + _ = updated.(AppModel) +} +``` + +**Step 2: Run test to verify it fails** + +Run: `go test ./internal/tui/ -run TestHandleSpawnRequest -v` +Expected: FAIL — handleSpawnRequest not defined + +**Step 3: Implement pool event handlers** + +```go +package tui + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/robinojw/dj/internal/state" +) + +func (app AppModel) handleSpawnRequest(msg SpawnRequestMsg) (tea.Model, tea.Cmd) { + if app.pool == nil { + return app, nil + } + + agentID, err := app.pool.Spawn(msg.Persona, msg.Task, msg.SourceAgentID) + if err != nil { + app.statusBar.SetError(err.Error()) + return app, nil + } + + app.store.Add(agentID, msg.Task) + agent, exists := app.pool.Get(agentID) + if exists { + thread, threadExists := app.store.Get(agentID) + if threadExists { + thread.AgentProcessID = agentID + thread.AgentRole = msg.Persona + thread.ParentID = msg.SourceAgentID + } + _ = agent + } + + app.statusBar.SetThreadCount(len(app.store.All())) + app.tree.Refresh() + return app, nil +} + +func (app AppModel) handleAgentMessage(msg AgentMessageMsg) (tea.Model, tea.Cmd) { + if app.pool == nil { + return app, nil + } + + targetAgent, exists := app.pool.Get(msg.TargetAgentID) + if !exists { + return app, nil + } + + if targetAgent.Client == nil { + return app, nil + } + + sourceAgent, sourceExists := app.pool.Get(msg.SourceAgentID) + senderLabel := msg.SourceAgentID + if sourceExists && sourceAgent.Persona != nil { + senderLabel = sourceAgent.Persona.Name + } + + wrappedMessage := "[From: " + msg.SourceAgentID + " (" + senderLabel + ")] " + msg.Content + targetAgent.Client.SendUserInput(wrappedMessage) + return app, nil +} + +func (app AppModel) handleAgentComplete(msg AgentCompleteMsg) (tea.Model, tea.Cmd) { + app.store.UpdateStatus(msg.AgentID, state.StatusCompleted, "") + app.store.UpdateActivity(msg.AgentID, "") + return app, nil +} +``` + +**Step 4: Run test to verify it passes** + +Run: `go test ./internal/tui/ -run "TestHandleSpawnRequest|TestHandleAgentComplete" -v -race` +Expected: PASS + +**Step 5: Wire handlers into Update()** + +In `internal/tui/app.go`, add cases to `handleAgentMsg`: + +```go +case SpawnRequestMsg: + return app.handleSpawnRequest(msg) +case AgentMessageMsg: + return app.handleAgentMessage(msg) +case AgentCompleteMsg: + return app.handleAgentComplete(msg) +``` + +**Step 6: Commit** + +```bash +git add internal/tui/app_pool.go internal/tui/app_pool_test.go internal/tui/app.go +git commit -m "feat(tui): handle spawn request, agent message, and agent complete events" +``` + +--- + +### Task 14: TUI — Header and Status Bar Swarm Indicators + +**Files:** +- Modify: `internal/tui/header.go:15-22` +- Modify: `internal/tui/statusbar.go:24-91` +- Test: `internal/tui/header_test.go` +- Test: `internal/tui/statusbar_test.go` + +**Step 1: Write the failing test** + +Add to `internal/tui/header_test.go`: + +```go +func TestHeaderSwarmHints(t *testing.T) { + header := NewHeaderBar(80) + header.SetSwarmActive(true) + view := header.View() + if !strings.Contains(view, "p: persona") { + t.Error("expected persona hint when swarm is active") + } +} +``` + +Add to `internal/tui/statusbar_test.go`: + +```go +func TestStatusBarAgentCount(t *testing.T) { + bar := NewStatusBar() + bar.SetWidth(80) + bar.SetAgentCount(3, 1) + view := bar.View() + if !strings.Contains(view, "3 agents") { + t.Error("expected agent count in status bar") + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `go test ./internal/tui/ -run "TestHeaderSwarmHints|TestStatusBarAgentCount" -v` +Expected: FAIL — SetSwarmActive and SetAgentCount not defined + +**Step 3: Add swarm hints to header** + +In `internal/tui/header.go`, add `swarmActive bool` field to `HeaderBar`. Add `SetSwarmActive(bool)` method. In `View()`, append swarm-specific hints (`p: persona`, `m: message`, `s: swarm`) when active. + +In `internal/tui/statusbar.go`, add `agentCount int` and `completedCount int` fields. Add `SetAgentCount(total, completed int)` method. In `View()`, add agent count section when > 0. + +**Step 4: Run test to verify it passes** + +Run: `go test ./internal/tui/ -run "TestHeaderSwarmHints|TestStatusBarAgentCount" -v -race` +Expected: PASS + +**Step 5: Commit** + +```bash +git add internal/tui/header.go internal/tui/header_test.go internal/tui/statusbar.go internal/tui/statusbar_test.go +git commit -m "feat(tui): add swarm status indicators to header and status bar" +``` + +--- + +### Task 15: TUI — New Keybindings (p, m, s, K) + +**Files:** +- Modify: `internal/tui/app_keys.go:58-71` +- Test: `internal/tui/app_keys_test.go` + +**Step 1: Write the failing test** + +```go +func TestPersonaPickerKeybinding(t *testing.T) { + store := state.NewThreadStore() + app := NewAppModel(store) + msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'p'}} + updated, _ := app.handleRune(msg) + _ = updated +} +``` + +**Step 2: Run test to verify it fails** + +Run: `go test ./internal/tui/ -run TestPersonaPickerKeybinding -v` +Expected: May pass (no-op) or fail depending on existing handling + +**Step 3: Add keybindings to handleRune** + +In `internal/tui/app_keys.go`, update `handleRune`: + +```go +case "p": + return app.showPersonaPicker() +case "m": + return app.sendMessageToAgent() +case "K": + return app.killAgent() +``` + +Remap existing `s` (select/pin) to avoid conflict with swarm view. Existing `s` is used for pin toggle alongside `" "` — keep space for pin, use `s` for swarm toggle: + +```go +case "s": + return app.toggleSwarmView() +case " ": + return app.togglePin() +``` + +Implement stub methods in a new `internal/tui/app_swarm.go`: + +```go +func (app AppModel) showPersonaPicker() (tea.Model, tea.Cmd) { + return app, nil +} + +func (app AppModel) sendMessageToAgent() (tea.Model, tea.Cmd) { + return app, nil +} + +func (app AppModel) killAgent() (tea.Model, tea.Cmd) { + if app.pool == nil { + return app, nil + } + threadID := app.canvas.SelectedThreadID() + agent, exists := app.pool.GetByThreadID(threadID) + if !exists { + return app, nil + } + app.pool.StopAgent(agent.ID) + app.store.UpdateStatus(threadID, state.StatusCompleted, "") + return app, nil +} + +func (app AppModel) toggleSwarmView() (tea.Model, tea.Cmd) { + return app, nil +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `go test ./internal/tui/ -v -race` +Expected: PASS + +**Step 5: Commit** + +```bash +git add internal/tui/app_keys.go internal/tui/app_swarm.go internal/tui/app_keys_test.go +git commit -m "feat(tui): add p/m/s/K keybindings for swarm control" +``` + +--- + +### Task 16: Main — Startup Flow with Roster and Pool + +**Files:** +- Modify: `cmd/dj/main.go:35-59` +- Test: manual verification (requires codex CLI) + +**Step 1: Update runApp** + +```go +func runApp(cmd *cobra.Command, args []string) error { + cfg, err := config.Load(configPath) + if err != nil { + return fmt.Errorf("load config: %w", err) + } + + store := state.NewThreadStore() + var opts []tui.AppOption + + personas, signals := loadRoster(cfg) + hasPersonas := len(personas) > 0 + + if hasPersonas && cfg.Roster.AutoOrchestrate { + agentPool := pool.NewAgentPool( + cfg.AppServer.Command, + cfg.AppServer.Args, + personas, + cfg.Pool.MaxAgents, + ) + opts = append(opts, tui.WithPool(agentPool)) + _ = signals + } else { + client := appserver.NewClient(cfg.AppServer.Command, cfg.AppServer.Args...) + defer client.Stop() + opts = append(opts, tui.WithClient(client)) + } + + opts = append(opts, tui.WithInteractiveCommand(cfg.Interactive.Command, cfg.Interactive.Args...)) + app := tui.NewAppModel(store, opts...) + + program := tea.NewProgram(app, tea.WithAltScreen(), tea.WithMouseCellMotion()) + finalModel, err := program.Run() + + if finalApp, ok := finalModel.(tui.AppModel); ok { + finalApp.StopAllPTYSessions() + } + + return err +} + +func loadRoster(cfg *config.Config) ([]roster.PersonaDefinition, *roster.RepoSignals) { + personaDir := filepath.Join(cfg.Roster.Path, "personas") + personas, err := roster.LoadPersonas(personaDir) + if err != nil { + return nil, nil + } + + signalsPath := filepath.Join(cfg.Roster.Path, "signals.json") + signals, err := roster.LoadSignals(signalsPath) + if err != nil { + return personas, nil + } + + return personas, signals +} +``` + +Add imports: `"path/filepath"`, `"github.com/robinojw/dj/internal/pool"`, `"github.com/robinojw/dj/internal/roster"`. + +**Step 2: Run build to verify compilation** + +Run: `go build -o dj ./cmd/dj` +Expected: Build succeeds + +**Step 3: Run all tests** + +Run: `go test ./... -v -race` +Expected: All PASS + +**Step 4: Commit** + +```bash +git add cmd/dj/main.go +git commit -m "feat(main): integrate roster loading and agent pool into startup" +``` + +--- + +### Task 17: Integration — Pool Event Multiplexing + +**Files:** +- Modify: `internal/tui/app_proto.go` +- Create: `internal/tui/app_pool_events.go` +- Test: `internal/tui/app_pool_events_test.go` + +**Step 1: Write the failing test** + +```go +package tui + +import ( + "testing" + + "github.com/robinojw/dj/internal/appserver" + poolpkg "github.com/robinojw/dj/internal/pool" + "github.com/robinojw/dj/internal/state" +) + +func TestListenForPoolEvents(t *testing.T) { + store := state.NewThreadStore() + agentPool := poolpkg.NewAgentPool("echo", []string{}, nil, 10) + app := NewAppModel(store, WithPool(agentPool)) + + cmd := app.listenForPoolEvents() + if cmd == nil { + t.Error("expected non-nil command") + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `go test ./internal/tui/ -run TestListenForPoolEvents -v` +Expected: FAIL — listenForPoolEvents not defined + +**Step 3: Implement pool event listener** + +```go +package tui + +import ( + "fmt" + + tea "github.com/charmbracelet/bubbletea" + "github.com/robinojw/dj/internal/orchestrator" +) + +func (app AppModel) listenForPoolEvents() tea.Cmd { + if app.pool == nil { + return nil + } + return func() tea.Msg { + event, ok := <-app.pool.Events() + if !ok { + return AppServerErrorMsg{Err: fmt.Errorf("pool events closed")} + } + return PoolEventMsg{ + AgentID: event.AgentID, + Message: event.Message, + } + } +} + +func (app AppModel) handlePoolEvent(msg PoolEventMsg) (tea.Model, tea.Cmd) { + agent, exists := app.pool.Get(msg.AgentID) + if !exists { + return app, app.listenForPoolEvents() + } + + tuiMsg := V2MessageToMsg(msg.Message) + if tuiMsg == nil { + return app, app.listenForPoolEvents() + } + + if deltaMsg, ok := tuiMsg.(V2AgentDeltaMsg); ok { + agent.Parser.Feed(deltaMsg.Delta) + commands := agent.Parser.Flush() + return app.processCommands(msg.AgentID, commands, tuiMsg) + } + + updated, innerCmd := app.Update(tuiMsg) + resultApp := updated.(AppModel) + return resultApp, tea.Batch(innerCmd, resultApp.listenForPoolEvents()) +} + +func (app AppModel) processCommands(agentID string, commands []orchestrator.DJCommand, originalMsg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + updated, innerCmd := app.Update(originalMsg) + resultApp := updated.(AppModel) + cmds = append(cmds, innerCmd) + + for _, command := range commands { + switch command.Action { + case "spawn": + spawnMsg := SpawnRequestMsg{ + SourceAgentID: agentID, + Persona: command.Persona, + Task: command.Task, + } + spawnUpdated, spawnCmd := resultApp.handleSpawnRequest(spawnMsg) + resultApp = spawnUpdated.(AppModel) + cmds = append(cmds, spawnCmd) + case "message": + msgCmd := AgentMessageMsg{ + SourceAgentID: agentID, + TargetAgentID: command.Target, + Content: command.Content, + } + msgUpdated, msgInnerCmd := resultApp.handleAgentMessage(msgCmd) + resultApp = msgUpdated.(AppModel) + cmds = append(cmds, msgInnerCmd) + case "complete": + completeMsg := AgentCompleteMsg{ + AgentID: agentID, + Content: command.Content, + } + completeUpdated, completeCmd := resultApp.handleAgentComplete(completeMsg) + resultApp = completeUpdated.(AppModel) + cmds = append(cmds, completeCmd) + } + } + + cmds = append(cmds, resultApp.listenForPoolEvents()) + return resultApp, tea.Batch(cmds...) +} +``` + +**Step 4: Wire into Init() and Update()** + +In `app.go` `Init()`, add `app.listenForPoolEvents()` to the batch when pool is present. + +In `Update()`, add: + +```go +case PoolEventMsg: + return app.handlePoolEvent(msg) +``` + +**Step 5: Run tests** + +Run: `go test ./... -v -race` +Expected: PASS + +**Step 6: Commit** + +```bash +git add internal/tui/app_pool_events.go internal/tui/app_pool_events_test.go internal/tui/app.go +git commit -m "feat(tui): pool event multiplexing with command parsing" +``` + +--- + +### Task 18: Full Build Verification and Lint + +**Step 1: Run full test suite** + +Run: `go test ./... -v -race` +Expected: All PASS + +**Step 2: Run linter** + +Run: `golangci-lint run` +Expected: No errors (may need to fix funlen/cyclop violations by extracting helpers) + +**Step 3: Run build** + +Run: `go build -o dj ./cmd/dj` +Expected: Build succeeds + +**Step 4: Fix any lint violations** + +If `funlen` or `cyclop` flags functions as too long/complex, extract helper functions to stay within the 60-line / 15-complexity limits from `.golangci.yml`. + +**Step 5: Final commit** + +```bash +git add -A +git commit -m "chore: fix lint violations and verify full build" +``` + +--- + +## Summary + +| Task | Package | Description | +|------|---------|-------------| +| 1 | `internal/roster/` | PersonaDefinition and RepoSignals types | +| 2 | `internal/roster/` | Persona and signals file loaders | +| 3 | `internal/orchestrator/` | dj-command delta stream parser | +| 4 | `internal/pool/` | AgentPool types and constructor | +| 5 | `internal/pool/` | Spawn, StopAgent, StopAll methods | +| 6 | `internal/pool/` | Thread ID and orchestrator lookup | +| 7 | `internal/config/` | Roster and pool config sections | +| 8 | `internal/state/` | AgentProcessID field on ThreadState | +| 9 | `internal/tui/` | Pool-aware message types | +| 10 | `internal/tui/` | Persona color palette | +| 11 | `internal/tui/` | Card persona badge and orchestrator border | +| 12 | `internal/tui/` | AppModel pool integration | +| 13 | `internal/tui/` | Spawn request, agent message, agent complete handlers | +| 14 | `internal/tui/` | Header and status bar swarm indicators | +| 15 | `internal/tui/` | p/m/s/K keybindings | +| 16 | `cmd/dj/` | Startup flow with roster and pool | +| 17 | `internal/tui/` | Pool event multiplexing with command parsing | +| 18 | — | Full build verification and lint | From b660731885f2912c87a0c9337bce32db718fe116 Mon Sep 17 00:00:00 2001 From: Robin White Date: Thu, 19 Mar 2026 10:29:40 -0400 Subject: [PATCH 03/41] feat(roster): add PersonaDefinition and RepoSignals types --- internal/roster/types.go | 21 +++++++++++++++++ internal/roster/types_test.go | 43 +++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 internal/roster/types.go create mode 100644 internal/roster/types_test.go diff --git a/internal/roster/types.go b/internal/roster/types.go new file mode 100644 index 0000000..003c045 --- /dev/null +++ b/internal/roster/types.go @@ -0,0 +1,21 @@ +package roster + +type PersonaDefinition struct { + ID string `yaml:"id"` + Name string `yaml:"name"` + Description string `yaml:"description"` + Triggers []string `yaml:"triggers"` + Content string `yaml:"-"` +} + +type RepoSignals struct { + RepoName string `json:"repo_name"` + Languages []string `json:"languages"` + Frameworks []string `json:"frameworks"` + CIProvider string `json:"ci_provider,omitempty"` + LintConfig string `json:"lint_config,omitempty"` + IsMonorepo bool `json:"is_monorepo"` + HasDocker bool `json:"has_docker"` + HasE2E bool `json:"has_e2e"` + FileCount int `json:"file_count"` +} diff --git a/internal/roster/types_test.go b/internal/roster/types_test.go new file mode 100644 index 0000000..09451f4 --- /dev/null +++ b/internal/roster/types_test.go @@ -0,0 +1,43 @@ +package roster + +import "testing" + +const ( + testPersonaID = "architect" + testPersonaName = "Architect" + testPersonaDescription = "System architecture" + testRepoName = "myapp" + expectedTriggerCount = 2 + expectedLanguageCount = 2 +) + +func TestPersonaDefinitionFields(testing *testing.T) { + persona := PersonaDefinition{ + ID: testPersonaID, + Name: testPersonaName, + Description: testPersonaDescription, + Triggers: []string{"new service", "API boundary"}, + Content: "## Principles\n\nFavour simplicity.", + } + + if persona.ID != testPersonaID { + testing.Errorf("expected ID %s, got %s", testPersonaID, persona.ID) + } + if len(persona.Triggers) != expectedTriggerCount { + testing.Errorf("expected %d triggers, got %d", expectedTriggerCount, len(persona.Triggers)) + } +} + +func TestRepoSignalsFields(testing *testing.T) { + signals := RepoSignals{ + RepoName: testRepoName, + Languages: []string{"Go", "TypeScript"}, + } + + if signals.RepoName != testRepoName { + testing.Errorf("expected %s, got %s", testRepoName, signals.RepoName) + } + if len(signals.Languages) != expectedLanguageCount { + testing.Errorf("expected %d languages, got %d", expectedLanguageCount, len(signals.Languages)) + } +} From c81d42b6f63269f08f7f245386932ae097bd1a6f Mon Sep 17 00:00:00 2001 From: Robin White Date: Thu, 19 Mar 2026 10:30:50 -0400 Subject: [PATCH 04/41] feat(roster): add persona and signals file loaders --- internal/roster/loader.go | 82 +++++++++++++++++++++++++++++ internal/roster/loader_test.go | 96 ++++++++++++++++++++++++++++++++++ 2 files changed, 178 insertions(+) create mode 100644 internal/roster/loader.go create mode 100644 internal/roster/loader_test.go diff --git a/internal/roster/loader.go b/internal/roster/loader.go new file mode 100644 index 0000000..771582c --- /dev/null +++ b/internal/roster/loader.go @@ -0,0 +1,82 @@ +package roster + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "go.yaml.in/yaml/v3" +) + +const frontmatterDelimiter = "---" + +func LoadPersonas(dir string) ([]PersonaDefinition, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return nil, fmt.Errorf("read persona dir: %w", err) + } + + var personas []PersonaDefinition + for _, entry := range entries { + isMarkdown := !entry.IsDir() && strings.HasSuffix(entry.Name(), ".md") + if !isMarkdown { + continue + } + persona, err := loadPersonaFile(filepath.Join(dir, entry.Name())) + if err != nil { + return nil, fmt.Errorf("load persona %s: %w", entry.Name(), err) + } + personas = append(personas, persona) + } + return personas, nil +} + +func loadPersonaFile(path string) (PersonaDefinition, error) { + data, err := os.ReadFile(path) + if err != nil { + return PersonaDefinition{}, fmt.Errorf("read file: %w", err) + } + + frontmatter, body, err := splitFrontmatter(string(data)) + if err != nil { + return PersonaDefinition{}, fmt.Errorf("parse frontmatter: %w", err) + } + + var persona PersonaDefinition + if err := yaml.Unmarshal([]byte(frontmatter), &persona); err != nil { + return PersonaDefinition{}, fmt.Errorf("unmarshal frontmatter: %w", err) + } + persona.Content = strings.TrimSpace(body) + return persona, nil +} + +func splitFrontmatter(content string) (string, string, error) { + trimmed := strings.TrimSpace(content) + if !strings.HasPrefix(trimmed, frontmatterDelimiter) { + return "", "", fmt.Errorf("missing opening frontmatter delimiter") + } + rest := trimmed[len(frontmatterDelimiter):] + closingDelimiter := "\n" + frontmatterDelimiter + endIndex := strings.Index(rest, closingDelimiter) + if endIndex == -1 { + return "", "", fmt.Errorf("missing closing frontmatter delimiter") + } + frontmatter := rest[:endIndex] + body := rest[endIndex+len(closingDelimiter):] + return frontmatter, body, nil +} + +func LoadSignals(path string) (*RepoSignals, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read signals file: %w", err) + } + + var signals RepoSignals + if err := json.Unmarshal(data, &signals); err != nil { + return nil, fmt.Errorf("unmarshal signals: %w", err) + } + return &signals, nil +} diff --git a/internal/roster/loader_test.go b/internal/roster/loader_test.go new file mode 100644 index 0000000..b80bc3d --- /dev/null +++ b/internal/roster/loader_test.go @@ -0,0 +1,96 @@ +package roster + +import ( + "os" + "path/filepath" + "testing" +) + +const ( + testSignalsRepoName = "myapp" + testSignalsCIProvider = "GitHub Actions" + expectedPersonaCount = 1 + expectedSignalLangCount = 1 + + errUnexpected = "unexpected error: %v" + errExpectedSGotS = "expected %s, got %s" + + permDir = 0o755 + permFile = 0o644 +) + +func TestLoadPersonas(testing *testing.T) { + dir := testing.TempDir() + personaDir := filepath.Join(dir, "personas") + os.MkdirAll(personaDir, permDir) + + content := "---\nid: architect\nname: Architect\ndescription: System architecture\ntriggers:\n - new service\n - API boundary\n---\n\n## Principles\n\nFavour simplicity." + os.WriteFile(filepath.Join(personaDir, "architect.md"), []byte(content), permFile) + + personas, err := LoadPersonas(personaDir) + if err != nil { + testing.Fatalf(errUnexpected, err) + } + if len(personas) != expectedPersonaCount { + testing.Fatalf("expected %d persona, got %d", expectedPersonaCount, len(personas)) + } + if personas[0].ID != testPersonaID { + testing.Errorf(errExpectedSGotS, testPersonaID, personas[0].ID) + } + if personas[0].Name != testPersonaName { + testing.Errorf(errExpectedSGotS, testPersonaName, personas[0].Name) + } + if len(personas[0].Triggers) != expectedTriggerCount { + testing.Errorf("expected %d triggers, got %d", expectedTriggerCount, len(personas[0].Triggers)) + } + hasContent := personas[0].Content != "" + if !hasContent { + testing.Error("expected non-empty content") + } +} + +func TestLoadPersonasEmptyDir(testing *testing.T) { + dir := testing.TempDir() + personas, err := LoadPersonas(dir) + if err != nil { + testing.Fatalf(errUnexpected, err) + } + if len(personas) != 0 { + testing.Errorf("expected 0 personas, got %d", len(personas)) + } +} + +func TestLoadPersonasMissingDir(testing *testing.T) { + _, err := LoadPersonas("/nonexistent/path") + if err == nil { + testing.Error("expected error for missing directory") + } +} + +func TestLoadSignals(testing *testing.T) { + dir := testing.TempDir() + signalsJSON := `{"repo_name":"myapp","languages":["Go"],"frameworks":[],"ci_provider":"GitHub Actions","file_count":50}` + path := filepath.Join(dir, "signals.json") + os.WriteFile(path, []byte(signalsJSON), permFile) + + signals, err := LoadSignals(path) + if err != nil { + testing.Fatalf(errUnexpected, err) + } + if signals.RepoName != testSignalsRepoName { + testing.Errorf(errExpectedSGotS, testSignalsRepoName, signals.RepoName) + } + if len(signals.Languages) != expectedSignalLangCount { + testing.Errorf("expected %d language, got %d", expectedSignalLangCount, len(signals.Languages)) + } + if signals.CIProvider != testSignalsCIProvider { + testing.Errorf(errExpectedSGotS, testSignalsCIProvider, signals.CIProvider) + } +} + +func TestLoadSignalsMissingFile(testing *testing.T) { + _, err := LoadSignals("/nonexistent/signals.json") + if err == nil { + testing.Error("expected error for missing file") + } +} From e3b66791dfff41337bed71920f2d43f2f22635d9 Mon Sep 17 00:00:00 2001 From: Robin White Date: Thu, 19 Mar 2026 10:31:51 -0400 Subject: [PATCH 05/41] feat(orchestrator): add dj-command delta stream parser --- internal/orchestrator/command.go | 73 +++++++++++++++ internal/orchestrator/command_test.go | 126 ++++++++++++++++++++++++++ 2 files changed, 199 insertions(+) create mode 100644 internal/orchestrator/command.go create mode 100644 internal/orchestrator/command_test.go diff --git a/internal/orchestrator/command.go b/internal/orchestrator/command.go new file mode 100644 index 0000000..d6e5485 --- /dev/null +++ b/internal/orchestrator/command.go @@ -0,0 +1,73 @@ +package orchestrator + +import ( + "encoding/json" + "strings" +) + +const ( + fenceOpen = "```dj-command\n" + fenceClose = "\n```" +) + +type DJCommand struct { + Action string `json:"action"` + Persona string `json:"persona,omitempty"` + Task string `json:"task,omitempty"` + Target string `json:"target,omitempty"` + Content string `json:"content,omitempty"` +} + +type CommandParser struct { + buffer strings.Builder + commands []DJCommand + cleanedText strings.Builder +} + +func NewCommandParser() *CommandParser { + return &CommandParser{} +} + +func (parser *CommandParser) Feed(delta string) { + parser.buffer.WriteString(delta) +} + +func (parser *CommandParser) Flush() []DJCommand { + parser.commands = nil + parser.cleanedText.Reset() + + text := parser.buffer.String() + parser.buffer.Reset() + + for { + openIndex := strings.Index(text, fenceOpen) + if openIndex == -1 { + parser.cleanedText.WriteString(text) + break + } + + parser.cleanedText.WriteString(text[:openIndex]) + rest := text[openIndex+len(fenceOpen):] + + closeIndex := strings.Index(rest, fenceClose) + if closeIndex == -1 { + parser.buffer.WriteString(text[openIndex:]) + break + } + + jsonBlock := strings.TrimSpace(rest[:closeIndex]) + var command DJCommand + if err := json.Unmarshal([]byte(jsonBlock), &command); err == nil { + parser.commands = append(parser.commands, command) + } + + remaining := rest[closeIndex+len(fenceClose):] + text = strings.TrimPrefix(remaining, "\n") + } + + return parser.commands +} + +func (parser *CommandParser) CleanedText() string { + return parser.cleanedText.String() +} diff --git a/internal/orchestrator/command_test.go b/internal/orchestrator/command_test.go new file mode 100644 index 0000000..bb7bc74 --- /dev/null +++ b/internal/orchestrator/command_test.go @@ -0,0 +1,126 @@ +package orchestrator + +import "testing" + +const ( + actionSpawn = "spawn" + actionMessage = "message" + actionComplete = "complete" + personaArch = "architect" + personaTest = "test" + targetArch1 = "arch-1" + taskDesignAPI = "Design API" + + expectedOneCommand = 1 + expectedTwoCommands = 2 + + errExpectedDCommands = "expected %d command(s), got %d" + errExpectedSGotS = "expected %s, got %s" +) + +func TestCommandParserSingleBlock(testing *testing.T) { + parser := NewCommandParser() + parser.Feed("Some text before\n```dj-command\n") + parser.Feed(`{"action":"spawn","persona":"architect","task":"Design API"}`) + parser.Feed("\n```\nSome text after") + + commands := parser.Flush() + if len(commands) != expectedOneCommand { + testing.Fatalf(errExpectedDCommands, expectedOneCommand, len(commands)) + } + if commands[0].Action != actionSpawn { + testing.Errorf(errExpectedSGotS, actionSpawn, commands[0].Action) + } + if commands[0].Persona != personaArch { + testing.Errorf(errExpectedSGotS, personaArch, commands[0].Persona) + } + if commands[0].Task != taskDesignAPI { + testing.Errorf(errExpectedSGotS, taskDesignAPI, commands[0].Task) + } +} + +func TestCommandParserMultipleBlocks(testing *testing.T) { + parser := NewCommandParser() + parser.Feed("```dj-command\n{\"action\":\"spawn\",\"persona\":\"architect\",\"task\":\"A\"}\n```\n") + parser.Feed("```dj-command\n{\"action\":\"spawn\",\"persona\":\"test\",\"task\":\"B\"}\n```\n") + + commands := parser.Flush() + if len(commands) != expectedTwoCommands { + testing.Fatalf(errExpectedDCommands, expectedTwoCommands, len(commands)) + } + if commands[0].Persona != personaArch { + testing.Errorf(errExpectedSGotS, personaArch, commands[0].Persona) + } + if commands[1].Persona != personaTest { + testing.Errorf(errExpectedSGotS, personaTest, commands[1].Persona) + } +} + +func TestCommandParserChunkedDelta(testing *testing.T) { + parser := NewCommandParser() + parser.Feed("```dj-") + parser.Feed("command\n{\"action\":") + parser.Feed("\"message\",\"target\":\"arch-1\"") + parser.Feed(",\"content\":\"hello\"}\n`") + parser.Feed("``\n") + + commands := parser.Flush() + if len(commands) != expectedOneCommand { + testing.Fatalf(errExpectedDCommands, expectedOneCommand, len(commands)) + } + if commands[0].Action != actionMessage { + testing.Errorf(errExpectedSGotS, actionMessage, commands[0].Action) + } + if commands[0].Target != targetArch1 { + testing.Errorf(errExpectedSGotS, targetArch1, commands[0].Target) + } +} + +func TestCommandParserNoCommands(testing *testing.T) { + parser := NewCommandParser() + parser.Feed("Just regular text with no commands at all.") + + commands := parser.Flush() + if len(commands) != 0 { + testing.Errorf("expected 0 commands, got %d", len(commands)) + } +} + +func TestCommandParserMalformedJSON(testing *testing.T) { + parser := NewCommandParser() + parser.Feed("```dj-command\n{invalid json}\n```\n") + + commands := parser.Flush() + if len(commands) != 0 { + testing.Errorf("expected 0 commands for malformed JSON, got %d", len(commands)) + } +} + +func TestCommandParserStripsCommands(testing *testing.T) { + parser := NewCommandParser() + parser.Feed("Before\n```dj-command\n{\"action\":\"complete\",\"content\":\"done\"}\n```\nAfter") + + _ = parser.Flush() + cleaned := parser.CleanedText() + expectedCleaned := "Before\nAfter" + if cleaned != expectedCleaned { + testing.Errorf("expected %q, got %q", expectedCleaned, cleaned) + } +} + +func TestCommandParserCompleteAction(testing *testing.T) { + parser := NewCommandParser() + parser.Feed("```dj-command\n{\"action\":\"complete\",\"content\":\"Task finished with 2 findings\"}\n```\n") + + commands := parser.Flush() + if len(commands) != expectedOneCommand { + testing.Fatalf(errExpectedDCommands, expectedOneCommand, len(commands)) + } + if commands[0].Action != actionComplete { + testing.Errorf(errExpectedSGotS, actionComplete, commands[0].Action) + } + expectedContent := "Task finished with 2 findings" + if commands[0].Content != expectedContent { + testing.Errorf(errExpectedSGotS, expectedContent, commands[0].Content) + } +} From 5926c8e4bde2177bc2c6d1460d4653dabc485dda Mon Sep 17 00:00:00 2001 From: Robin White Date: Thu, 19 Mar 2026 10:46:21 -0400 Subject: [PATCH 06/41] feat(pool): add AgentPool types and constructor --- internal/pool/pool.go | 69 ++++++++++++++++++++++++++++++++++++++ internal/pool/pool_test.go | 57 +++++++++++++++++++++++++++++++ internal/pool/types.go | 37 ++++++++++++++++++++ 3 files changed, 163 insertions(+) create mode 100644 internal/pool/pool.go create mode 100644 internal/pool/pool_test.go create mode 100644 internal/pool/types.go diff --git a/internal/pool/pool.go b/internal/pool/pool.go new file mode 100644 index 0000000..6c585a3 --- /dev/null +++ b/internal/pool/pool.go @@ -0,0 +1,69 @@ +package pool + +import ( + "sync" + "sync/atomic" + + "github.com/robinojw/dj/internal/roster" +) + +const DefaultMaxAgents = 10 + +const poolEventChannelSize = 128 + +type AgentPool struct { + agents map[string]*AgentProcess + mu sync.RWMutex + events chan PoolEvent + command string + args []string + personas map[string]roster.PersonaDefinition + maxAgents int + idCounter atomic.Int64 +} + +func NewAgentPool(command string, args []string, personas []roster.PersonaDefinition, maxAgents int) *AgentPool { + personaMap := make(map[string]roster.PersonaDefinition, len(personas)) + for _, persona := range personas { + personaMap[persona.ID] = persona + } + + return &AgentPool{ + agents: make(map[string]*AgentProcess), + events: make(chan PoolEvent, poolEventChannelSize), + command: command, + args: args, + personas: personaMap, + maxAgents: maxAgents, + } +} + +func (agentPool *AgentPool) Events() <-chan PoolEvent { + return agentPool.events +} + +func (agentPool *AgentPool) Get(agentID string) (*AgentProcess, bool) { + agentPool.mu.RLock() + defer agentPool.mu.RUnlock() + + agent, exists := agentPool.agents[agentID] + return agent, exists +} + +func (agentPool *AgentPool) All() []*AgentProcess { + agentPool.mu.RLock() + defer agentPool.mu.RUnlock() + + result := make([]*AgentProcess, 0, len(agentPool.agents)) + for _, agent := range agentPool.agents { + result = append(result, agent) + } + return result +} + +func (agentPool *AgentPool) Count() int { + agentPool.mu.RLock() + defer agentPool.mu.RUnlock() + + return len(agentPool.agents) +} diff --git a/internal/pool/pool_test.go b/internal/pool/pool_test.go new file mode 100644 index 0000000..99e71b3 --- /dev/null +++ b/internal/pool/pool_test.go @@ -0,0 +1,57 @@ +package pool + +import "testing" + +const ( + testCommand = "codex" + testArg = "proto" + expectedZero = 0 + errExpectedNil = "expected non-nil pool" + errExpectedDGot = "expected %d agents, got %d" +) + +func TestNewAgentPool(testing *testing.T) { + agentPool := NewAgentPool(testCommand, []string{testArg}, nil, DefaultMaxAgents) + + if agentPool == nil { + testing.Fatal(errExpectedNil) + } + + agents := agentPool.All() + if len(agents) != expectedZero { + testing.Errorf(errExpectedDGot, expectedZero, len(agents)) + } +} + +func TestAgentPoolGet(testing *testing.T) { + agentPool := NewAgentPool(testCommand, []string{testArg}, nil, DefaultMaxAgents) + + _, exists := agentPool.Get("nonexistent") + if exists { + testing.Error("expected agent to not exist") + } +} + +func TestAgentRoleConstants(testing *testing.T) { + if RoleOrchestrator != "orchestrator" { + testing.Errorf("expected orchestrator, got %s", RoleOrchestrator) + } + if RoleWorker != "worker" { + testing.Errorf("expected worker, got %s", RoleWorker) + } +} + +func TestAgentStatusConstants(testing *testing.T) { + if AgentStatusSpawning != "spawning" { + testing.Errorf("expected spawning, got %s", AgentStatusSpawning) + } + if AgentStatusActive != "active" { + testing.Errorf("expected active, got %s", AgentStatusActive) + } + if AgentStatusCompleted != "completed" { + testing.Errorf("expected completed, got %s", AgentStatusCompleted) + } + if AgentStatusError != "error" { + testing.Errorf("expected error, got %s", AgentStatusError) + } +} diff --git a/internal/pool/types.go b/internal/pool/types.go new file mode 100644 index 0000000..deb6402 --- /dev/null +++ b/internal/pool/types.go @@ -0,0 +1,37 @@ +package pool + +import ( + "github.com/robinojw/dj/internal/appserver" + "github.com/robinojw/dj/internal/orchestrator" + "github.com/robinojw/dj/internal/roster" +) + +const ( + RoleOrchestrator = "orchestrator" + RoleWorker = "worker" +) + +const ( + AgentStatusSpawning = "spawning" + AgentStatusActive = "active" + AgentStatusCompleted = "completed" + AgentStatusError = "error" +) + +type AgentProcess struct { + ID string + PersonaID string + ThreadID string + Client *appserver.Client + Role string + Task string + Status string + ParentID string + Persona *roster.PersonaDefinition + Parser *orchestrator.CommandParser +} + +type PoolEvent struct { + AgentID string + Message appserver.JSONRPCMessage +} From 0a9c88e67c36afd5be4baee6439c970bc27b39db Mon Sep 17 00:00:00 2001 From: Robin White Date: Thu, 19 Mar 2026 10:47:18 -0400 Subject: [PATCH 07/41] feat(pool): add Spawn, StopAgent, and StopAll methods --- internal/pool/pool.go | 66 +++++++++++++++++++++++++++++++++++++ internal/pool/spawn_test.go | 43 ++++++++++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 internal/pool/spawn_test.go diff --git a/internal/pool/pool.go b/internal/pool/pool.go index 6c585a3..b730bc2 100644 --- a/internal/pool/pool.go +++ b/internal/pool/pool.go @@ -1,9 +1,11 @@ package pool import ( + "fmt" "sync" "sync/atomic" + "github.com/robinojw/dj/internal/orchestrator" "github.com/robinojw/dj/internal/roster" ) @@ -67,3 +69,67 @@ func (agentPool *AgentPool) Count() int { return len(agentPool.agents) } + +func (agentPool *AgentPool) Spawn(personaID string, task string, parentAgentID string) (string, error) { + agentPool.mu.Lock() + defer agentPool.mu.Unlock() + + isAtCapacity := len(agentPool.agents) >= agentPool.maxAgents + if isAtCapacity { + return "", fmt.Errorf("agent pool at capacity (%d)", agentPool.maxAgents) + } + + persona, exists := agentPool.personas[personaID] + if !exists { + return "", fmt.Errorf("unknown persona: %s", personaID) + } + + agentID := agentPool.nextAgentID(personaID) + agent := &AgentProcess{ + ID: agentID, + PersonaID: personaID, + Role: RoleWorker, + Task: task, + Status: AgentStatusSpawning, + ParentID: parentAgentID, + Persona: &persona, + Parser: orchestrator.NewCommandParser(), + } + agentPool.agents[agentID] = agent + + return agentID, nil +} + +func (agentPool *AgentPool) StopAgent(agentID string) error { + agentPool.mu.Lock() + defer agentPool.mu.Unlock() + + agent, exists := agentPool.agents[agentID] + if !exists { + return fmt.Errorf("agent not found: %s", agentID) + } + + if agent.Client != nil { + agent.Client.Stop() + } + agent.Status = AgentStatusCompleted + delete(agentPool.agents, agentID) + return nil +} + +func (agentPool *AgentPool) StopAll() { + agentPool.mu.Lock() + defer agentPool.mu.Unlock() + + for _, agent := range agentPool.agents { + if agent.Client != nil { + agent.Client.Stop() + } + } + agentPool.agents = make(map[string]*AgentProcess) +} + +func (agentPool *AgentPool) nextAgentID(personaID string) string { + counter := agentPool.idCounter.Add(1) + return fmt.Sprintf("%s-%d", personaID, counter) +} diff --git a/internal/pool/spawn_test.go b/internal/pool/spawn_test.go new file mode 100644 index 0000000..69e882a --- /dev/null +++ b/internal/pool/spawn_test.go @@ -0,0 +1,43 @@ +package pool + +import "testing" + +const ( + testPersonaArch = "architect" + testTaskSome = "some task" + zeroMaxAgents = 0 + nonexistentID = "nonexistent" +) + +func TestSpawnRejectsUnknownPersona(testing *testing.T) { + agentPool := NewAgentPool(testCommand, []string{testArg}, nil, DefaultMaxAgents) + _, err := agentPool.Spawn(nonexistentID, testTaskSome, "") + if err == nil { + testing.Error("expected error for unknown persona") + } +} + +func TestSpawnRejectsAtCapacity(testing *testing.T) { + agentPool := NewAgentPool(testCommand, []string{testArg}, nil, zeroMaxAgents) + _, err := agentPool.Spawn(testPersonaArch, testTaskSome, "") + if err == nil { + testing.Error("expected error when at capacity") + } +} + +func TestNextAgentID(testing *testing.T) { + agentPool := NewAgentPool(testCommand, []string{testArg}, nil, DefaultMaxAgents) + id1 := agentPool.nextAgentID(testPersonaArch) + id2 := agentPool.nextAgentID(testPersonaArch) + if id1 == id2 { + testing.Errorf("expected unique IDs, got %s and %s", id1, id2) + } +} + +func TestStopAgentNotFound(testing *testing.T) { + agentPool := NewAgentPool(testCommand, []string{testArg}, nil, DefaultMaxAgents) + err := agentPool.StopAgent(nonexistentID) + if err == nil { + testing.Error("expected error for nonexistent agent") + } +} From d915966e7480903a083232d315d5a79d069b30fa Mon Sep 17 00:00:00 2001 From: Robin White Date: Thu, 19 Mar 2026 10:48:02 -0400 Subject: [PATCH 08/41] feat(pool): add thread ID and orchestrator lookup methods --- internal/pool/lookup_test.go | 64 ++++++++++++++++++++++++++++++++++++ internal/pool/pool.go | 28 ++++++++++++++++ 2 files changed, 92 insertions(+) create mode 100644 internal/pool/lookup_test.go diff --git a/internal/pool/lookup_test.go b/internal/pool/lookup_test.go new file mode 100644 index 0000000..310d8be --- /dev/null +++ b/internal/pool/lookup_test.go @@ -0,0 +1,64 @@ +package pool + +import ( + "testing" + + "github.com/robinojw/dj/internal/roster" +) + +const ( + testThreadABC = "thread-abc" + testPersonaArchID = "architect" + testPersonaTestID = "test" + testPersonaArchName = "Architect" + testPersonaTestName = "Tester" + testTask = "task" + expectedTwoPersonas = 2 +) + +func TestGetByThreadID(testing *testing.T) { + personas := []roster.PersonaDefinition{{ID: testPersonaArchID, Name: testPersonaArchName}} + agentPool := NewAgentPool(testCommand, []string{testArg}, personas, DefaultMaxAgents) + + agentID, _ := agentPool.Spawn(testPersonaArchID, testTask, "") + agent, _ := agentPool.Get(agentID) + agent.ThreadID = testThreadABC + + found, exists := agentPool.GetByThreadID(testThreadABC) + if !exists { + testing.Fatal("expected to find agent by thread ID") + } + if found.ID != agentID { + testing.Errorf("expected %s, got %s", agentID, found.ID) + } +} + +func TestGetByThreadIDNotFound(testing *testing.T) { + agentPool := NewAgentPool(testCommand, []string{testArg}, nil, DefaultMaxAgents) + _, exists := agentPool.GetByThreadID(nonexistentID) + if exists { + testing.Error("expected agent to not exist") + } +} + +func TestGetOrchestrator(testing *testing.T) { + agentPool := NewAgentPool(testCommand, []string{testArg}, nil, DefaultMaxAgents) + + _, exists := agentPool.GetOrchestrator() + if exists { + testing.Error("expected no orchestrator initially") + } +} + +func TestPersonas(testing *testing.T) { + personas := []roster.PersonaDefinition{ + {ID: testPersonaArchID, Name: testPersonaArchName}, + {ID: testPersonaTestID, Name: testPersonaTestName}, + } + agentPool := NewAgentPool(testCommand, []string{testArg}, personas, DefaultMaxAgents) + + result := agentPool.Personas() + if len(result) != expectedTwoPersonas { + testing.Errorf("expected %d personas, got %d", expectedTwoPersonas, len(result)) + } +} diff --git a/internal/pool/pool.go b/internal/pool/pool.go index b730bc2..468f98d 100644 --- a/internal/pool/pool.go +++ b/internal/pool/pool.go @@ -129,6 +129,34 @@ func (agentPool *AgentPool) StopAll() { agentPool.agents = make(map[string]*AgentProcess) } +func (agentPool *AgentPool) GetByThreadID(threadID string) (*AgentProcess, bool) { + agentPool.mu.RLock() + defer agentPool.mu.RUnlock() + + for _, agent := range agentPool.agents { + if agent.ThreadID == threadID { + return agent, true + } + } + return nil, false +} + +func (agentPool *AgentPool) GetOrchestrator() (*AgentProcess, bool) { + agentPool.mu.RLock() + defer agentPool.mu.RUnlock() + + for _, agent := range agentPool.agents { + if agent.Role == RoleOrchestrator { + return agent, true + } + } + return nil, false +} + +func (agentPool *AgentPool) Personas() map[string]roster.PersonaDefinition { + return agentPool.personas +} + func (agentPool *AgentPool) nextAgentID(personaID string) string { counter := agentPool.idCounter.Add(1) return fmt.Sprintf("%s-%d", personaID, counter) From 1268ac4076164b1aac1b76765fcda08d320cb233 Mon Sep 17 00:00:00 2001 From: Robin White Date: Thu, 19 Mar 2026 10:49:14 -0400 Subject: [PATCH 09/41] feat(config): add roster and pool configuration sections --- internal/config/config.go | 62 +++++++++++++++++++++++++++------- internal/config/config_test.go | 55 +++++++++++++++++++++++------- 2 files changed, 92 insertions(+), 25 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 9e85aa8..c3b5608 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -4,16 +4,33 @@ import ( "github.com/spf13/viper" ) +const defaultCommand = "codex" + const ( - DefaultAppServerCommand = "codex" - DefaultInteractiveCommand = "codex" - DefaultTheme = "default" + DefaultAppServerCommand = defaultCommand + DefaultInteractiveCommand = defaultCommand + DefaultTheme = "default" + DefaultRosterPath = ".roster" + DefaultMaxAgents = 10 +) + +const ( + keyAppServerCommand = "appserver.command" + keyAppServerArgs = "appserver.args" + keyInteractiveCommand = "interactive.command" + keyInteractiveArgs = "interactive.args" + keyUITheme = "ui.theme" + keyRosterPath = "roster.path" + keyRosterAutoOrch = "roster.auto_orchestrate" + keyPoolMaxAgents = "pool.max_agents" ) type Config struct { AppServer AppServerConfig Interactive InteractiveConfig UI UIConfig + Roster RosterConfig + Pool PoolConfig } type InteractiveConfig struct { @@ -30,15 +47,27 @@ type UIConfig struct { Theme string } +type RosterConfig struct { + Path string + AutoOrchestrate bool +} + +type PoolConfig struct { + MaxAgents int +} + func Load(path string) (*Config, error) { viperInstance := viper.New() viperInstance.SetConfigType("toml") - viperInstance.SetDefault("appserver.command", DefaultAppServerCommand) - viperInstance.SetDefault("appserver.args", []string{"proto"}) - viperInstance.SetDefault("interactive.command", DefaultInteractiveCommand) - viperInstance.SetDefault("interactive.args", []string{}) - viperInstance.SetDefault("ui.theme", DefaultTheme) + viperInstance.SetDefault(keyAppServerCommand, DefaultAppServerCommand) + viperInstance.SetDefault(keyAppServerArgs, []string{"proto"}) + viperInstance.SetDefault(keyInteractiveCommand, DefaultInteractiveCommand) + viperInstance.SetDefault(keyInteractiveArgs, []string{}) + viperInstance.SetDefault(keyUITheme, DefaultTheme) + viperInstance.SetDefault(keyRosterPath, DefaultRosterPath) + viperInstance.SetDefault(keyRosterAutoOrch, true) + viperInstance.SetDefault(keyPoolMaxAgents, DefaultMaxAgents) if path != "" { viperInstance.SetConfigFile(path) @@ -47,15 +76,22 @@ func Load(path string) (*Config, error) { cfg := &Config{ AppServer: AppServerConfig{ - Command: viperInstance.GetString("appserver.command"), - Args: viperInstance.GetStringSlice("appserver.args"), + Command: viperInstance.GetString(keyAppServerCommand), + Args: viperInstance.GetStringSlice(keyAppServerArgs), }, Interactive: InteractiveConfig{ - Command: viperInstance.GetString("interactive.command"), - Args: viperInstance.GetStringSlice("interactive.args"), + Command: viperInstance.GetString(keyInteractiveCommand), + Args: viperInstance.GetStringSlice(keyInteractiveArgs), }, UI: UIConfig{ - Theme: viperInstance.GetString("ui.theme"), + Theme: viperInstance.GetString(keyUITheme), + }, + Roster: RosterConfig{ + Path: viperInstance.GetString(keyRosterPath), + AutoOrchestrate: viperInstance.GetBool(keyRosterAutoOrch), + }, + Pool: PoolConfig{ + MaxAgents: viperInstance.GetInt(keyPoolMaxAgents), }, } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 4afb65b..18888a4 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -6,18 +6,26 @@ import ( "testing" ) -func TestLoadDefaults(t *testing.T) { +const ( + errLoadFailed = "Load failed: %v" + errUnexpected = "unexpected error: %v" + expectedDefaultRosterPath = ".roster" + expectedDefaultMaxAgents = 10 + permFile = 0o644 +) + +func TestLoadDefaults(testing *testing.T) { cfg, err := Load("") if err != nil { - t.Fatalf("Load failed: %v", err) + testing.Fatalf(errLoadFailed, err) } if cfg.AppServer.Command != DefaultAppServerCommand { - t.Errorf("expected default command %s, got %s", DefaultAppServerCommand, cfg.AppServer.Command) + testing.Errorf("expected default command %s, got %s", DefaultAppServerCommand, cfg.AppServer.Command) } } -func TestLoadFromFile(t *testing.T) { - dir := t.TempDir() +func TestLoadFromFile(testing *testing.T) { + dir := testing.TempDir() path := filepath.Join(dir, "dj.toml") content := ` @@ -27,26 +35,49 @@ command = "/usr/local/bin/codex" [ui] theme = "dark" ` - os.WriteFile(path, []byte(content), 0644) + os.WriteFile(path, []byte(content), permFile) cfg, err := Load(path) if err != nil { - t.Fatalf("Load failed: %v", err) + testing.Fatalf(errLoadFailed, err) } if cfg.AppServer.Command != "/usr/local/bin/codex" { - t.Errorf("expected custom command, got %s", cfg.AppServer.Command) + testing.Errorf("expected custom command, got %s", cfg.AppServer.Command) } if cfg.UI.Theme != "dark" { - t.Errorf("expected dark theme, got %s", cfg.UI.Theme) + testing.Errorf("expected dark theme, got %s", cfg.UI.Theme) } } -func TestLoadMissingFileUsesDefaults(t *testing.T) { +func TestLoadMissingFileUsesDefaults(testing *testing.T) { cfg, err := Load("/nonexistent/dj.toml") if err != nil { - t.Fatalf("Load failed: %v", err) + testing.Fatalf(errLoadFailed, err) } if cfg.AppServer.Command != DefaultAppServerCommand { - t.Errorf("expected default command, got %s", cfg.AppServer.Command) + testing.Errorf("expected default command, got %s", cfg.AppServer.Command) + } +} + +func TestDefaultRosterConfig(testing *testing.T) { + cfg, err := Load("") + if err != nil { + testing.Fatalf(errUnexpected, err) + } + if cfg.Roster.Path != expectedDefaultRosterPath { + testing.Errorf("expected %s, got %s", expectedDefaultRosterPath, cfg.Roster.Path) + } + if !cfg.Roster.AutoOrchestrate { + testing.Error("expected auto_orchestrate to be true by default") + } +} + +func TestDefaultPoolConfig(testing *testing.T) { + cfg, err := Load("") + if err != nil { + testing.Fatalf(errUnexpected, err) + } + if cfg.Pool.MaxAgents != expectedDefaultMaxAgents { + testing.Errorf("expected max_agents %d, got %d", expectedDefaultMaxAgents, cfg.Pool.MaxAgents) } } From b399456f721e2dcfb93ed5ffd58523d0e8fa0eda Mon Sep 17 00:00:00 2001 From: Robin White Date: Thu, 19 Mar 2026 10:50:12 -0400 Subject: [PATCH 10/41] feat(state): add AgentProcessID field to ThreadState --- internal/state/thread.go | 21 +++++++++++---------- internal/state/thread_test.go | 29 +++++++++++++++++++++-------- 2 files changed, 32 insertions(+), 18 deletions(-) diff --git a/internal/state/thread.go b/internal/state/thread.go index 886c2d6..17a01e2 100644 --- a/internal/state/thread.go +++ b/internal/state/thread.go @@ -14,16 +14,17 @@ type ChatMessage struct { } type ThreadState struct { - ID string - Title string - Status string - Activity string - ParentID string - AgentNickname string - AgentRole string - Depth int - Messages []ChatMessage - CommandOutput map[string]string + ID string + Title string + Status string + Activity string + ParentID string + AgentNickname string + AgentRole string + AgentProcessID string + Depth int + Messages []ChatMessage + CommandOutput map[string]string } func NewThreadState(id string, title string) *ThreadState { diff --git a/internal/state/thread_test.go b/internal/state/thread_test.go index dd156bd..32414c7 100644 --- a/internal/state/thread_test.go +++ b/internal/state/thread_test.go @@ -3,14 +3,16 @@ package state import "testing" const ( - testThreadID = "t-1" - testTitle = "Test" - testMessageID = "m-1" - testExecID = "e-1" - testGreeting = "Hello" - testActivity = "Running: git status" + testThreadID = "t-1" + testTitle = "Test" + testMessageID = "m-1" + testExecID = "e-1" + testGreeting = "Hello" + testActivity = "Running: git status" + testAgentProcess = "architect-1" - errExpectedHello = "expected Hello, got %s" + errExpectedHello = "expected Hello, got %s" + errExpectedSGotS = "expected %s, got %s" ) func TestNewThreadState(testing *testing.T) { @@ -67,7 +69,7 @@ func TestThreadStateSetActivity(testing *testing.T) { thread.SetActivity(testActivity) if thread.Activity != testActivity { - testing.Errorf("expected %s, got %s", testActivity, thread.Activity) + testing.Errorf(errExpectedSGotS, testActivity, thread.Activity) } } @@ -80,3 +82,14 @@ func TestThreadStateClearActivity(testing *testing.T) { testing.Errorf("expected empty activity, got %s", thread.Activity) } } + +func TestThreadStateAgentProcessID(testing *testing.T) { + thread := NewThreadState(testThreadID, testTitle) + if thread.AgentProcessID != "" { + testing.Error("expected empty AgentProcessID for new thread") + } + thread.AgentProcessID = testAgentProcess + if thread.AgentProcessID != testAgentProcess { + testing.Errorf(errExpectedSGotS, testAgentProcess, thread.AgentProcessID) + } +} From b680ab5a7798663ce78569170d0b2ad055f1b3e8 Mon Sep 17 00:00:00 2001 From: Robin White Date: Thu, 19 Mar 2026 10:51:04 -0400 Subject: [PATCH 11/41] feat(tui): add pool-aware message types --- internal/tui/msgs_pool.go | 25 ++++++++++++++++ internal/tui/msgs_pool_test.go | 55 ++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 internal/tui/msgs_pool.go create mode 100644 internal/tui/msgs_pool_test.go diff --git a/internal/tui/msgs_pool.go b/internal/tui/msgs_pool.go new file mode 100644 index 0000000..522a33e --- /dev/null +++ b/internal/tui/msgs_pool.go @@ -0,0 +1,25 @@ +package tui + +import "github.com/robinojw/dj/internal/appserver" + +type SpawnRequestMsg struct { + SourceAgentID string + Persona string + Task string +} + +type AgentMessageMsg struct { + SourceAgentID string + TargetAgentID string + Content string +} + +type AgentCompleteMsg struct { + AgentID string + Content string +} + +type PoolEventMsg struct { + AgentID string + Message appserver.JSONRPCMessage +} diff --git a/internal/tui/msgs_pool_test.go b/internal/tui/msgs_pool_test.go new file mode 100644 index 0000000..c30dfa8 --- /dev/null +++ b/internal/tui/msgs_pool_test.go @@ -0,0 +1,55 @@ +package tui + +import "testing" + +const ( + testOrchestratorID = "orchestrator-1" + testArchitectID = "architect-1" + testSecurityID = "security-1" + testTestID = "test-1" + testPersonaArch = "architect" + testTaskDesignAPI = "Design API" + testMsgContent = "Need rate limiter" + testFindingsMsg = "Found 2 issues" + + errPoolExpectedSGotS = "expected %s, got %s" +) + +func TestSpawnRequestMsgFields(testing *testing.T) { + msg := SpawnRequestMsg{ + SourceAgentID: testOrchestratorID, + Persona: testPersonaArch, + Task: testTaskDesignAPI, + } + if msg.SourceAgentID != testOrchestratorID { + testing.Errorf(errPoolExpectedSGotS, testOrchestratorID, msg.SourceAgentID) + } +} + +func TestAgentMessageMsgFields(testing *testing.T) { + msg := AgentMessageMsg{ + SourceAgentID: testTestID, + TargetAgentID: testArchitectID, + Content: testMsgContent, + } + if msg.TargetAgentID != testArchitectID { + testing.Errorf(errPoolExpectedSGotS, testArchitectID, msg.TargetAgentID) + } +} + +func TestAgentCompleteMsgFields(testing *testing.T) { + msg := AgentCompleteMsg{ + AgentID: testSecurityID, + Content: testFindingsMsg, + } + if msg.AgentID != testSecurityID { + testing.Errorf(errPoolExpectedSGotS, testSecurityID, msg.AgentID) + } +} + +func TestPoolEventMsgFields(testing *testing.T) { + msg := PoolEventMsg{AgentID: testTestID} + if msg.AgentID != testTestID { + testing.Errorf(errPoolExpectedSGotS, testTestID, msg.AgentID) + } +} From e6c10242fdb3e69a04d95dd7345ac8568f2a22a3 Mon Sep 17 00:00:00 2001 From: Robin White Date: Thu, 19 Mar 2026 10:52:38 -0400 Subject: [PATCH 12/41] feat(tui): add persona color palette for agent cards --- internal/tui/card.go | 48 ++++++++++++++++++++++++++++++++++++--- internal/tui/card_test.go | 24 ++++++++++++++++++++ 2 files changed, 69 insertions(+), 3 deletions(-) diff --git a/internal/tui/card.go b/internal/tui/card.go index a85be0c..efc5dd6 100644 --- a/internal/tui/card.go +++ b/internal/tui/card.go @@ -17,18 +17,60 @@ const ( ) var ( - colorIdle = lipgloss.Color("245") + colorGreen = lipgloss.Color("42") + colorRed = lipgloss.Color("196") + colorGray = lipgloss.Color("245") + colorIdle = colorGray +) +var ( statusColors = map[string]lipgloss.Color{ - state.StatusActive: lipgloss.Color("42"), + state.StatusActive: colorGreen, state.StatusIdle: colorIdle, state.StatusCompleted: lipgloss.Color("34"), - state.StatusError: lipgloss.Color("196"), + state.StatusError: colorRed, } defaultStatusColor = colorIdle ) +var ( + PersonaColorArchitect = lipgloss.Color("33") + PersonaColorTest = colorGreen + PersonaColorSecurity = colorRed + PersonaColorReviewer = lipgloss.Color("226") + PersonaColorPerformance = lipgloss.Color("44") + PersonaColorDesign = lipgloss.Color("201") + PersonaColorDevOps = lipgloss.Color("208") + PersonaColorDocs = lipgloss.Color("252") + PersonaColorAPI = lipgloss.Color("75") + PersonaColorData = lipgloss.Color("178") + PersonaColorAccessibility = lipgloss.Color("141") + defaultPersonaColor = colorGray +) + +var personaColors = map[string]lipgloss.Color{ + "architect": PersonaColorArchitect, + "test": PersonaColorTest, + "security": PersonaColorSecurity, + "reviewer": PersonaColorReviewer, + "performance": PersonaColorPerformance, + "design": PersonaColorDesign, + "devops": PersonaColorDevOps, + "docs": PersonaColorDocs, + "api": PersonaColorAPI, + "data": PersonaColorData, + "accessibility": PersonaColorAccessibility, +} + +func PersonaColor(personaID string) lipgloss.Color { + color, exists := personaColors[personaID] + if !exists { + return defaultPersonaColor + } + return color +} + const pinnedIndicator = " ✓" const subAgentPrefix = "↳ " const roleIndent = " " diff --git a/internal/tui/card_test.go b/internal/tui/card_test.go index 9aa268a..e9cbf54 100644 --- a/internal/tui/card_test.go +++ b/internal/tui/card_test.go @@ -4,6 +4,7 @@ import ( "strings" "testing" + "github.com/charmbracelet/lipgloss" "github.com/robinojw/dj/internal/state" ) @@ -165,3 +166,26 @@ func TestRootCardNoDepthPrefix(test *testing.T) { test.Error("root card should not have depth prefix") } } + +func TestPersonaColorMapping(testing *testing.T) { + tests := []struct { + personaID string + expected lipgloss.Color + }{ + {"architect", PersonaColorArchitect}, + {"test", PersonaColorTest}, + {"security", PersonaColorSecurity}, + {"reviewer", PersonaColorReviewer}, + {"performance", PersonaColorPerformance}, + {"design", PersonaColorDesign}, + {"devops", PersonaColorDevOps}, + {"unknown", defaultPersonaColor}, + } + + for _, testCase := range tests { + color := PersonaColor(testCase.personaID) + if color != testCase.expected { + testing.Errorf("PersonaColor(%s) = %s, want %s", testCase.personaID, color, testCase.expected) + } + } +} From 0c1a9775d996b1930235affd625d80cb930f56b1 Mon Sep 17 00:00:00 2001 From: Robin White Date: Thu, 19 Mar 2026 10:54:24 -0400 Subject: [PATCH 13/41] feat(tui): add persona badge and orchestrator border to cards --- internal/tui/card.go | 52 ++++++++++++++++++++++++++++++--------- internal/tui/card_test.go | 30 ++++++++++++++++++++++ 2 files changed, 71 insertions(+), 11 deletions(-) diff --git a/internal/tui/card.go b/internal/tui/card.go index efc5dd6..99999d3 100644 --- a/internal/tui/card.go +++ b/internal/tui/card.go @@ -1,7 +1,7 @@ package tui import ( - "fmt" + "strings" "github.com/charmbracelet/lipgloss" "github.com/robinojw/dj/internal/state" @@ -76,11 +76,13 @@ const subAgentPrefix = "↳ " const roleIndent = " " type CardModel struct { - thread *state.ThreadState - selected bool - pinned bool - width int - height int + thread *state.ThreadState + selected bool + pinned bool + orchestrator bool + personaBadge string + width int + height int } func NewCardModel(thread *state.ThreadState, selected bool, pinned bool) CardModel { @@ -104,6 +106,14 @@ func (card *CardModel) SetSize(width int, height int) { card.height = height } +func (card *CardModel) SetPersonaBadge(badge string) { + card.personaBadge = badge +} + +func (card *CardModel) SetOrchestrator(isOrchestrator bool) { + card.orchestrator = isOrchestrator +} + func (card CardModel) View() string { title := card.buildTitle() statusLine := card.buildStatusLine() @@ -149,30 +159,50 @@ func (card CardModel) buildStatusLine() string { } func (card CardModel) buildContent(title string, statusLine string) string { + lines := []string{title} + + hasBadge := card.personaBadge != "" + if hasBadge { + badgeColor := PersonaColor(strings.ToLower(card.personaBadge)) + badgeLine := lipgloss.NewStyle(). + Foreground(badgeColor). + Bold(true). + Render(card.personaBadge) + lines = append(lines, badgeLine) + } + isSubAgent := card.thread.ParentID != "" hasRole := isSubAgent && card.thread.AgentRole != "" if hasRole { roleLine := lipgloss.NewStyle(). Foreground(colorIdle). Render(roleIndent + card.thread.AgentRole) - return fmt.Sprintf("%s\n%s\n%s", title, roleLine, statusLine) + lines = append(lines, roleLine) } - return fmt.Sprintf("%s\n%s", title, statusLine) + + lines = append(lines, statusLine) + return strings.Join(lines, "\n") } func (card CardModel) buildBorderStyle() lipgloss.Style { style := lipgloss.NewStyle(). Width(card.width). Height(card.height). - Border(lipgloss.RoundedBorder()). Padding(0, 1) + if card.orchestrator { + return style. + Border(lipgloss.ThickBorder()). + BorderForeground(lipgloss.Color("214")) + } + if card.selected { - style = style. + return style. Border(lipgloss.DoubleBorder()). BorderForeground(lipgloss.Color("39")) } - return style + + return style.Border(lipgloss.RoundedBorder()) } func truncate(text string, maxLen int) string { diff --git a/internal/tui/card_test.go b/internal/tui/card_test.go index e9cbf54..c17ed0f 100644 --- a/internal/tui/card_test.go +++ b/internal/tui/card_test.go @@ -22,6 +22,7 @@ const ( testSubCardWidth = 30 testSubCardHeight = 6 testDepthArrow = "\u21b3" + testPersonaArchName = "Architect" ) func TestCardRenderShowsTitle(testing *testing.T) { @@ -167,6 +168,35 @@ func TestRootCardNoDepthPrefix(test *testing.T) { } } +func TestCardPersonaBadge(testing *testing.T) { + thread := &state.ThreadState{ + ID: testThreadID, + Title: testTaskDesignAPI, + Status: state.StatusActive, + AgentProcessID: testArchitectID, + } + card := NewCardModel(thread, false, false) + card.SetPersonaBadge(testPersonaArchName) + view := card.View() + if !strings.Contains(view, testPersonaArchName) { + testing.Error("expected persona badge in card view") + } +} + +func TestCardOrchestratorBorder(testing *testing.T) { + thread := &state.ThreadState{ + ID: testThreadID, + Title: "Orchestrator", + Status: state.StatusIdle, + } + card := NewCardModel(thread, false, false) + card.SetOrchestrator(true) + view := card.View() + if view == "" { + testing.Error("expected non-empty card view") + } +} + func TestPersonaColorMapping(testing *testing.T) { tests := []struct { personaID string From ec8c8f2e973964657ed16fc8a1ceafccacd49b52 Mon Sep 17 00:00:00 2001 From: Robin White Date: Thu, 19 Mar 2026 10:55:33 -0400 Subject: [PATCH 14/41] feat(tui): integrate AgentPool into AppModel --- internal/tui/app.go | 8 ++++++++ internal/tui/app_test.go | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/internal/tui/app.go b/internal/tui/app.go index 28d39ae..16cfab8 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -3,6 +3,7 @@ package tui import ( tea "github.com/charmbracelet/bubbletea" "github.com/robinojw/dj/internal/appserver" + "github.com/robinojw/dj/internal/pool" "github.com/robinojw/dj/internal/state" ) @@ -36,6 +37,7 @@ type AppModel struct { interactiveArgs []string header HeaderBar sessionPanel SessionPanelModel + pool *pool.AgentPool } func NewAppModel(store *state.ThreadStore, opts ...AppOption) AppModel { @@ -67,6 +69,12 @@ func WithClient(client *appserver.Client) AppOption { } } +func WithPool(agentPool *pool.AgentPool) AppOption { + return func(app *AppModel) { + app.pool = agentPool + } +} + func WithInteractiveCommand(command string, args ...string) AppOption { return func(app *AppModel) { app.interactiveCmd = command diff --git a/internal/tui/app_test.go b/internal/tui/app_test.go index 2b68252..4aa06af 100644 --- a/internal/tui/app_test.go +++ b/internal/tui/app_test.go @@ -4,6 +4,7 @@ import ( "testing" tea "github.com/charmbracelet/bubbletea" + poolpkg "github.com/robinojw/dj/internal/pool" "github.com/robinojw/dj/internal/state" ) @@ -35,6 +36,7 @@ const ( appTestExpectSessionFocus = "expected session focus, got %d" appTestExpectCanvasFocus = "expected FocusPaneCanvas, got %d" appTestExpectedStrFmt = "expected %s, got %s" + appTestPoolMaxAgents = 10 ) func TestAppHandlesArrowKeys(test *testing.T) { @@ -272,3 +274,20 @@ func TestAppHandlesThreadCreatedMsg(test *testing.T) { test.Errorf(appTestExpectSessionFocus, appModel.FocusPane()) } } + +func TestNewAppModelWithPool(testing *testing.T) { + store := state.NewThreadStore() + agentPool := poolpkg.NewAgentPool(appTestCmdEcho, []string{}, nil, appTestPoolMaxAgents) + app := NewAppModel(store, WithPool(agentPool)) + if app.pool == nil { + testing.Error("expected pool to be set") + } +} + +func TestNewAppModelWithoutPool(testing *testing.T) { + store := state.NewThreadStore() + app := NewAppModel(store) + if app.pool != nil { + testing.Error("expected pool to be nil for backward compatibility") + } +} From 2183d0ce0a3f3f16004536cd894d063f8982fa14 Mon Sep 17 00:00:00 2001 From: Robin White Date: Thu, 19 Mar 2026 10:57:01 -0400 Subject: [PATCH 15/41] feat(tui): handle spawn request, agent message, and agent complete events --- internal/tui/app.go | 13 +++++++ internal/tui/app_pool.go | 54 ++++++++++++++++++++++++++++ internal/tui/app_pool_test.go | 66 +++++++++++++++++++++++++++++++++++ 3 files changed, 133 insertions(+) create mode 100644 internal/tui/app_pool.go create mode 100644 internal/tui/app_pool_test.go diff --git a/internal/tui/app.go b/internal/tui/app.go index 16cfab8..38a355f 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -156,12 +156,25 @@ func (app AppModel) handleAgentMsg(msg tea.Msg) (tea.Model, tea.Cmd) { return app.handleCollabSpawn(msg) case CollabCloseMsg: return app.handleCollabClose(msg) + default: + return app.handleProtocolAndPoolMsg(msg) + } +} + +func (app AppModel) handleProtocolAndPoolMsg(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { case ThreadStatusChangedMsg: return app.handleThreadStatusChanged(msg) case V2ExecApprovalMsg: return app.handleV2ExecApproval(msg) case V2FileApprovalMsg: return app.handleV2FileApproval(msg) + case SpawnRequestMsg: + return app.handleSpawnRequest(msg) + case AgentMessageMsg: + return app.handleAgentMessage(msg) + case AgentCompleteMsg: + return app.handleAgentComplete(msg) } return app, nil } diff --git a/internal/tui/app_pool.go b/internal/tui/app_pool.go new file mode 100644 index 0000000..c436b95 --- /dev/null +++ b/internal/tui/app_pool.go @@ -0,0 +1,54 @@ +package tui + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/robinojw/dj/internal/state" +) + +func (app AppModel) handleSpawnRequest(msg SpawnRequestMsg) (tea.Model, tea.Cmd) { + if app.pool == nil { + return app, nil + } + + agentID, err := app.pool.Spawn(msg.Persona, msg.Task, msg.SourceAgentID) + if err != nil { + app.statusBar.SetError(err.Error()) + return app, nil + } + + app.store.Add(agentID, msg.Task) + thread, threadExists := app.store.Get(agentID) + if threadExists { + thread.AgentProcessID = agentID + thread.AgentRole = msg.Persona + thread.ParentID = msg.SourceAgentID + } + + app.statusBar.SetThreadCount(len(app.store.All())) + app.tree.Refresh() + return app, nil +} + +func (app AppModel) handleAgentMessage(msg AgentMessageMsg) (tea.Model, tea.Cmd) { + if app.pool == nil { + return app, nil + } + + targetAgent, exists := app.pool.Get(msg.TargetAgentID) + if !exists { + return app, nil + } + + if targetAgent.Client == nil { + return app, nil + } + + targetAgent.Client.SendUserInput(msg.Content) + return app, nil +} + +func (app AppModel) handleAgentComplete(msg AgentCompleteMsg) (tea.Model, tea.Cmd) { + app.store.UpdateStatus(msg.AgentID, state.StatusCompleted, "") + app.store.UpdateActivity(msg.AgentID, "") + return app, nil +} diff --git a/internal/tui/app_pool_test.go b/internal/tui/app_pool_test.go new file mode 100644 index 0000000..bede7b7 --- /dev/null +++ b/internal/tui/app_pool_test.go @@ -0,0 +1,66 @@ +package tui + +import ( + "testing" + + poolpkg "github.com/robinojw/dj/internal/pool" + "github.com/robinojw/dj/internal/roster" + "github.com/robinojw/dj/internal/state" +) + +const ( + poolTestPersonaID = "architect" + poolTestPersonaName = "Architect" + poolTestTaskLabel = "Design API" + poolTestSourceAgent = "orchestrator-1" + poolTestAgentID = "test-1" + poolTestThreadID = "t1" + poolTestThreadTitle = "Test Agent" + poolTestDoneContent = "Done" +) + +func TestHandleSpawnRequestCreatesThread(testing *testing.T) { + store := state.NewThreadStore() + personas := []roster.PersonaDefinition{{ID: poolTestPersonaID, Name: poolTestPersonaName}} + agentPool := poolpkg.NewAgentPool(appTestCmdEcho, []string{appTestArgHello}, personas, appTestPoolMaxAgents) + + app := NewAppModel(store, WithPool(agentPool)) + msg := SpawnRequestMsg{ + SourceAgentID: poolTestSourceAgent, + Persona: poolTestPersonaID, + Task: poolTestTaskLabel, + } + + updated, _ := app.handleSpawnRequest(msg) + resultApp := updated.(AppModel) + threads := resultApp.store.All() + + hasThread := len(threads) > 0 + if !hasThread { + testing.Error("expected at least one thread after spawn request") + } +} + +func TestHandleAgentCompleteUpdatesStatus(testing *testing.T) { + store := state.NewThreadStore() + store.Add(poolTestThreadID, poolTestThreadTitle) + + agentPool := poolpkg.NewAgentPool(appTestCmdEcho, []string{}, nil, appTestPoolMaxAgents) + app := NewAppModel(store, WithPool(agentPool)) + + msg := AgentCompleteMsg{ + AgentID: poolTestThreadID, + Content: poolTestDoneContent, + } + + updated, _ := app.handleAgentComplete(msg) + _ = updated.(AppModel) + + thread, exists := store.Get(poolTestThreadID) + if !exists { + testing.Fatal("expected thread to exist") + } + if thread.Status != state.StatusCompleted { + testing.Errorf("expected completed status, got %s", thread.Status) + } +} From f98009954aa1557f80e6db71a447e2da39f9a544 Mon Sep 17 00:00:00 2001 From: Robin White Date: Thu, 19 Mar 2026 10:58:39 -0400 Subject: [PATCH 16/41] feat(tui): add swarm status indicators to header and status bar --- internal/tui/header.go | 20 +++++++- internal/tui/header_test.go | 35 ++++++++++---- internal/tui/statusbar.go | 84 ++++++++++++++++++++-------------- internal/tui/statusbar_test.go | 45 ++++++++++++------ 4 files changed, 125 insertions(+), 59 deletions(-) diff --git a/internal/tui/header.go b/internal/tui/header.go index 016ef1a..714802a 100644 --- a/internal/tui/header.go +++ b/internal/tui/header.go @@ -23,8 +23,15 @@ var headerHints = []string{ const headerHintSeparator = " " +var swarmHints = []string{ + "p: persona", + "m: message", + "K: kill agent", +} + type HeaderBar struct { - width int + width int + swarmActive bool } func NewHeaderBar(width int) HeaderBar { @@ -35,11 +42,20 @@ func (header *HeaderBar) SetWidth(width int) { header.width = width } +func (header *HeaderBar) SetSwarmActive(active bool) { + header.swarmActive = active +} + func (header HeaderBar) View() string { title := headerTitleStyle.Render(headerTitle) + allHints := headerHints + if header.swarmActive { + allHints = append(allHints, swarmHints...) + } + hints := "" - for index, hint := range headerHints { + for index, hint := range allHints { if index > 0 { hints += headerHintSeparator } diff --git a/internal/tui/header_test.go b/internal/tui/header_test.go index 5850da5..6a1cf14 100644 --- a/internal/tui/header_test.go +++ b/internal/tui/header_test.go @@ -7,32 +7,47 @@ import ( "github.com/charmbracelet/lipgloss" ) -func TestHeaderBarRendersTitle(t *testing.T) { - header := NewHeaderBar(80) +const ( + headerTestWidth = 80 + headerTestWidthLg = 120 + headerTestMaxWidth = 120 +) + +func TestHeaderBarRendersTitle(testing *testing.T) { + header := NewHeaderBar(headerTestWidth) output := header.View() if !strings.Contains(output, "DJ") { - t.Errorf("expected title in header, got:\n%s", output) + testing.Errorf("expected title in header, got:\n%s", output) } } -func TestHeaderBarRendersShortcuts(t *testing.T) { - header := NewHeaderBar(80) +func TestHeaderBarRendersShortcuts(testing *testing.T) { + header := NewHeaderBar(headerTestWidth) output := header.View() if !strings.Contains(output, "n: new") { - t.Errorf("expected shortcut hints in header, got:\n%s", output) + testing.Errorf("expected shortcut hints in header, got:\n%s", output) } } -func TestHeaderBarFitsWidth(t *testing.T) { - header := NewHeaderBar(120) +func TestHeaderBarFitsWidth(testing *testing.T) { + header := NewHeaderBar(headerTestWidthLg) output := header.View() lines := strings.Split(output, "\n") for _, line := range lines { - if lipgloss.Width(line) > 120 { - t.Errorf("header exceeds width 120: len=%d", lipgloss.Width(line)) + if lipgloss.Width(line) > headerTestMaxWidth { + testing.Errorf("header exceeds width %d: len=%d", headerTestMaxWidth, lipgloss.Width(line)) } } } + +func TestHeaderSwarmHints(testing *testing.T) { + header := NewHeaderBar(headerTestWidth) + header.SetSwarmActive(true) + view := header.View() + if !strings.Contains(view, "p: persona") { + testing.Error("expected persona hint when swarm is active") + } +} diff --git a/internal/tui/statusbar.go b/internal/tui/statusbar.go index d2eb81a..270b01d 100644 --- a/internal/tui/statusbar.go +++ b/internal/tui/statusbar.go @@ -7,6 +7,8 @@ import ( ) var ( + statusBarColorRed = lipgloss.Color("196") + statusBarStyle = lipgloss.NewStyle(). Background(lipgloss.Color("236")). Foreground(lipgloss.Color("252")). @@ -14,78 +16,92 @@ var ( statusConnectedStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("42")) statusDisconnectedStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("196")) + Foreground(statusBarColorRed) statusErrorStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("196")). + Foreground(statusBarColorRed). Bold(true) ) -// StatusBar displays connection state and context info. type StatusBar struct { connected bool threadCount int selectedThread string errorMessage string width int + agentCount int + completedCount int } -// NewStatusBar creates a status bar. func NewStatusBar() *StatusBar { return &StatusBar{} } -// SetConnected updates the connection state. -func (s *StatusBar) SetConnected(connected bool) { - s.connected = connected +func (bar *StatusBar) SetConnected(connected bool) { + bar.connected = connected if connected { - s.errorMessage = "" + bar.errorMessage = "" } } -// SetThreadCount updates the thread count display. -func (s *StatusBar) SetThreadCount(count int) { - s.threadCount = count +func (bar *StatusBar) SetThreadCount(count int) { + bar.threadCount = count +} + +func (bar *StatusBar) SetSelectedThread(name string) { + bar.selectedThread = name } -// SetSelectedThread updates the selected thread name. -func (s *StatusBar) SetSelectedThread(name string) { - s.selectedThread = name +func (bar *StatusBar) SetError(msg string) { + bar.errorMessage = msg } -// SetError sets an error message. -func (s *StatusBar) SetError(msg string) { - s.errorMessage = msg +func (bar *StatusBar) SetWidth(width int) { + bar.width = width } -// SetWidth sets the status bar width. -func (s *StatusBar) SetWidth(width int) { - s.width = width +func (bar *StatusBar) SetAgentCount(total int, completed int) { + bar.agentCount = total + bar.completedCount = completed +} + +func (bar StatusBar) View() string { + left := bar.renderConnectionStatus() + middle := bar.renderCounts() + right := bar.renderSelected() + + content := left + middle + right + style := statusBarStyle.Width(bar.width) + return style.Render(content) } -// View renders the status bar. -func (s StatusBar) View() string { +func (bar StatusBar) renderConnectionStatus() string { var left string - if s.connected { + if bar.connected { left = statusConnectedStyle.Render("● Connected") } else { left = statusDisconnectedStyle.Render("○ Disconnected") } - if s.errorMessage != "" { - left += " " + statusErrorStyle.Render(s.errorMessage) + if bar.errorMessage != "" { + left += " " + statusErrorStyle.Render(bar.errorMessage) } + return left +} +func (bar StatusBar) renderCounts() string { middle := "" - if s.threadCount > 0 { - middle = fmt.Sprintf(" | %d threads", s.threadCount) + if bar.threadCount > 0 { + middle = fmt.Sprintf(" | %d threads", bar.threadCount) } - - right := "" - if s.selectedThread != "" { - right = fmt.Sprintf(" | %s", s.selectedThread) + if bar.agentCount > 0 { + middle += fmt.Sprintf(" | %d agents", bar.agentCount) } + return middle +} - content := left + middle + right - style := statusBarStyle.Width(s.width) - return style.Render(content) +func (bar StatusBar) renderSelected() string { + if bar.selectedThread != "" { + return fmt.Sprintf(" | %s", bar.selectedThread) + } + return "" } diff --git a/internal/tui/statusbar_test.go b/internal/tui/statusbar_test.go index 3016d51..54c9272 100644 --- a/internal/tui/statusbar_test.go +++ b/internal/tui/statusbar_test.go @@ -5,43 +5,62 @@ import ( "testing" ) -func TestStatusBarConnected(t *testing.T) { +const ( + statusBarTestWidth = 80 + statusBarTestThreads = 3 + statusBarTestAgents = 3 + statusBarTestCompleted = 1 + statusBarTestSelected = "Build web app" + statusBarTestError = "connection lost" +) + +func TestStatusBarConnected(testing *testing.T) { bar := NewStatusBar() bar.SetConnected(true) - bar.SetThreadCount(3) - bar.SetSelectedThread("Build web app") + bar.SetThreadCount(statusBarTestThreads) + bar.SetSelectedThread(statusBarTestSelected) output := bar.View() if !strings.Contains(output, "Connected") { - t.Errorf("expected Connected in output:\n%s", output) + testing.Errorf("expected Connected in output:\n%s", output) } if !strings.Contains(output, "3 threads") { - t.Errorf("expected thread count in output:\n%s", output) + testing.Errorf("expected thread count in output:\n%s", output) } - if !strings.Contains(output, "Build web app") { - t.Errorf("expected selected thread in output:\n%s", output) + if !strings.Contains(output, statusBarTestSelected) { + testing.Errorf("expected selected thread in output:\n%s", output) } } -func TestStatusBarDisconnected(t *testing.T) { +func TestStatusBarDisconnected(testing *testing.T) { bar := NewStatusBar() bar.SetConnected(false) output := bar.View() if !strings.Contains(output, "Disconnected") { - t.Errorf("expected Disconnected in output:\n%s", output) + testing.Errorf("expected Disconnected in output:\n%s", output) } } -func TestStatusBarError(t *testing.T) { +func TestStatusBarError(testing *testing.T) { bar := NewStatusBar() - bar.SetError("connection lost") + bar.SetError(statusBarTestError) output := bar.View() - if !strings.Contains(output, "connection lost") { - t.Errorf("expected error in output:\n%s", output) + if !strings.Contains(output, statusBarTestError) { + testing.Errorf("expected error in output:\n%s", output) + } +} + +func TestStatusBarAgentCount(testing *testing.T) { + bar := NewStatusBar() + bar.SetWidth(statusBarTestWidth) + bar.SetAgentCount(statusBarTestAgents, statusBarTestCompleted) + view := bar.View() + if !strings.Contains(view, "3 agents") { + testing.Error("expected agent count in status bar") } } From c5b33f2a1ff531102193f02fecc267c0970b77ef Mon Sep 17 00:00:00 2001 From: Robin White Date: Thu, 19 Mar 2026 10:59:59 -0400 Subject: [PATCH 17/41] feat(tui): add p/m/s/K keybindings for swarm control --- internal/tui/app_keys.go | 10 +++++++++- internal/tui/app_swarm.go | 32 ++++++++++++++++++++++++++++++++ internal/tui/header.go | 2 +- internal/tui/help.go | 4 ++++ 4 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 internal/tui/app_swarm.go diff --git a/internal/tui/app_keys.go b/internal/tui/app_keys.go index fc2ba5c..0de3ebf 100644 --- a/internal/tui/app_keys.go +++ b/internal/tui/app_keys.go @@ -63,10 +63,18 @@ func (app AppModel) handleRune(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return app, app.createThread() case "?": app.helpVisible = !app.helpVisible - case " ", "s": + case " ": return app.togglePin() case "k": return app.killSession() + case "p": + return app.showPersonaPicker() + case "m": + return app.sendMessageToAgent() + case "s": + return app.toggleSwarmView() + case "K": + return app.killAgent() } return app, nil } diff --git a/internal/tui/app_swarm.go b/internal/tui/app_swarm.go new file mode 100644 index 0000000..b4b8cee --- /dev/null +++ b/internal/tui/app_swarm.go @@ -0,0 +1,32 @@ +package tui + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/robinojw/dj/internal/state" +) + +func (app AppModel) showPersonaPicker() (tea.Model, tea.Cmd) { + return app, nil +} + +func (app AppModel) sendMessageToAgent() (tea.Model, tea.Cmd) { + return app, nil +} + +func (app AppModel) killAgent() (tea.Model, tea.Cmd) { + if app.pool == nil { + return app, nil + } + threadID := app.canvas.SelectedThreadID() + agent, exists := app.pool.GetByThreadID(threadID) + if !exists { + return app, nil + } + app.pool.StopAgent(agent.ID) + app.store.UpdateStatus(threadID, state.StatusCompleted, "") + return app, nil +} + +func (app AppModel) toggleSwarmView() (tea.Model, tea.Cmd) { + return app, nil +} diff --git a/internal/tui/header.go b/internal/tui/header.go index 714802a..236e74f 100644 --- a/internal/tui/header.go +++ b/internal/tui/header.go @@ -14,7 +14,7 @@ const headerTitle = "DJ — Codex TUI Visualizer" var headerHints = []string{ "n: new", - "s: select", + "Space: pin", "Enter: open", "?: help", "t: tree", diff --git a/internal/tui/help.go b/internal/tui/help.go index 3b89440..0bd41f4 100644 --- a/internal/tui/help.go +++ b/internal/tui/help.go @@ -52,6 +52,10 @@ var keybindings = []keybinding{ {"Ctrl+B x", "Unpin focused session"}, {"Ctrl+B z", "Toggle zoom session"}, {"Mouse Wheel", "Scroll session up/down"}, + {"p", "Spawn persona agent"}, + {"m", "Message selected agent"}, + {"s", "Toggle swarm view"}, + {"K", "Kill selected agent"}, {"?", "Toggle help"}, {"Ctrl+C", "Quit"}, } From 42cdb91501d828371b45bfd8dc41045beaefa410 Mon Sep 17 00:00:00 2001 From: Robin White Date: Thu, 19 Mar 2026 11:02:56 -0400 Subject: [PATCH 18/41] feat(main): integrate roster loading and agent pool into startup --- cmd/dj/main.go | 50 ++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 42 insertions(+), 8 deletions(-) diff --git a/cmd/dj/main.go b/cmd/dj/main.go index 89d58e7..9640c7c 100644 --- a/cmd/dj/main.go +++ b/cmd/dj/main.go @@ -3,12 +3,15 @@ package main import ( "fmt" "os" + "path/filepath" tea "github.com/charmbracelet/bubbletea" "github.com/spf13/cobra" "github.com/robinojw/dj/internal/appserver" "github.com/robinojw/dj/internal/config" + "github.com/robinojw/dj/internal/pool" + "github.com/robinojw/dj/internal/roster" "github.com/robinojw/dj/internal/state" "github.com/robinojw/dj/internal/tui" ) @@ -38,15 +41,30 @@ func runApp(cmd *cobra.Command, args []string) error { return fmt.Errorf("load config: %w", err) } - client := appserver.NewClient(cfg.AppServer.Command, cfg.AppServer.Args...) - defer client.Stop() - store := state.NewThreadStore() - app := tui.NewAppModel( - store, - tui.WithClient(client), - tui.WithInteractiveCommand(cfg.Interactive.Command, cfg.Interactive.Args...), - ) + var opts []tui.AppOption + + personas, signals := loadRoster(cfg) + hasPersonas := len(personas) > 0 + shouldUsePool := hasPersonas && cfg.Roster.AutoOrchestrate + + if shouldUsePool { + agentPool := pool.NewAgentPool( + cfg.AppServer.Command, + cfg.AppServer.Args, + personas, + cfg.Pool.MaxAgents, + ) + opts = append(opts, tui.WithPool(agentPool)) + _ = signals + } else { + client := appserver.NewClient(cfg.AppServer.Command, cfg.AppServer.Args...) + defer client.Stop() + opts = append(opts, tui.WithClient(client)) + } + + opts = append(opts, tui.WithInteractiveCommand(cfg.Interactive.Command, cfg.Interactive.Args...)) + app := tui.NewAppModel(store, opts...) program := tea.NewProgram(app, tea.WithAltScreen(), tea.WithMouseCellMotion()) finalModel, err := program.Run() @@ -57,3 +75,19 @@ func runApp(cmd *cobra.Command, args []string) error { return err } + +func loadRoster(cfg *config.Config) ([]roster.PersonaDefinition, *roster.RepoSignals) { + personaDir := filepath.Join(cfg.Roster.Path, "personas") + personas, err := roster.LoadPersonas(personaDir) + if err != nil { + return nil, nil + } + + signalsPath := filepath.Join(cfg.Roster.Path, "signals.json") + signals, err := roster.LoadSignals(signalsPath) + if err != nil { + return personas, nil + } + + return personas, signals +} From 1da5394c5877cb371450affc1d5b08ce9fedf635 Mon Sep 17 00:00:00 2001 From: Robin White Date: Thu, 19 Mar 2026 11:04:13 -0400 Subject: [PATCH 19/41] feat(tui): pool event multiplexing with command parsing --- internal/tui/app.go | 8 +++ internal/tui/app_pool_events.go | 101 +++++++++++++++++++++++++++ internal/tui/app_pool_events_test.go | 31 ++++++++ 3 files changed, 140 insertions(+) create mode 100644 internal/tui/app_pool_events.go create mode 100644 internal/tui/app_pool_events_test.go diff --git a/internal/tui/app.go b/internal/tui/app.go index 38a355f..939a3ad 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -95,6 +95,12 @@ func (app AppModel) HelpVisible() bool { } func (app AppModel) Init() tea.Cmd { + if app.pool != nil { + return tea.Batch( + app.listenForPoolEvents(), + app.listenForPTYEvents(), + ) + } if app.client == nil { return nil } @@ -122,6 +128,8 @@ func (app AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return app, nil case ThreadCreatedMsg: return app.handleThreadCreated(msg) + case PoolEventMsg: + return app.handlePoolEvent(msg) default: return app.handleAgentMsg(msg) } diff --git a/internal/tui/app_pool_events.go b/internal/tui/app_pool_events.go new file mode 100644 index 0000000..84b8c4b --- /dev/null +++ b/internal/tui/app_pool_events.go @@ -0,0 +1,101 @@ +package tui + +import ( + "fmt" + + tea "github.com/charmbracelet/bubbletea" + "github.com/robinojw/dj/internal/orchestrator" +) + +func (app AppModel) listenForPoolEvents() tea.Cmd { + if app.pool == nil { + return nil + } + return func() tea.Msg { + event, ok := <-app.pool.Events() + if !ok { + return AppServerErrorMsg{Err: fmt.Errorf("pool events closed")} + } + return PoolEventMsg{ + AgentID: event.AgentID, + Message: event.Message, + } + } +} + +func (app AppModel) handlePoolEvent(msg PoolEventMsg) (tea.Model, tea.Cmd) { + agent, exists := app.pool.Get(msg.AgentID) + if !exists { + return app, app.listenForPoolEvents() + } + + tuiMsg := V2MessageToMsg(msg.Message) + if tuiMsg == nil { + return app, app.listenForPoolEvents() + } + + deltaMsg, isDelta := tuiMsg.(V2AgentDeltaMsg) + if !isDelta { + updated, innerCmd := app.Update(tuiMsg) + resultApp := updated.(AppModel) + return resultApp, tea.Batch(innerCmd, resultApp.listenForPoolEvents()) + } + + agent.Parser.Feed(deltaMsg.Delta) + commands := agent.Parser.Flush() + return app.processCommands(msg.AgentID, commands, tuiMsg) +} + +func (app AppModel) processCommands(agentID string, commands []orchestrator.DJCommand, originalMsg tea.Msg) (tea.Model, tea.Cmd) { + updated, innerCmd := app.Update(originalMsg) + resultApp := updated.(AppModel) + cmds := []tea.Cmd{innerCmd} + + for _, command := range commands { + resultApp, cmds = applyCommand(resultApp, agentID, command, cmds) + } + + cmds = append(cmds, resultApp.listenForPoolEvents()) + return resultApp, tea.Batch(cmds...) +} + +func applyCommand(app AppModel, agentID string, command orchestrator.DJCommand, cmds []tea.Cmd) (AppModel, []tea.Cmd) { + switch command.Action { + case "spawn": + return applySpawnCommand(app, agentID, command, cmds) + case "message": + return applyMessageCommand(app, agentID, command, cmds) + case "complete": + return applyCompleteCommand(app, agentID, command, cmds) + } + return app, cmds +} + +func applySpawnCommand(app AppModel, agentID string, command orchestrator.DJCommand, cmds []tea.Cmd) (AppModel, []tea.Cmd) { + spawnMsg := SpawnRequestMsg{ + SourceAgentID: agentID, + Persona: command.Persona, + Task: command.Task, + } + spawnUpdated, spawnCmd := app.handleSpawnRequest(spawnMsg) + return spawnUpdated.(AppModel), append(cmds, spawnCmd) +} + +func applyMessageCommand(app AppModel, agentID string, command orchestrator.DJCommand, cmds []tea.Cmd) (AppModel, []tea.Cmd) { + agentMsg := AgentMessageMsg{ + SourceAgentID: agentID, + TargetAgentID: command.Target, + Content: command.Content, + } + msgUpdated, msgCmd := app.handleAgentMessage(agentMsg) + return msgUpdated.(AppModel), append(cmds, msgCmd) +} + +func applyCompleteCommand(app AppModel, agentID string, command orchestrator.DJCommand, cmds []tea.Cmd) (AppModel, []tea.Cmd) { + completeMsg := AgentCompleteMsg{ + AgentID: agentID, + Content: command.Content, + } + completeUpdated, completeCmd := app.handleAgentComplete(completeMsg) + return completeUpdated.(AppModel), append(cmds, completeCmd) +} diff --git a/internal/tui/app_pool_events_test.go b/internal/tui/app_pool_events_test.go new file mode 100644 index 0000000..6c5d56c --- /dev/null +++ b/internal/tui/app_pool_events_test.go @@ -0,0 +1,31 @@ +package tui + +import ( + "testing" + + poolpkg "github.com/robinojw/dj/internal/pool" + "github.com/robinojw/dj/internal/state" +) + +const testPoolMaxAgents = 10 + +func TestListenForPoolEvents(testing *testing.T) { + store := state.NewThreadStore() + agentPool := poolpkg.NewAgentPool("echo", []string{}, nil, testPoolMaxAgents) + app := NewAppModel(store, WithPool(agentPool)) + + cmd := app.listenForPoolEvents() + if cmd == nil { + testing.Error("expected non-nil command") + } +} + +func TestListenForPoolEventsNilPool(testing *testing.T) { + store := state.NewThreadStore() + app := NewAppModel(store) + + cmd := app.listenForPoolEvents() + if cmd != nil { + testing.Error("expected nil command when pool is nil") + } +} From 9a854c32777a5394a591a8c7d355e811ef960142 Mon Sep 17 00:00:00 2001 From: Robin White Date: Thu, 19 Mar 2026 12:01:54 -0400 Subject: [PATCH 20/41] docs: add swarm interaction UI design --- .../2026-03-19-swarm-interaction-design.md | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 docs/plans/2026-03-19-swarm-interaction-design.md diff --git a/docs/plans/2026-03-19-swarm-interaction-design.md b/docs/plans/2026-03-19-swarm-interaction-design.md new file mode 100644 index 0000000..900a284 --- /dev/null +++ b/docs/plans/2026-03-19-swarm-interaction-design.md @@ -0,0 +1,88 @@ +# Swarm Interaction UI Design + +## Goal + +Wire up the stub keybindings (p, m, s, K) so users can spawn persona agents, message them, filter the canvas to swarm-only view, and see swarm hints in the header bar. + +## Architecture + +Reuse the existing `MenuModel` for persona and agent pickers. Add a new lightweight `InputBar` component for task/message text entry. The flow is two-step: pick from a list, then type in the input bar. No new dependencies. + +## Components + +### InputBarModel + +A single-line text input that renders in place of the status bar. + +- `prompt string` — label like "Task for Architect: " +- `value string` — user-typed text +- `View()` renders prompt + value + cursor +- No `Update()` — the app feeds keystrokes directly + +### New AppModel Fields + +- `inputBar InputBarModel` +- `inputBarVisible bool` +- `inputBarIntent InputIntent` — enum: `IntentSpawnTask`, `IntentSendMessage` +- `menuIntent MenuIntent` — enum: `MenuIntentThread`, `MenuIntentPersonaPicker`, `MenuIntentAgentPicker` +- `pendingPersonaID string` +- `pendingTargetAgentID string` +- `swarmFilter bool` + +## Interaction Flows + +### Spawn Agent (p) + +1. Press `p` → `showPersonaPicker()` builds MenuModel from `pool.Personas()` +2. Sets `menuVisible = true`, `menuIntent = MenuIntentPersonaPicker` +3. Arrow/Enter/Esc handled by existing `handleMenuKey` +4. Enter → `dispatchMenuAction` routes via `menuIntent` → stores `pendingPersonaID`, shows input bar with "Task for : " +5. Enter on input bar → `pool.Spawn(pendingPersonaID, value, "")`, creates thread in store, clears state + +### Message Agent (m) + +1. Press `m` → `sendMessageToAgent()` builds MenuModel from `pool.All()` +2. Sets `menuVisible = true`, `menuIntent = MenuIntentAgentPicker` +3. Enter → stores `pendingTargetAgentID`, shows input bar with "Message to : " +4. Enter on input bar → `client.SendUserInput(message)` on target agent, clears state + +### Swarm Filter (s) + +- Toggles `swarmFilter` bool +- Canvas skips threads where `AgentProcessID == ""` when filter is active +- Selection index clamps to filtered set + +### Header Wiring + +- In `NewAppModel`, after applying opts: if `pool != nil` → `header.SetSwarmActive(true)` + +## Key Handling Priority + +``` +handleKey: + 1. helpVisible → handleHelpKey + 2. inputBarVisible → handleInputBarKey (NEW) + 3. menuVisible → handleMenuKey + 4. prefix → handlePrefix + 5. session focus → handleSessionKey + 6. canvas → handleCanvasKey +``` + +### handleInputBarKey + +- Printable runes → append to value +- Backspace → delete last char +- Enter → dispatch based on intent, clear state +- Esc → dismiss, clear pending state + +## View Changes + +When `inputBarVisible` is true, the input bar renders in place of the status bar at the bottom of the screen. The canvas remains visible above. + +## Error Handling + +- No pool: `p` and `m` are no-ops +- No personas loaded: status bar error "No personas available" +- No active agents: status bar error "No active agents" +- Spawn at capacity: `pool.Spawn` error → status bar +- Empty input on Enter: dismiss without action From 048b99a2a64a646de345c9bdbed781558203f3f4 Mon Sep 17 00:00:00 2001 From: Robin White Date: Thu, 19 Mar 2026 12:04:41 -0400 Subject: [PATCH 21/41] docs: add swarm interaction UI implementation plan --- .../2026-03-19-swarm-interaction-plan.md | 1292 +++++++++++++++++ 1 file changed, 1292 insertions(+) create mode 100644 docs/plans/2026-03-19-swarm-interaction-plan.md diff --git a/docs/plans/2026-03-19-swarm-interaction-plan.md b/docs/plans/2026-03-19-swarm-interaction-plan.md new file mode 100644 index 0000000..4afcf0a --- /dev/null +++ b/docs/plans/2026-03-19-swarm-interaction-plan.md @@ -0,0 +1,1292 @@ +# Swarm Interaction UI Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Wire up swarm keybindings (p, m, s) with persona picker, text input bar, and canvas filtering so users can spawn agents, message them, and view the swarm. + +**Architecture:** Reuse the existing `MenuModel` for persona/agent pickers with a new `menuIntent` field to distinguish picker types from the thread context menu. Add a lightweight `InputBarModel` for single-line text entry that renders in place of the status bar. Canvas gains a `swarmFilter` mode that hides non-agent threads. + +**Tech Stack:** Go, Bubble Tea, Lipgloss. No new dependencies. + +--- + +### Task 1: InputBarModel — Component + +**Files:** +- Create: `internal/tui/inputbar.go` +- Test: `internal/tui/inputbar_test.go` + +**Step 1: Write the failing test** + +Create `internal/tui/inputbar_test.go`: + +```go +package tui + +import ( + "strings" + "testing" +) + +const ( + testInputPrompt = "Task: " + testInputValue = "Design the API" +) + +func TestInputBarView(testing *testing.T) { + bar := NewInputBarModel(testInputPrompt) + bar.InsertRune('H') + bar.InsertRune('i') + view := bar.View() + if !strings.Contains(view, testInputPrompt) { + testing.Error("expected prompt in view") + } + if !strings.Contains(view, "Hi") { + testing.Error("expected typed value in view") + } +} + +func TestInputBarDeleteRune(testing *testing.T) { + bar := NewInputBarModel(testInputPrompt) + bar.InsertRune('A') + bar.InsertRune('B') + bar.DeleteRune() + value := bar.Value() + if value != "A" { + testing.Errorf("expected 'A', got %q", value) + } +} + +func TestInputBarDeleteRuneEmpty(testing *testing.T) { + bar := NewInputBarModel(testInputPrompt) + bar.DeleteRune() + value := bar.Value() + if value != "" { + testing.Errorf("expected empty, got %q", value) + } +} + +func TestInputBarValue(testing *testing.T) { + bar := NewInputBarModel(testInputPrompt) + bar.InsertRune('G') + bar.InsertRune('o') + value := bar.Value() + if value != "Go" { + testing.Errorf("expected 'Go', got %q", value) + } +} + +func TestInputBarReset(testing *testing.T) { + bar := NewInputBarModel(testInputPrompt) + bar.InsertRune('X') + bar.Reset() + value := bar.Value() + if value != "" { + testing.Errorf("expected empty after reset, got %q", value) + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `go test ./internal/tui/ -run "TestInputBar" -v` +Expected: FAIL — NewInputBarModel not defined + +**Step 3: Write minimal implementation** + +Create `internal/tui/inputbar.go`: + +```go +package tui + +import ( + "strings" + + "github.com/charmbracelet/lipgloss" +) + +const ( + inputBarCursor = "█" + inputBarColorBg = "236" + inputBarColorFg = "252" + inputBarColorAcc = "39" +) + +var ( + inputBarStyle = lipgloss.NewStyle(). + Background(lipgloss.Color(inputBarColorBg)). + Foreground(lipgloss.Color(inputBarColorFg)). + Padding(0, 1) + inputBarPromptStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(inputBarColorAcc)). + Bold(true) +) + +type InputBarModel struct { + prompt string + value strings.Builder +} + +func NewInputBarModel(prompt string) InputBarModel { + return InputBarModel{prompt: prompt} +} + +func (bar *InputBarModel) InsertRune(r rune) { + bar.value.WriteRune(r) +} + +func (bar *InputBarModel) DeleteRune() { + current := bar.value.String() + if len(current) == 0 { + return + } + runes := []rune(current) + bar.value.Reset() + bar.value.WriteString(string(runes[:len(runes)-1])) +} + +func (bar *InputBarModel) Value() string { + return bar.value.String() +} + +func (bar *InputBarModel) Reset() { + bar.value.Reset() +} + +func (bar InputBarModel) View() string { + prompt := inputBarPromptStyle.Render(bar.prompt) + text := bar.value.String() + inputBarCursor + return inputBarStyle.Render(prompt + text) +} + +func (bar InputBarModel) ViewWithWidth(width int) string { + prompt := inputBarPromptStyle.Render(bar.prompt) + text := bar.value.String() + inputBarCursor + style := inputBarStyle.Width(width) + return style.Render(prompt + text) +} +``` + +**Step 4: Run test to verify it passes** + +Run: `go test ./internal/tui/ -run "TestInputBar" -v -race` +Expected: PASS + +**Step 5: Commit** + +```bash +git add internal/tui/inputbar.go internal/tui/inputbar_test.go +git commit -m "feat(tui): add InputBarModel component for text input" +``` + +--- + +### Task 2: Wire Header Swarm Hints + +**Files:** +- Modify: `internal/tui/app.go:43-62` +- Test: `internal/tui/app_test.go` + +**Step 1: Write the failing test** + +Add to `internal/tui/app_test.go`: + +```go +func TestNewAppModelPoolSetsSwarmActive(testing *testing.T) { + store := state.NewThreadStore() + agentPool := pool.NewAgentPool("codex", []string{"proto"}, nil, 10) + app := NewAppModel(store, WithPool(agentPool)) + view := app.header.View() + if !strings.Contains(view, "p: persona") { + testing.Error("expected swarm hints in header when pool is set") + } +} +``` + +Add `"strings"` to imports if not present. The constant `10` should use the existing test constant (add `testPoolMaxAgentsApp = 10` if needed, or reuse from existing tests). + +**Step 2: Run test to verify it fails** + +Run: `go test ./internal/tui/ -run TestNewAppModelPoolSetsSwarmActive -v` +Expected: FAIL — header does not contain swarm hints + +**Step 3: Wire pool presence to header** + +In `internal/tui/app.go`, in `NewAppModel`, after the `for _, opt := range opts` loop, add: + +```go + hasPool := app.pool != nil + if hasPool { + app.header.SetSwarmActive(true) + } +``` + +**Step 4: Run test to verify it passes** + +Run: `go test ./internal/tui/ -run TestNewAppModelPoolSetsSwarmActive -v -race` +Expected: PASS + +**Step 5: Commit** + +```bash +git add internal/tui/app.go internal/tui/app_test.go +git commit -m "feat(tui): wire header swarm hints when pool is present" +``` + +--- + +### Task 3: Menu Intent and Swarm Fields on AppModel + +**Files:** +- Modify: `internal/tui/app.go:10-41` +- Modify: `internal/tui/msgs.go` +- Test: `internal/tui/app_test.go` + +**Step 1: Write the failing test** + +Add to `internal/tui/app_test.go`: + +```go +func TestAppModelSwarmFieldsDefault(testing *testing.T) { + store := state.NewThreadStore() + app := NewAppModel(store) + if app.menuIntent != MenuIntentThread { + testing.Error("expected default menu intent to be thread") + } + if app.inputBarVisible { + testing.Error("expected input bar hidden by default") + } + if app.swarmFilter { + testing.Error("expected swarm filter off by default") + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `go test ./internal/tui/ -run TestAppModelSwarmFieldsDefault -v` +Expected: FAIL — menuIntent, inputBarVisible, swarmFilter not defined + +**Step 3: Add new types and fields** + +In `internal/tui/msgs.go`, add the intent enums: + +```go +type MenuIntent int + +const ( + MenuIntentThread MenuIntent = iota + MenuIntentPersonaPicker + MenuIntentAgentPicker +) + +type InputIntent int + +const ( + IntentSpawnTask InputIntent = iota + IntentSendMessage +) +``` + +In `internal/tui/app.go`, add fields to `AppModel`: + +```go + inputBar InputBarModel + inputBarVisible bool + inputBarIntent InputIntent + menuIntent MenuIntent + pendingPersonaID string + pendingTargetAgentID string + swarmFilter bool +``` + +**Step 4: Run test to verify it passes** + +Run: `go test ./internal/tui/ -run TestAppModelSwarmFieldsDefault -v -race` +Expected: PASS + +**Step 5: Commit** + +```bash +git add internal/tui/app.go internal/tui/msgs.go internal/tui/app_test.go +git commit -m "feat(tui): add menu intent, input intent, and swarm fields to AppModel" +``` + +--- + +### Task 4: showPersonaPicker Implementation + +**Files:** +- Modify: `internal/tui/app_swarm.go:8-10` +- Test: `internal/tui/app_swarm_test.go` + +**Step 1: Write the failing test** + +Create `internal/tui/app_swarm_test.go`: + +```go +package tui + +import ( + "testing" + + "github.com/robinojw/dj/internal/pool" + "github.com/robinojw/dj/internal/roster" + "github.com/robinojw/dj/internal/state" +) + +const testSwarmMaxAgents = 10 + +func TestShowPersonaPickerShowsMenu(testing *testing.T) { + store := state.NewThreadStore() + personas := []roster.PersonaDefinition{ + {ID: "architect", Name: "Architect"}, + {ID: "test", Name: "Test"}, + } + agentPool := pool.NewAgentPool("echo", []string{}, personas, testSwarmMaxAgents) + app := NewAppModel(store, WithPool(agentPool)) + + updated, _ := app.showPersonaPicker() + resultApp := updated.(AppModel) + + if !resultApp.menuVisible { + testing.Error("expected menu to be visible after showPersonaPicker") + } + if resultApp.menuIntent != MenuIntentPersonaPicker { + testing.Error("expected menu intent to be persona picker") + } +} + +func TestShowPersonaPickerNoPool(testing *testing.T) { + store := state.NewThreadStore() + app := NewAppModel(store) + + updated, _ := app.showPersonaPicker() + resultApp := updated.(AppModel) + + if resultApp.menuVisible { + testing.Error("expected menu hidden when no pool") + } +} + +func TestShowPersonaPickerNoPersonas(testing *testing.T) { + store := state.NewThreadStore() + agentPool := pool.NewAgentPool("echo", []string{}, nil, testSwarmMaxAgents) + app := NewAppModel(store, WithPool(agentPool)) + + updated, _ := app.showPersonaPicker() + resultApp := updated.(AppModel) + + if resultApp.menuVisible { + testing.Error("expected menu hidden when no personas") + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `go test ./internal/tui/ -run "TestShowPersonaPicker" -v` +Expected: FAIL — showPersonaPicker is a no-op stub + +**Step 3: Implement showPersonaPicker** + +Replace the stub in `internal/tui/app_swarm.go`: + +```go +func (app AppModel) showPersonaPicker() (tea.Model, tea.Cmd) { + if app.pool == nil { + return app, nil + } + + personas := app.pool.Personas() + if len(personas) == 0 { + app.statusBar.SetError("No personas available") + return app, nil + } + + items := buildPersonaMenuItems(personas) + app.menu = NewMenuModel("Spawn Persona Agent", items) + app.menuVisible = true + app.menuIntent = MenuIntentPersonaPicker + return app, nil +} + +func buildPersonaMenuItems(personas map[string]roster.PersonaDefinition) []MenuItem { + var items []MenuItem + for _, persona := range personas { + items = append(items, MenuItem{ + Label: persona.Name, + Key: rune(persona.ID[0]), + }) + } + return items +} +``` + +Add import for `"github.com/robinojw/dj/internal/roster"`. + +Note: The `MenuItem.Key` is the first rune of the persona ID — it's used for display only in the menu, not for selection logic. + +**Step 4: Run test to verify it passes** + +Run: `go test ./internal/tui/ -run "TestShowPersonaPicker" -v -race` +Expected: PASS + +**Step 5: Commit** + +```bash +git add internal/tui/app_swarm.go internal/tui/app_swarm_test.go +git commit -m "feat(tui): implement showPersonaPicker with persona menu" +``` + +--- + +### Task 5: handleInputBarKey and Key Priority + +**Files:** +- Modify: `internal/tui/app_keys.go:9-27` +- Test: `internal/tui/app_keys_test.go` (or new `internal/tui/app_inputbar_test.go`) + +**Step 1: Write the failing test** + +Create `internal/tui/app_inputbar_test.go`: + +```go +package tui + +import ( + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/robinojw/dj/internal/state" +) + +func TestHandleInputBarKeyTyping(testing *testing.T) { + store := state.NewThreadStore() + app := NewAppModel(store) + app.inputBarVisible = true + app.inputBar = NewInputBarModel("Task: ") + + msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'H'}} + updated, _ := app.Update(msg) + resultApp := updated.(AppModel) + + value := resultApp.inputBar.Value() + if value != "H" { + testing.Errorf("expected 'H', got %q", value) + } +} + +func TestHandleInputBarKeyEscDismisses(testing *testing.T) { + store := state.NewThreadStore() + app := NewAppModel(store) + app.inputBarVisible = true + app.inputBar = NewInputBarModel("Task: ") + + msg := tea.KeyMsg{Type: tea.KeyEsc} + updated, _ := app.Update(msg) + resultApp := updated.(AppModel) + + if resultApp.inputBarVisible { + testing.Error("expected input bar dismissed on Esc") + } +} + +func TestHandleInputBarKeyBackspace(testing *testing.T) { + store := state.NewThreadStore() + app := NewAppModel(store) + app.inputBarVisible = true + app.inputBar = NewInputBarModel("Task: ") + app.inputBar.InsertRune('A') + app.inputBar.InsertRune('B') + + msg := tea.KeyMsg{Type: tea.KeyBackspace} + updated, _ := app.Update(msg) + resultApp := updated.(AppModel) + + value := resultApp.inputBar.Value() + if value != "A" { + testing.Errorf("expected 'A', got %q", value) + } +} + +func TestHandleInputBarKeyEnterEmptyDismisses(testing *testing.T) { + store := state.NewThreadStore() + app := NewAppModel(store) + app.inputBarVisible = true + app.inputBar = NewInputBarModel("Task: ") + + msg := tea.KeyMsg{Type: tea.KeyEnter} + updated, _ := app.Update(msg) + resultApp := updated.(AppModel) + + if resultApp.inputBarVisible { + testing.Error("expected input bar dismissed on empty Enter") + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `go test ./internal/tui/ -run "TestHandleInputBarKey" -v` +Expected: FAIL — input bar keystrokes are not handled (they fall through to canvas) + +**Step 3: Add handleInputBarKey and wire into handleKey** + +In `internal/tui/app_keys.go`, add the input bar check after help: + +```go +func (app AppModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if app.helpVisible { + return app.handleHelpKey(msg) + } + + if app.inputBarVisible { + return app.handleInputBarKey(msg) + } + + if app.menuVisible { + return app.handleMenuKey(msg) + } + + if result, model, cmd := app.handlePrefix(msg); result { + return model, cmd + } + + if app.focusPane == FocusPaneSession { + return app.handleSessionKey(msg) + } + + return app.handleCanvasKey(msg) +} +``` + +Create a new function `handleInputBarKey` — either in `app_keys.go` or a new `app_inputbar.go` file (prefer `app_inputbar.go` to keep files focused): + +Create `internal/tui/app_inputbar.go`: + +```go +package tui + +import tea "github.com/charmbracelet/bubbletea" + +func (app AppModel) handleInputBarKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.Type { + case tea.KeyEsc: + return app.dismissInputBar() + case tea.KeyEnter: + return app.submitInputBar() + case tea.KeyBackspace: + app.inputBar.DeleteRune() + return app, nil + case tea.KeyRunes: + for _, r := range msg.Runes { + app.inputBar.InsertRune(r) + } + return app, nil + } + return app, nil +} + +func (app AppModel) dismissInputBar() (tea.Model, tea.Cmd) { + app.inputBarVisible = false + app.pendingPersonaID = "" + app.pendingTargetAgentID = "" + app.inputBar.Reset() + return app, nil +} + +func (app AppModel) submitInputBar() (tea.Model, tea.Cmd) { + value := app.inputBar.Value() + isEmpty := value == "" + if isEmpty { + return app.dismissInputBar() + } + + switch app.inputBarIntent { + case IntentSpawnTask: + return app.executeSpawn(value) + case IntentSendMessage: + return app.executeSendMessage(value) + } + return app.dismissInputBar() +} + +func (app AppModel) executeSpawn(task string) (tea.Model, tea.Cmd) { + app.inputBarVisible = false + personaID := app.pendingPersonaID + app.pendingPersonaID = "" + app.inputBar.Reset() + + if app.pool == nil { + return app, nil + } + + agentID, err := app.pool.Spawn(personaID, task, "") + if err != nil { + app.statusBar.SetError(err.Error()) + return app, nil + } + + app.store.Add(agentID, task) + app.store.UpdateStatus(agentID, "active", "") + app.statusBar.SetThreadCount(len(app.store.All())) + app.tree.Refresh() + return app, nil +} + +func (app AppModel) executeSendMessage(content string) (tea.Model, tea.Cmd) { + app.inputBarVisible = false + targetID := app.pendingTargetAgentID + app.pendingTargetAgentID = "" + app.inputBar.Reset() + + if app.pool == nil { + return app, nil + } + + targetAgent, exists := app.pool.Get(targetID) + if !exists { + app.statusBar.SetError("Agent not found") + return app, nil + } + + if targetAgent.Client != nil { + targetAgent.Client.SendUserInput(content) + } + return app, nil +} +``` + +**Step 4: Run test to verify it passes** + +Run: `go test ./internal/tui/ -run "TestHandleInputBarKey" -v -race` +Expected: PASS + +**Step 5: Commit** + +```bash +git add internal/tui/app_keys.go internal/tui/app_inputbar.go internal/tui/app_inputbar_test.go +git commit -m "feat(tui): add input bar key handling with spawn and message dispatch" +``` + +--- + +### Task 6: Persona Picker Dispatch → Input Bar + +**Files:** +- Modify: `internal/tui/app_menu.go:84-105` +- Modify: `internal/tui/app_swarm.go` +- Test: `internal/tui/app_swarm_test.go` + +**Step 1: Write the failing test** + +Add to `internal/tui/app_swarm_test.go`: + +```go +func TestPersonaPickerDispatchShowsInputBar(testing *testing.T) { + store := state.NewThreadStore() + personas := []roster.PersonaDefinition{ + {ID: "architect", Name: "Architect"}, + } + agentPool := pool.NewAgentPool("echo", []string{}, personas, testSwarmMaxAgents) + app := NewAppModel(store, WithPool(agentPool)) + + app.showPersonaPicker() + + selected := app.menu.Selected() + app.menuVisible = false + updated, _ := app.dispatchPersonaPick(selected) + resultApp := updated.(AppModel) + + if !resultApp.inputBarVisible { + testing.Error("expected input bar visible after persona pick") + } + if resultApp.inputBarIntent != IntentSpawnTask { + testing.Error("expected spawn task intent") + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `go test ./internal/tui/ -run TestPersonaPickerDispatchShowsInputBar -v` +Expected: FAIL — dispatchPersonaPick not defined + +**Step 3: Implement persona dispatch and route menu intent** + +In `internal/tui/app_swarm.go`, add: + +```go +func (app AppModel) dispatchPersonaPick(item MenuItem) (tea.Model, tea.Cmd) { + persona := app.findPersonaByName(item.Label) + if persona == nil { + return app, nil + } + + app.pendingPersonaID = persona.ID + app.inputBar = NewInputBarModel("Task for " + persona.Name + ": ") + app.inputBarVisible = true + app.inputBarIntent = IntentSpawnTask + return app, nil +} + +func (app AppModel) findPersonaByName(name string) *roster.PersonaDefinition { + if app.pool == nil { + return nil + } + for _, persona := range app.pool.Personas() { + if persona.Name == name { + return &persona + } + } + return nil +} +``` + +In `internal/tui/app_menu.go`, modify `handleMenuKey` Enter case to route via `menuIntent`: + +Replace the Enter case in `handleMenuKey`: + +```go + case tea.KeyEnter: + selected := app.menu.Selected() + intent := app.menuIntent + app.closeMenu() + return app.dispatchMenuByIntent(intent, selected) +``` + +Add the routing function: + +```go +func (app AppModel) dispatchMenuByIntent(intent MenuIntent, item MenuItem) (tea.Model, tea.Cmd) { + switch intent { + case MenuIntentPersonaPicker: + return app.dispatchPersonaPick(item) + case MenuIntentAgentPicker: + return app.dispatchAgentPick(item) + default: + return app.dispatchMenuAction(item) + } +} +``` + +Add a stub for `dispatchAgentPick` in `app_swarm.go`: + +```go +func (app AppModel) dispatchAgentPick(item MenuItem) (tea.Model, tea.Cmd) { + return app, nil +} +``` + +Also reset `menuIntent` in `closeMenu`: + +```go +func (app *AppModel) closeMenu() { + app.menuVisible = false + app.menuIntent = MenuIntentThread +} +``` + +**Step 4: Run test to verify it passes** + +Run: `go test ./internal/tui/ -run "TestPersonaPickerDispatch" -v -race` +Expected: PASS + +**Step 5: Run all tests to check nothing is broken** + +Run: `go test ./internal/tui/ -v -race` +Expected: All PASS + +**Step 6: Commit** + +```bash +git add internal/tui/app_menu.go internal/tui/app_swarm.go internal/tui/app_swarm_test.go +git commit -m "feat(tui): route persona picker to input bar for task entry" +``` + +--- + +### Task 7: sendMessageToAgent + Agent Picker Dispatch + +**Files:** +- Modify: `internal/tui/app_swarm.go` +- Test: `internal/tui/app_swarm_test.go` + +**Step 1: Write the failing test** + +Add to `internal/tui/app_swarm_test.go`: + +```go +func TestSendMessageToAgentShowsMenu(testing *testing.T) { + store := state.NewThreadStore() + personas := []roster.PersonaDefinition{ + {ID: "architect", Name: "Architect"}, + } + agentPool := pool.NewAgentPool("echo", []string{}, personas, testSwarmMaxAgents) + agentPool.Spawn("architect", "Design API", "") + app := NewAppModel(store, WithPool(agentPool)) + + updated, _ := app.sendMessageToAgent() + resultApp := updated.(AppModel) + + if !resultApp.menuVisible { + testing.Error("expected menu visible for agent picker") + } + if resultApp.menuIntent != MenuIntentAgentPicker { + testing.Error("expected agent picker intent") + } +} + +func TestSendMessageToAgentNoAgents(testing *testing.T) { + store := state.NewThreadStore() + agentPool := pool.NewAgentPool("echo", []string{}, nil, testSwarmMaxAgents) + app := NewAppModel(store, WithPool(agentPool)) + + updated, _ := app.sendMessageToAgent() + resultApp := updated.(AppModel) + + if resultApp.menuVisible { + testing.Error("expected menu hidden when no agents") + } +} + +func TestDispatchAgentPickShowsInputBar(testing *testing.T) { + store := state.NewThreadStore() + personas := []roster.PersonaDefinition{ + {ID: "architect", Name: "Architect"}, + } + agentPool := pool.NewAgentPool("echo", []string{}, personas, testSwarmMaxAgents) + agentID, _ := agentPool.Spawn("architect", "Design API", "") + app := NewAppModel(store, WithPool(agentPool)) + + item := MenuItem{Label: agentID, Key: 'a'} + updated, _ := app.dispatchAgentPick(item) + resultApp := updated.(AppModel) + + if !resultApp.inputBarVisible { + testing.Error("expected input bar visible after agent pick") + } + if resultApp.inputBarIntent != IntentSendMessage { + testing.Error("expected send message intent") + } + if resultApp.pendingTargetAgentID != agentID { + testing.Errorf("expected target %s, got %s", agentID, resultApp.pendingTargetAgentID) + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `go test ./internal/tui/ -run "TestSendMessageToAgent|TestDispatchAgentPick" -v` +Expected: FAIL — sendMessageToAgent is a stub, dispatchAgentPick is a stub + +**Step 3: Implement sendMessageToAgent and dispatchAgentPick** + +Replace the stubs in `internal/tui/app_swarm.go`: + +```go +func (app AppModel) sendMessageToAgent() (tea.Model, tea.Cmd) { + if app.pool == nil { + return app, nil + } + + agents := app.pool.All() + if len(agents) == 0 { + app.statusBar.SetError("No active agents") + return app, nil + } + + items := buildAgentMenuItems(agents) + app.menu = NewMenuModel("Message Agent", items) + app.menuVisible = true + app.menuIntent = MenuIntentAgentPicker + return app, nil +} + +func buildAgentMenuItems(agents []*pool.AgentProcess) []MenuItem { + var items []MenuItem + for _, agent := range agents { + label := agent.ID + if agent.Persona != nil { + label = agent.Persona.Name + " (" + agent.ID + ")" + } + items = append(items, MenuItem{ + Label: label, + Key: rune(agent.ID[0]), + }) + } + return items +} + +func (app AppModel) dispatchAgentPick(item MenuItem) (tea.Model, tea.Cmd) { + agentID := extractAgentID(item.Label) + app.pendingTargetAgentID = agentID + app.inputBar = NewInputBarModel("Message to " + item.Label + ": ") + app.inputBarVisible = true + app.inputBarIntent = IntentSendMessage + return app, nil +} +``` + +Add helper to extract agent ID from menu label: + +```go +func extractAgentID(label string) string { + parenStart := strings.LastIndex(label, "(") + parenEnd := strings.LastIndex(label, ")") + hasParen := parenStart != -1 && parenEnd > parenStart + if hasParen { + return label[parenStart+1 : parenEnd] + } + return label +} +``` + +Add imports for `"strings"` and `"github.com/robinojw/dj/internal/pool"`. + +**Step 4: Run test to verify it passes** + +Run: `go test ./internal/tui/ -run "TestSendMessageToAgent|TestDispatchAgentPick" -v -race` +Expected: PASS + +**Step 5: Commit** + +```bash +git add internal/tui/app_swarm.go internal/tui/app_swarm_test.go +git commit -m "feat(tui): implement agent picker and message dispatch" +``` + +--- + +### Task 8: Input Bar in View + +**Files:** +- Modify: `internal/tui/app_view.go:22-47` +- Test: `internal/tui/app_test.go` + +**Step 1: Write the failing test** + +Add to `internal/tui/app_test.go`: + +```go +func TestAppViewShowsInputBar(testing *testing.T) { + store := state.NewThreadStore() + app := NewAppModel(store) + app.inputBarVisible = true + app.inputBar = NewInputBarModel("Task: ") + app.width = 80 + app.height = 24 + + view := app.View() + if !strings.Contains(view, "Task: ") { + testing.Error("expected input bar prompt in view") + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `go test ./internal/tui/ -run TestAppViewShowsInputBar -v` +Expected: FAIL — input bar not rendered in view + +**Step 3: Add input bar rendering to View()** + +In `internal/tui/app_view.go`, in the `View()` method, replace the status bar with the input bar when visible. The simplest approach is: after computing all sections, swap the status line. + +Modify the `View()` function. After `status := app.statusBar.View()`, add: + +```go +func (app AppModel) View() string { + title := app.header.View() + status := app.renderBottomBar() + + if app.helpVisible { + return joinSections(title, app.help.View(), status) + } + // ... rest unchanged +} + +func (app AppModel) renderBottomBar() string { + if app.inputBarVisible { + return app.inputBar.ViewWithWidth(app.width) + } + return app.statusBar.View() +} +``` + +Replace `app.statusBar.View()` with `app.renderBottomBar()` in the existing View method. + +**Step 4: Run test to verify it passes** + +Run: `go test ./internal/tui/ -run TestAppViewShowsInputBar -v -race` +Expected: PASS + +**Step 5: Commit** + +```bash +git add internal/tui/app_view.go internal/tui/app_test.go +git commit -m "feat(tui): render input bar in place of status bar" +``` + +--- + +### Task 9: Canvas Swarm Filter + +**Files:** +- Modify: `internal/tui/canvas.go:16-22,109-120` +- Test: `internal/tui/canvas_test.go` + +**Step 1: Write the failing test** + +Add to `internal/tui/canvas_test.go`: + +```go +func TestCanvasSwarmFilter(testing *testing.T) { + store := state.NewThreadStore() + store.Add("t1", "Regular Thread") + store.Add("t2", "Agent Thread") + thread2, _ := store.Get("t2") + thread2.AgentProcessID = "architect-1" + + canvas := NewCanvasModel(store) + canvas.SetSwarmFilter(true) + + filtered := canvas.filteredThreads() + if len(filtered) != 1 { + testing.Errorf("expected 1 agent thread, got %d", len(filtered)) + } + if filtered[0].ID != "t2" { + testing.Errorf("expected t2, got %s", filtered[0].ID) + } +} + +func TestCanvasSwarmFilterOff(testing *testing.T) { + store := state.NewThreadStore() + store.Add("t1", "Regular Thread") + store.Add("t2", "Agent Thread") + + canvas := NewCanvasModel(store) + canvas.SetSwarmFilter(false) + + filtered := canvas.filteredThreads() + if len(filtered) != 2 { + testing.Errorf("expected 2 threads, got %d", len(filtered)) + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `go test ./internal/tui/ -run "TestCanvasSwarmFilter" -v` +Expected: FAIL — SetSwarmFilter and filteredThreads not defined + +**Step 3: Add swarm filter to CanvasModel** + +In `internal/tui/canvas.go`, add field and methods: + +```go +type CanvasModel struct { + store *state.ThreadStore + selected int + pinnedIDs map[string]bool + width int + height int + swarmFilter bool +} + +func (canvas *CanvasModel) SetSwarmFilter(enabled bool) { + canvas.swarmFilter = enabled +} + +func (canvas *CanvasModel) filteredThreads() []*state.ThreadState { + threads := canvas.store.TreeOrder() + if !canvas.swarmFilter { + return threads + } + + var filtered []*state.ThreadState + for _, thread := range threads { + isAgent := thread.AgentProcessID != "" + if isAgent { + filtered = append(filtered, thread) + } + } + return filtered +} +``` + +Update `View()` and `SelectedThreadID()` to use `filteredThreads()` instead of `canvas.store.TreeOrder()`: + +```go +func (canvas *CanvasModel) View() string { + threads := canvas.filteredThreads() + if len(threads) == 0 { + return canvas.renderEmpty() + } + + grid := canvas.renderGrid(threads) + if canvas.hasDimensions() { + return canvas.centerContent(grid) + } + return grid +} + +func (canvas *CanvasModel) SelectedThreadID() string { + threads := canvas.filteredThreads() + if len(threads) == 0 { + return "" + } + clampedIndex := canvas.selected + if clampedIndex >= len(threads) { + clampedIndex = len(threads) - 1 + } + return threads[clampedIndex].ID +} +``` + +Also update `MoveRight` and `MoveDown` to use `filteredThreads()`: + +```go +func (canvas *CanvasModel) MoveRight() { + threads := canvas.filteredThreads() + if canvas.selected < len(threads)-1 { + canvas.selected++ + } +} + +func (canvas *CanvasModel) MoveDown() { + threads := canvas.filteredThreads() + next := canvas.selected + canvasColumns + if next < len(threads) { + canvas.selected = next + } +} +``` + +**Step 4: Run test to verify it passes** + +Run: `go test ./internal/tui/ -run "TestCanvasSwarmFilter" -v -race` +Expected: PASS + +**Step 5: Run all canvas tests to check nothing is broken** + +Run: `go test ./internal/tui/ -run "TestCanvas" -v -race` +Expected: All PASS + +**Step 6: Commit** + +```bash +git add internal/tui/canvas.go internal/tui/canvas_test.go +git commit -m "feat(tui): add swarm filter to canvas" +``` + +--- + +### Task 10: toggleSwarmView Implementation + +**Files:** +- Modify: `internal/tui/app_swarm.go` +- Test: `internal/tui/app_swarm_test.go` + +**Step 1: Write the failing test** + +Add to `internal/tui/app_swarm_test.go`: + +```go +func TestToggleSwarmViewFiltersCanvas(testing *testing.T) { + store := state.NewThreadStore() + app := NewAppModel(store) + + updated, _ := app.toggleSwarmView() + resultApp := updated.(AppModel) + + if !resultApp.swarmFilter { + testing.Error("expected swarm filter enabled after toggle") + } + + updated2, _ := resultApp.toggleSwarmView() + resultApp2 := updated2.(AppModel) + + if resultApp2.swarmFilter { + testing.Error("expected swarm filter disabled after second toggle") + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `go test ./internal/tui/ -run TestToggleSwarmViewFiltersCanvas -v` +Expected: FAIL — toggleSwarmView is a stub + +**Step 3: Implement toggleSwarmView** + +Replace the stub in `internal/tui/app_swarm.go`: + +```go +func (app AppModel) toggleSwarmView() (tea.Model, tea.Cmd) { + app.swarmFilter = !app.swarmFilter + app.canvas.SetSwarmFilter(app.swarmFilter) + return app, nil +} +``` + +**Step 4: Run test to verify it passes** + +Run: `go test ./internal/tui/ -run TestToggleSwarmViewFiltersCanvas -v -race` +Expected: PASS + +**Step 5: Commit** + +```bash +git add internal/tui/app_swarm.go internal/tui/app_swarm_test.go +git commit -m "feat(tui): implement toggleSwarmView with canvas filtering" +``` + +--- + +### Task 11: Full Build Verification and Lint + +**Step 1: Run full test suite** + +Run: `go test ./... -v -race` +Expected: All PASS + +**Step 2: Run linter** + +Run: `golangci-lint run` +Expected: No errors (fix any funlen/cyclop violations by extracting helpers) + +**Step 3: Run build** + +Run: `go build -o dj ./cmd/dj` +Expected: Build succeeds + +**Step 4: Fix any lint violations** + +If `funlen` or `cyclop` flags functions as too long/complex, extract helper functions to stay within the 60-line / 15-complexity limits. + +**Step 5: Final commit** + +```bash +git add -A +git commit -m "chore: fix lint violations and verify full build" +``` + +--- + +## Summary + +| Task | Package | Description | +|------|---------|-------------| +| 1 | `internal/tui/` | InputBarModel component with insert/delete/reset/view | +| 2 | `internal/tui/` | Wire header swarm hints when pool is present | +| 3 | `internal/tui/` | MenuIntent, InputIntent enums and new AppModel fields | +| 4 | `internal/tui/` | showPersonaPicker builds persona menu from pool | +| 5 | `internal/tui/` | handleInputBarKey + key priority + spawn/message dispatch | +| 6 | `internal/tui/` | Persona picker dispatch → input bar for task entry | +| 7 | `internal/tui/` | sendMessageToAgent + agent picker dispatch | +| 8 | `internal/tui/` | Input bar renders in place of status bar | +| 9 | `internal/tui/` | Canvas swarm filter hides non-agent threads | +| 10 | `internal/tui/` | toggleSwarmView flips canvas filter | +| 11 | — | Full build verification and lint | From 431c29b1be34ca741415e2176d71ba8dbe5cad45 Mon Sep 17 00:00:00 2001 From: Robin White Date: Thu, 19 Mar 2026 12:07:59 -0400 Subject: [PATCH 22/41] feat(tui): add InputBarModel component for text input --- internal/tui/inputbar.go | 68 +++++++++++++++++++++++++++++++++++ internal/tui/inputbar_test.go | 64 +++++++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+) create mode 100644 internal/tui/inputbar.go create mode 100644 internal/tui/inputbar_test.go diff --git a/internal/tui/inputbar.go b/internal/tui/inputbar.go new file mode 100644 index 0000000..462b8a4 --- /dev/null +++ b/internal/tui/inputbar.go @@ -0,0 +1,68 @@ +package tui + +import ( + "strings" + + "github.com/charmbracelet/lipgloss" +) + +const ( + inputBarCursor = "█" + inputBarColorBg = "236" + inputBarColorFg = "252" + inputBarColorAcc = "39" +) + +var ( + inputBarStyle = lipgloss.NewStyle(). + Background(lipgloss.Color(inputBarColorBg)). + Foreground(lipgloss.Color(inputBarColorFg)). + Padding(0, 1) + inputBarPromptStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(inputBarColorAcc)). + Bold(true) +) + +type InputBarModel struct { + prompt string + value strings.Builder +} + +func NewInputBarModel(prompt string) InputBarModel { + return InputBarModel{prompt: prompt} +} + +func (bar *InputBarModel) InsertRune(r rune) { + bar.value.WriteRune(r) +} + +func (bar *InputBarModel) DeleteRune() { + current := bar.value.String() + if len(current) == 0 { + return + } + runes := []rune(current) + bar.value.Reset() + bar.value.WriteString(string(runes[:len(runes)-1])) +} + +func (bar *InputBarModel) Value() string { + return bar.value.String() +} + +func (bar *InputBarModel) Reset() { + bar.value.Reset() +} + +func (bar InputBarModel) View() string { + prompt := inputBarPromptStyle.Render(bar.prompt) + text := bar.value.String() + inputBarCursor + return inputBarStyle.Render(prompt + text) +} + +func (bar InputBarModel) ViewWithWidth(width int) string { + prompt := inputBarPromptStyle.Render(bar.prompt) + text := bar.value.String() + inputBarCursor + style := inputBarStyle.Width(width) + return style.Render(prompt + text) +} diff --git a/internal/tui/inputbar_test.go b/internal/tui/inputbar_test.go new file mode 100644 index 0000000..b1bd352 --- /dev/null +++ b/internal/tui/inputbar_test.go @@ -0,0 +1,64 @@ +package tui + +import ( + "strings" + "testing" +) + +const ( + testInputPrompt = "Task: " + testInputValue = "Design the API" +) + +func TestInputBarView(testing *testing.T) { + bar := NewInputBarModel(testInputPrompt) + bar.InsertRune('H') + bar.InsertRune('i') + view := bar.View() + if !strings.Contains(view, testInputPrompt) { + testing.Error("expected prompt in view") + } + if !strings.Contains(view, "Hi") { + testing.Error("expected typed value in view") + } +} + +func TestInputBarDeleteRune(testing *testing.T) { + bar := NewInputBarModel(testInputPrompt) + bar.InsertRune('A') + bar.InsertRune('B') + bar.DeleteRune() + value := bar.Value() + if value != "A" { + testing.Errorf("expected 'A', got %q", value) + } +} + +func TestInputBarDeleteRuneEmpty(testing *testing.T) { + bar := NewInputBarModel(testInputPrompt) + bar.DeleteRune() + value := bar.Value() + if value != "" { + testing.Errorf("expected empty, got %q", value) + } +} + +func TestInputBarValue(testing *testing.T) { + bar := NewInputBarModel(testInputPrompt) + bar.InsertRune('G') + bar.InsertRune('o') + value := bar.Value() + if value != "Go" { + testing.Errorf("expected 'Go', got %q", value) + } +} + +func TestInputBarReset(testing *testing.T) { + bar := NewInputBarModel(testInputPrompt) + bar.InsertRune('X') + bar.Reset() + value := bar.Value() + if value != "" { + testing.Errorf("expected empty after reset, got %q", value) + } +} From c959ed08180d8c9c37459999e39968521a7a22e8 Mon Sep 17 00:00:00 2001 From: Robin White Date: Thu, 19 Mar 2026 12:09:25 -0400 Subject: [PATCH 23/41] feat(tui): wire header swarm hints when pool is present --- internal/tui/app.go | 4 ++++ internal/tui/app_swarm_test.go | 21 +++++++++++++++++++++ internal/tui/app_test.go | 1 + 3 files changed, 26 insertions(+) create mode 100644 internal/tui/app_swarm_test.go diff --git a/internal/tui/app.go b/internal/tui/app.go index 939a3ad..088e125 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -58,6 +58,10 @@ func NewAppModel(store *state.ThreadStore, opts ...AppOption) AppModel { for _, opt := range opts { opt(&app) } + hasPool := app.pool != nil + if hasPool { + app.header.SetSwarmActive(true) + } return app } diff --git a/internal/tui/app_swarm_test.go b/internal/tui/app_swarm_test.go new file mode 100644 index 0000000..85dfa30 --- /dev/null +++ b/internal/tui/app_swarm_test.go @@ -0,0 +1,21 @@ +package tui + +import ( + "strings" + "testing" + + poolpkg "github.com/robinojw/dj/internal/pool" + "github.com/robinojw/dj/internal/state" +) + +const testSwarmMaxAgents = 10 + +func TestNewAppModelPoolSetsSwarmActive(testing *testing.T) { + store := state.NewThreadStore() + agentPool := poolpkg.NewAgentPool("echo", []string{}, nil, testSwarmMaxAgents) + app := NewAppModel(store, WithPool(agentPool)) + view := app.header.View() + if !strings.Contains(view, "p: persona") { + testing.Error("expected swarm hints in header when pool is set") + } +} diff --git a/internal/tui/app_test.go b/internal/tui/app_test.go index 4aa06af..29ee5b4 100644 --- a/internal/tui/app_test.go +++ b/internal/tui/app_test.go @@ -291,3 +291,4 @@ func TestNewAppModelWithoutPool(testing *testing.T) { testing.Error("expected pool to be nil for backward compatibility") } } + From bfa08d87e64ba067c8d283cd7068e88d0fb52a4e Mon Sep 17 00:00:00 2001 From: Robin White Date: Thu, 19 Mar 2026 12:10:13 -0400 Subject: [PATCH 24/41] feat(tui): add menu intent, input intent, and swarm fields to AppModel --- internal/tui/app.go | 13 ++++++++++--- internal/tui/app_swarm_test.go | 14 ++++++++++++++ internal/tui/msgs.go | 15 +++++++++++++++ 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/internal/tui/app.go b/internal/tui/app.go index 088e125..5eafd09 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -35,9 +35,16 @@ type AppModel struct { sessionCounter *int interactiveCmd string interactiveArgs []string - header HeaderBar - sessionPanel SessionPanelModel - pool *pool.AgentPool + header HeaderBar + sessionPanel SessionPanelModel + pool *pool.AgentPool + inputBar InputBarModel + inputBarVisible bool + inputBarIntent InputIntent + menuIntent MenuIntent + pendingPersonaID string + pendingTargetAgentID string + swarmFilter bool } func NewAppModel(store *state.ThreadStore, opts ...AppOption) AppModel { diff --git a/internal/tui/app_swarm_test.go b/internal/tui/app_swarm_test.go index 85dfa30..92b1e14 100644 --- a/internal/tui/app_swarm_test.go +++ b/internal/tui/app_swarm_test.go @@ -10,6 +10,20 @@ import ( const testSwarmMaxAgents = 10 +func TestAppModelSwarmFieldsDefault(testing *testing.T) { + store := state.NewThreadStore() + app := NewAppModel(store) + if app.menuIntent != MenuIntentThread { + testing.Error("expected default menu intent to be thread") + } + if app.inputBarVisible { + testing.Error("expected input bar hidden by default") + } + if app.swarmFilter { + testing.Error("expected swarm filter off by default") + } +} + func TestNewAppModelPoolSetsSwarmActive(testing *testing.T) { store := state.NewThreadStore() agentPool := poolpkg.NewAgentPool("echo", []string{}, nil, testSwarmMaxAgents) diff --git a/internal/tui/msgs.go b/internal/tui/msgs.go index 5e15901..9f23f73 100644 --- a/internal/tui/msgs.go +++ b/internal/tui/msgs.go @@ -1,5 +1,20 @@ package tui +type MenuIntent int + +const ( + MenuIntentThread MenuIntent = iota + MenuIntentPersonaPicker + MenuIntentAgentPicker +) + +type InputIntent int + +const ( + IntentSpawnTask InputIntent = iota + IntentSendMessage +) + type ThreadCreatedMsg struct { ThreadID string Title string From e53551b0394a246e7e7c36776611988082cd09da Mon Sep 17 00:00:00 2001 From: Robin White Date: Thu, 19 Mar 2026 12:13:37 -0400 Subject: [PATCH 25/41] feat(tui): implement showPersonaPicker with persona menu --- internal/tui/app_swarm.go | 26 +++++++++++++++++ internal/tui/app_swarm_test.go | 53 ++++++++++++++++++++++++++++++++-- 2 files changed, 77 insertions(+), 2 deletions(-) diff --git a/internal/tui/app_swarm.go b/internal/tui/app_swarm.go index b4b8cee..13880d2 100644 --- a/internal/tui/app_swarm.go +++ b/internal/tui/app_swarm.go @@ -2,13 +2,39 @@ package tui import ( tea "github.com/charmbracelet/bubbletea" + "github.com/robinojw/dj/internal/roster" "github.com/robinojw/dj/internal/state" ) func (app AppModel) showPersonaPicker() (tea.Model, tea.Cmd) { + if app.pool == nil { + return app, nil + } + + personas := app.pool.Personas() + if len(personas) == 0 { + app.statusBar.SetError("No personas available") + return app, nil + } + + items := buildPersonaMenuItems(personas) + app.menu = NewMenuModel("Spawn Persona Agent", items) + app.menuVisible = true + app.menuIntent = MenuIntentPersonaPicker return app, nil } +func buildPersonaMenuItems(personas map[string]roster.PersonaDefinition) []MenuItem { + items := make([]MenuItem, 0, len(personas)) + for _, persona := range personas { + items = append(items, MenuItem{ + Label: persona.Name, + Key: rune(persona.ID[0]), + }) + } + return items +} + func (app AppModel) sendMessageToAgent() (tea.Model, tea.Cmd) { return app, nil } diff --git a/internal/tui/app_swarm_test.go b/internal/tui/app_swarm_test.go index 92b1e14..9f50211 100644 --- a/internal/tui/app_swarm_test.go +++ b/internal/tui/app_swarm_test.go @@ -5,10 +5,14 @@ import ( "testing" poolpkg "github.com/robinojw/dj/internal/pool" + "github.com/robinojw/dj/internal/roster" "github.com/robinojw/dj/internal/state" ) -const testSwarmMaxAgents = 10 +const ( + testSwarmMaxAgents = 10 + testSwarmCommand = "echo" +) func TestAppModelSwarmFieldsDefault(testing *testing.T) { store := state.NewThreadStore() @@ -26,10 +30,55 @@ func TestAppModelSwarmFieldsDefault(testing *testing.T) { func TestNewAppModelPoolSetsSwarmActive(testing *testing.T) { store := state.NewThreadStore() - agentPool := poolpkg.NewAgentPool("echo", []string{}, nil, testSwarmMaxAgents) + agentPool := poolpkg.NewAgentPool(testSwarmCommand, []string{}, nil, testSwarmMaxAgents) app := NewAppModel(store, WithPool(agentPool)) view := app.header.View() if !strings.Contains(view, "p: persona") { testing.Error("expected swarm hints in header when pool is set") } } + +func TestShowPersonaPickerShowsMenu(testing *testing.T) { + store := state.NewThreadStore() + personas := []roster.PersonaDefinition{ + {ID: "architect", Name: "Architect"}, + {ID: "test", Name: "Test"}, + } + agentPool := poolpkg.NewAgentPool(testSwarmCommand, []string{}, personas, testSwarmMaxAgents) + app := NewAppModel(store, WithPool(agentPool)) + + updated, _ := app.showPersonaPicker() + resultApp := updated.(AppModel) + + if !resultApp.menuVisible { + testing.Error("expected menu to be visible after showPersonaPicker") + } + if resultApp.menuIntent != MenuIntentPersonaPicker { + testing.Error("expected menu intent to be persona picker") + } +} + +func TestShowPersonaPickerNoPool(testing *testing.T) { + store := state.NewThreadStore() + app := NewAppModel(store) + + updated, _ := app.showPersonaPicker() + resultApp := updated.(AppModel) + + if resultApp.menuVisible { + testing.Error("expected menu hidden when no pool") + } +} + +func TestShowPersonaPickerNoPersonas(testing *testing.T) { + store := state.NewThreadStore() + agentPool := poolpkg.NewAgentPool(testSwarmCommand, []string{}, nil, testSwarmMaxAgents) + app := NewAppModel(store, WithPool(agentPool)) + + updated, _ := app.showPersonaPicker() + resultApp := updated.(AppModel) + + if resultApp.menuVisible { + testing.Error("expected menu hidden when no personas") + } +} From f8d21ab0506d77bf7668166010debe8b3f9dd841 Mon Sep 17 00:00:00 2001 From: Robin White Date: Thu, 19 Mar 2026 12:14:36 -0400 Subject: [PATCH 26/41] feat(tui): add input bar key handling with spawn and message dispatch --- internal/tui/app_inputbar.go | 90 +++++++++++++++++++++++++++++++ internal/tui/app_inputbar_test.go | 74 +++++++++++++++++++++++++ internal/tui/app_keys.go | 4 ++ 3 files changed, 168 insertions(+) create mode 100644 internal/tui/app_inputbar.go create mode 100644 internal/tui/app_inputbar_test.go diff --git a/internal/tui/app_inputbar.go b/internal/tui/app_inputbar.go new file mode 100644 index 0000000..96781e2 --- /dev/null +++ b/internal/tui/app_inputbar.go @@ -0,0 +1,90 @@ +package tui + +import tea "github.com/charmbracelet/bubbletea" + +func (app AppModel) handleInputBarKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.Type { + case tea.KeyEsc: + return app.dismissInputBar() + case tea.KeyEnter: + return app.submitInputBar() + case tea.KeyBackspace: + app.inputBar.DeleteRune() + return app, nil + case tea.KeyRunes: + for _, r := range msg.Runes { + app.inputBar.InsertRune(r) + } + return app, nil + } + return app, nil +} + +func (app AppModel) dismissInputBar() (tea.Model, tea.Cmd) { + app.inputBarVisible = false + app.pendingPersonaID = "" + app.pendingTargetAgentID = "" + app.inputBar.Reset() + return app, nil +} + +func (app AppModel) submitInputBar() (tea.Model, tea.Cmd) { + value := app.inputBar.Value() + isEmpty := value == "" + if isEmpty { + return app.dismissInputBar() + } + + switch app.inputBarIntent { + case IntentSpawnTask: + return app.executeSpawn(value) + case IntentSendMessage: + return app.executeSendMessage(value) + } + return app.dismissInputBar() +} + +func (app AppModel) executeSpawn(task string) (tea.Model, tea.Cmd) { + app.inputBarVisible = false + personaID := app.pendingPersonaID + app.pendingPersonaID = "" + app.inputBar.Reset() + + if app.pool == nil { + return app, nil + } + + agentID, spawnErr := app.pool.Spawn(personaID, task, "") + if spawnErr != nil { + app.statusBar.SetError(spawnErr.Error()) + return app, nil + } + + app.store.Add(agentID, task) + app.store.UpdateStatus(agentID, "active", "") + app.statusBar.SetThreadCount(len(app.store.All())) + app.tree.Refresh() + return app, nil +} + +func (app AppModel) executeSendMessage(content string) (tea.Model, tea.Cmd) { + app.inputBarVisible = false + targetID := app.pendingTargetAgentID + app.pendingTargetAgentID = "" + app.inputBar.Reset() + + if app.pool == nil { + return app, nil + } + + targetAgent, exists := app.pool.Get(targetID) + if !exists { + app.statusBar.SetError("Agent not found") + return app, nil + } + + if targetAgent.Client != nil { + targetAgent.Client.SendUserInput(content) + } + return app, nil +} diff --git a/internal/tui/app_inputbar_test.go b/internal/tui/app_inputbar_test.go new file mode 100644 index 0000000..6de0ef8 --- /dev/null +++ b/internal/tui/app_inputbar_test.go @@ -0,0 +1,74 @@ +package tui + +import ( + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/robinojw/dj/internal/state" +) + +const testInputBarPrompt = "Task: " + +func TestHandleInputBarKeyTyping(testing *testing.T) { + store := state.NewThreadStore() + app := NewAppModel(store) + app.inputBarVisible = true + app.inputBar = NewInputBarModel(testInputBarPrompt) + + msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'H'}} + updated, _ := app.Update(msg) + resultApp := updated.(AppModel) + + value := resultApp.inputBar.Value() + if value != "H" { + testing.Errorf("expected 'H', got %q", value) + } +} + +func TestHandleInputBarKeyEscDismisses(testing *testing.T) { + store := state.NewThreadStore() + app := NewAppModel(store) + app.inputBarVisible = true + app.inputBar = NewInputBarModel(testInputBarPrompt) + + msg := tea.KeyMsg{Type: tea.KeyEsc} + updated, _ := app.Update(msg) + resultApp := updated.(AppModel) + + if resultApp.inputBarVisible { + testing.Error("expected input bar dismissed on Esc") + } +} + +func TestHandleInputBarKeyBackspace(testing *testing.T) { + store := state.NewThreadStore() + app := NewAppModel(store) + app.inputBarVisible = true + app.inputBar = NewInputBarModel(testInputBarPrompt) + app.inputBar.InsertRune('A') + app.inputBar.InsertRune('B') + + msg := tea.KeyMsg{Type: tea.KeyBackspace} + updated, _ := app.Update(msg) + resultApp := updated.(AppModel) + + value := resultApp.inputBar.Value() + if value != "A" { + testing.Errorf("expected 'A', got %q", value) + } +} + +func TestHandleInputBarKeyEnterEmptyDismisses(testing *testing.T) { + store := state.NewThreadStore() + app := NewAppModel(store) + app.inputBarVisible = true + app.inputBar = NewInputBarModel(testInputBarPrompt) + + msg := tea.KeyMsg{Type: tea.KeyEnter} + updated, _ := app.Update(msg) + resultApp := updated.(AppModel) + + if resultApp.inputBarVisible { + testing.Error("expected input bar dismissed on empty Enter") + } +} diff --git a/internal/tui/app_keys.go b/internal/tui/app_keys.go index 0de3ebf..e78b6c8 100644 --- a/internal/tui/app_keys.go +++ b/internal/tui/app_keys.go @@ -11,6 +11,10 @@ func (app AppModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return app.handleHelpKey(msg) } + if app.inputBarVisible { + return app.handleInputBarKey(msg) + } + if app.menuVisible { return app.handleMenuKey(msg) } From 67479c390734a08317e3bf53d61e3a24c9a95f90 Mon Sep 17 00:00:00 2001 From: Robin White Date: Thu, 19 Mar 2026 13:03:29 -0400 Subject: [PATCH 27/41] feat(tui): route persona picker to input bar for task entry --- internal/tui/app_menu.go | 15 ++++++++++++++- internal/tui/app_swarm.go | 29 +++++++++++++++++++++++++++++ internal/tui/app_swarm_test.go | 32 +++++++++++++++++++++++++++++--- 3 files changed, 72 insertions(+), 4 deletions(-) diff --git a/internal/tui/app_menu.go b/internal/tui/app_menu.go index fa7c4b9..1c728f4 100644 --- a/internal/tui/app_menu.go +++ b/internal/tui/app_menu.go @@ -25,8 +25,9 @@ func (app AppModel) handleMenuKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return app, nil case tea.KeyEnter: selected := app.menu.Selected() + intent := app.menuIntent app.closeMenu() - return app.dispatchMenuAction(selected) + return app.dispatchMenuByIntent(intent, selected) } return app, nil } @@ -81,6 +82,17 @@ func (app AppModel) jumpToPane(digit rune) (tea.Model, tea.Cmd) { return app, nil } +func (app AppModel) dispatchMenuByIntent(intent MenuIntent, item MenuItem) (tea.Model, tea.Cmd) { + switch intent { + case MenuIntentPersonaPicker: + return app.dispatchPersonaPick(item) + case MenuIntentAgentPicker: + return app.dispatchAgentPick(item) + default: + return app.dispatchMenuAction(item) + } +} + func (app AppModel) dispatchMenuAction(item MenuItem) (tea.Model, tea.Cmd) { threadID := app.canvas.SelectedThreadID() if threadID == "" { @@ -111,4 +123,5 @@ func (app *AppModel) showMenu() { func (app *AppModel) closeMenu() { app.menuVisible = false + app.menuIntent = MenuIntentThread } diff --git a/internal/tui/app_swarm.go b/internal/tui/app_swarm.go index 13880d2..a12dd7c 100644 --- a/internal/tui/app_swarm.go +++ b/internal/tui/app_swarm.go @@ -35,6 +35,35 @@ func buildPersonaMenuItems(personas map[string]roster.PersonaDefinition) []MenuI return items } +func (app AppModel) dispatchPersonaPick(item MenuItem) (tea.Model, tea.Cmd) { + persona := app.findPersonaByName(item.Label) + if persona == nil { + return app, nil + } + + app.pendingPersonaID = persona.ID + app.inputBar = NewInputBarModel("Task for " + persona.Name + ": ") + app.inputBarVisible = true + app.inputBarIntent = IntentSpawnTask + return app, nil +} + +func (app AppModel) findPersonaByName(name string) *roster.PersonaDefinition { + if app.pool == nil { + return nil + } + for _, persona := range app.pool.Personas() { + if persona.Name == name { + return &persona + } + } + return nil +} + +func (app AppModel) dispatchAgentPick(item MenuItem) (tea.Model, tea.Cmd) { + return app, nil +} + func (app AppModel) sendMessageToAgent() (tea.Model, tea.Cmd) { return app, nil } diff --git a/internal/tui/app_swarm_test.go b/internal/tui/app_swarm_test.go index 9f50211..8db5fb7 100644 --- a/internal/tui/app_swarm_test.go +++ b/internal/tui/app_swarm_test.go @@ -10,8 +10,10 @@ import ( ) const ( - testSwarmMaxAgents = 10 - testSwarmCommand = "echo" + testSwarmMaxAgents = 10 + testSwarmCommand = "echo" + testSwarmPersonaID = "architect" + testSwarmPersonaName = "Architect" ) func TestAppModelSwarmFieldsDefault(testing *testing.T) { @@ -41,7 +43,7 @@ func TestNewAppModelPoolSetsSwarmActive(testing *testing.T) { func TestShowPersonaPickerShowsMenu(testing *testing.T) { store := state.NewThreadStore() personas := []roster.PersonaDefinition{ - {ID: "architect", Name: "Architect"}, + {ID: testSwarmPersonaID, Name: testSwarmPersonaName}, {ID: "test", Name: "Test"}, } agentPool := poolpkg.NewAgentPool(testSwarmCommand, []string{}, personas, testSwarmMaxAgents) @@ -82,3 +84,27 @@ func TestShowPersonaPickerNoPersonas(testing *testing.T) { testing.Error("expected menu hidden when no personas") } } + +func TestPersonaPickerDispatchShowsInputBar(testing *testing.T) { + store := state.NewThreadStore() + personas := []roster.PersonaDefinition{ + {ID: testSwarmPersonaID, Name: testSwarmPersonaName}, + } + agentPool := poolpkg.NewAgentPool(testSwarmCommand, []string{}, personas, testSwarmMaxAgents) + app := NewAppModel(store, WithPool(agentPool)) + + updated, _ := app.showPersonaPicker() + app = updated.(AppModel) + + selected := app.menu.Selected() + app.menuVisible = false + updated, _ = app.dispatchPersonaPick(selected) + resultApp := updated.(AppModel) + + if !resultApp.inputBarVisible { + testing.Error("expected input bar visible after persona pick") + } + if resultApp.inputBarIntent != IntentSpawnTask { + testing.Error("expected spawn task intent") + } +} From cbc6d79d91486ddec12470310ac5aa4c4ec6a56a Mon Sep 17 00:00:00 2001 From: Robin White Date: Thu, 19 Mar 2026 13:05:27 -0400 Subject: [PATCH 28/41] feat(tui): implement agent picker and message dispatch --- internal/tui/app_swarm.go | 51 +++++++++++++++++++++++++++++- internal/tui/app_swarm_test.go | 58 ++++++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 1 deletion(-) diff --git a/internal/tui/app_swarm.go b/internal/tui/app_swarm.go index a12dd7c..4098f8c 100644 --- a/internal/tui/app_swarm.go +++ b/internal/tui/app_swarm.go @@ -1,11 +1,16 @@ package tui import ( + "strings" + tea "github.com/charmbracelet/bubbletea" + "github.com/robinojw/dj/internal/pool" "github.com/robinojw/dj/internal/roster" "github.com/robinojw/dj/internal/state" ) +const inputBarPromptSuffix = ": " + func (app AppModel) showPersonaPicker() (tea.Model, tea.Cmd) { if app.pool == nil { return app, nil @@ -42,7 +47,7 @@ func (app AppModel) dispatchPersonaPick(item MenuItem) (tea.Model, tea.Cmd) { } app.pendingPersonaID = persona.ID - app.inputBar = NewInputBarModel("Task for " + persona.Name + ": ") + app.inputBar = NewInputBarModel("Task for " + persona.Name + inputBarPromptSuffix) app.inputBarVisible = true app.inputBarIntent = IntentSpawnTask return app, nil @@ -61,13 +66,57 @@ func (app AppModel) findPersonaByName(name string) *roster.PersonaDefinition { } func (app AppModel) dispatchAgentPick(item MenuItem) (tea.Model, tea.Cmd) { + agentID := extractAgentID(item.Label) + app.pendingTargetAgentID = agentID + app.inputBar = NewInputBarModel("Message to " + item.Label + inputBarPromptSuffix) + app.inputBarVisible = true + app.inputBarIntent = IntentSendMessage return app, nil } func (app AppModel) sendMessageToAgent() (tea.Model, tea.Cmd) { + if app.pool == nil { + return app, nil + } + + agents := app.pool.All() + if len(agents) == 0 { + app.statusBar.SetError("No active agents") + return app, nil + } + + items := buildAgentMenuItems(agents) + app.menu = NewMenuModel("Message Agent", items) + app.menuVisible = true + app.menuIntent = MenuIntentAgentPicker return app, nil } +func buildAgentMenuItems(agents []*pool.AgentProcess) []MenuItem { + items := make([]MenuItem, 0, len(agents)) + for _, agent := range agents { + label := agent.ID + if agent.Persona != nil { + label = agent.Persona.Name + " (" + agent.ID + ")" + } + items = append(items, MenuItem{ + Label: label, + Key: rune(agent.ID[0]), + }) + } + return items +} + +func extractAgentID(label string) string { + parenStart := strings.LastIndex(label, "(") + parenEnd := strings.LastIndex(label, ")") + hasParen := parenStart != -1 && parenEnd > parenStart + if hasParen { + return label[parenStart+1 : parenEnd] + } + return label +} + func (app AppModel) killAgent() (tea.Model, tea.Cmd) { if app.pool == nil { return app, nil diff --git a/internal/tui/app_swarm_test.go b/internal/tui/app_swarm_test.go index 8db5fb7..cbb25a0 100644 --- a/internal/tui/app_swarm_test.go +++ b/internal/tui/app_swarm_test.go @@ -14,6 +14,7 @@ const ( testSwarmCommand = "echo" testSwarmPersonaID = "architect" testSwarmPersonaName = "Architect" + testSwarmTask = "Design API" ) func TestAppModelSwarmFieldsDefault(testing *testing.T) { @@ -108,3 +109,60 @@ func TestPersonaPickerDispatchShowsInputBar(testing *testing.T) { testing.Error("expected spawn task intent") } } + +func TestSendMessageToAgentShowsMenu(testing *testing.T) { + store := state.NewThreadStore() + personas := []roster.PersonaDefinition{ + {ID: testSwarmPersonaID, Name: testSwarmPersonaName}, + } + agentPool := poolpkg.NewAgentPool(testSwarmCommand, []string{}, personas, testSwarmMaxAgents) + agentPool.Spawn(testSwarmPersonaID, testSwarmTask, "") + app := NewAppModel(store, WithPool(agentPool)) + + updated, _ := app.sendMessageToAgent() + resultApp := updated.(AppModel) + + if !resultApp.menuVisible { + testing.Error("expected menu visible for agent picker") + } + if resultApp.menuIntent != MenuIntentAgentPicker { + testing.Error("expected agent picker intent") + } +} + +func TestSendMessageToAgentNoAgents(testing *testing.T) { + store := state.NewThreadStore() + agentPool := poolpkg.NewAgentPool(testSwarmCommand, []string{}, nil, testSwarmMaxAgents) + app := NewAppModel(store, WithPool(agentPool)) + + updated, _ := app.sendMessageToAgent() + resultApp := updated.(AppModel) + + if resultApp.menuVisible { + testing.Error("expected menu hidden when no agents") + } +} + +func TestDispatchAgentPickShowsInputBar(testing *testing.T) { + store := state.NewThreadStore() + personas := []roster.PersonaDefinition{ + {ID: testSwarmPersonaID, Name: testSwarmPersonaName}, + } + agentPool := poolpkg.NewAgentPool(testSwarmCommand, []string{}, personas, testSwarmMaxAgents) + agentID, _ := agentPool.Spawn(testSwarmPersonaID, testSwarmTask, "") + app := NewAppModel(store, WithPool(agentPool)) + + item := MenuItem{Label: agentID, Key: rune(agentID[0])} + updated, _ := app.dispatchAgentPick(item) + resultApp := updated.(AppModel) + + if !resultApp.inputBarVisible { + testing.Error("expected input bar visible after agent pick") + } + if resultApp.inputBarIntent != IntentSendMessage { + testing.Error("expected send message intent") + } + if resultApp.pendingTargetAgentID != agentID { + testing.Errorf("expected target %s, got %s", agentID, resultApp.pendingTargetAgentID) + } +} From a45480b783bfd7b5f79a7766f27655448ebb0554 Mon Sep 17 00:00:00 2001 From: Robin White Date: Thu, 19 Mar 2026 13:06:07 -0400 Subject: [PATCH 29/41] feat(tui): render input bar in place of status bar --- internal/tui/app_view.go | 9 ++++++++- internal/tui/app_view_test.go | 28 ++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 internal/tui/app_view_test.go diff --git a/internal/tui/app_view.go b/internal/tui/app_view.go index f077176..442d190 100644 --- a/internal/tui/app_view.go +++ b/internal/tui/app_view.go @@ -19,9 +19,16 @@ func joinSections(sections ...string) string { return strings.Join(sections, viewSeparator) } +func (app AppModel) renderBottomBar() string { + if app.inputBarVisible { + return app.inputBar.ViewWithWidth(app.width) + } + return app.statusBar.View() +} + func (app AppModel) View() string { title := app.header.View() - status := app.statusBar.View() + status := app.renderBottomBar() if app.helpVisible { return joinSections(title, app.help.View(), status) diff --git a/internal/tui/app_view_test.go b/internal/tui/app_view_test.go new file mode 100644 index 0000000..23fda94 --- /dev/null +++ b/internal/tui/app_view_test.go @@ -0,0 +1,28 @@ +package tui + +import ( + "strings" + "testing" + + "github.com/robinojw/dj/internal/state" +) + +const ( + appViewTestWidth = 80 + appViewTestHeight = 24 + appViewPrompt = "Task: " +) + +func TestAppViewShowsInputBar(testing *testing.T) { + store := state.NewThreadStore() + app := NewAppModel(store) + app.inputBarVisible = true + app.inputBar = NewInputBarModel(appViewPrompt) + app.width = appViewTestWidth + app.height = appViewTestHeight + + view := app.View() + if !strings.Contains(view, appViewPrompt) { + testing.Error("expected input bar prompt in view") + } +} From 19d80411731abded80621aae4ea856f8f6246878 Mon Sep 17 00:00:00 2001 From: Robin White Date: Thu, 19 Mar 2026 13:08:14 -0400 Subject: [PATCH 30/41] feat(tui): add swarm filter to canvas --- internal/tui/canvas.go | 45 ++++++++++++++++++++++++++++--------- internal/tui/canvas_test.go | 39 ++++++++++++++++++++++++++++++-- 2 files changed, 72 insertions(+), 12 deletions(-) diff --git a/internal/tui/canvas.go b/internal/tui/canvas.go index db9842e..15c2d92 100644 --- a/internal/tui/canvas.go +++ b/internal/tui/canvas.go @@ -14,11 +14,12 @@ const ( ) type CanvasModel struct { - store *state.ThreadStore - selected int - pinnedIDs map[string]bool - width int - height int + store *state.ThreadStore + selected int + pinnedIDs map[string]bool + width int + height int + swarmFilter bool } func NewCanvasModel(store *state.ThreadStore) CanvasModel { @@ -37,16 +38,40 @@ func (canvas *CanvasModel) SetDimensions(width int, height int) { canvas.height = height } +func (canvas *CanvasModel) SetSwarmFilter(enabled bool) { + canvas.swarmFilter = enabled +} + +func (canvas *CanvasModel) filteredThreads() []*state.ThreadState { + threads := canvas.store.TreeOrder() + if !canvas.swarmFilter { + return threads + } + + var filtered []*state.ThreadState + for _, thread := range threads { + isAgent := thread.AgentProcessID != "" + if isAgent { + filtered = append(filtered, thread) + } + } + return filtered +} + func (canvas *CanvasModel) SelectedIndex() int { return canvas.selected } func (canvas *CanvasModel) SelectedThreadID() string { - threads := canvas.store.TreeOrder() + threads := canvas.filteredThreads() if len(threads) == 0 { return "" } - return threads[canvas.selected].ID + clampedIndex := canvas.selected + if clampedIndex >= len(threads) { + clampedIndex = len(threads) - 1 + } + return threads[clampedIndex].ID } func (canvas *CanvasModel) SetSelected(index int) { @@ -70,7 +95,7 @@ func (canvas *CanvasModel) ClampSelected() { } func (canvas *CanvasModel) MoveRight() { - threads := canvas.store.TreeOrder() + threads := canvas.filteredThreads() if canvas.selected < len(threads)-1 { canvas.selected++ } @@ -83,7 +108,7 @@ func (canvas *CanvasModel) MoveLeft() { } func (canvas *CanvasModel) MoveDown() { - threads := canvas.store.TreeOrder() + threads := canvas.filteredThreads() next := canvas.selected + canvasColumns if next < len(threads) { canvas.selected = next @@ -107,7 +132,7 @@ func (canvas *CanvasModel) centerContent(content string) string { } func (canvas *CanvasModel) View() string { - threads := canvas.store.TreeOrder() + threads := canvas.filteredThreads() if len(threads) == 0 { return canvas.renderEmpty() } diff --git a/internal/tui/canvas_test.go b/internal/tui/canvas_test.go index ff34b86..7046925 100644 --- a/internal/tui/canvas_test.go +++ b/internal/tui/canvas_test.go @@ -22,7 +22,9 @@ const ( canvasTestTitleRoot = "Root" canvasTestTitleChild1 = "Child 1" canvasTestTitleChild2 = "Child 2" - canvasTestTitleOnly = "Only" + canvasTestTitleOnly = "Only" + canvasTestExpectedFmt = "expected %s, got %s" + canvasTestExpectedThreads = 2 ) func TestCanvasNavigation(test *testing.T) { @@ -77,7 +79,7 @@ func TestCanvasSelectedThreadID(test *testing.T) { id := canvas.SelectedThreadID() if id != canvasTestID2 { - test.Errorf("expected %s, got %s", canvasTestID2, id) + test.Errorf(canvasTestExpectedFmt, canvasTestID2, id) } } @@ -168,3 +170,36 @@ func TestCanvasTreeOrder(test *testing.T) { test.Error("child-1 should appear before child-2") } } + +func TestCanvasSwarmFilter(test *testing.T) { + store := state.NewThreadStore() + store.Add(canvasTestID1, canvasTestFirst) + store.Add(canvasTestID2, canvasTestSecond) + thread2, _ := store.Get(canvasTestID2) + thread2.AgentProcessID = "architect-1" + + canvas := NewCanvasModel(store) + canvas.SetSwarmFilter(true) + + filtered := canvas.filteredThreads() + if len(filtered) != 1 { + test.Errorf("expected 1 agent thread, got %d", len(filtered)) + } + if filtered[0].ID != canvasTestID2 { + test.Errorf(canvasTestExpectedFmt, canvasTestID2, filtered[0].ID) + } +} + +func TestCanvasSwarmFilterOff(test *testing.T) { + store := state.NewThreadStore() + store.Add(canvasTestID1, canvasTestFirst) + store.Add(canvasTestID2, canvasTestSecond) + + canvas := NewCanvasModel(store) + canvas.SetSwarmFilter(false) + + filtered := canvas.filteredThreads() + if len(filtered) != canvasTestExpectedThreads { + test.Errorf("expected %d threads, got %d", canvasTestExpectedThreads, len(filtered)) + } +} From 00ed93caaf831fd6aa07bd3e95803bf9323aeaa2 Mon Sep 17 00:00:00 2001 From: Robin White Date: Thu, 19 Mar 2026 13:09:03 -0400 Subject: [PATCH 31/41] feat(tui): implement toggleSwarmView with canvas filtering --- internal/tui/app_swarm.go | 2 ++ internal/tui/app_swarm_test.go | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/internal/tui/app_swarm.go b/internal/tui/app_swarm.go index 4098f8c..69527ee 100644 --- a/internal/tui/app_swarm.go +++ b/internal/tui/app_swarm.go @@ -132,5 +132,7 @@ func (app AppModel) killAgent() (tea.Model, tea.Cmd) { } func (app AppModel) toggleSwarmView() (tea.Model, tea.Cmd) { + app.swarmFilter = !app.swarmFilter + app.canvas.SetSwarmFilter(app.swarmFilter) return app, nil } diff --git a/internal/tui/app_swarm_test.go b/internal/tui/app_swarm_test.go index cbb25a0..911f6da 100644 --- a/internal/tui/app_swarm_test.go +++ b/internal/tui/app_swarm_test.go @@ -166,3 +166,22 @@ func TestDispatchAgentPickShowsInputBar(testing *testing.T) { testing.Errorf("expected target %s, got %s", agentID, resultApp.pendingTargetAgentID) } } + +func TestToggleSwarmViewFiltersCanvas(testing *testing.T) { + store := state.NewThreadStore() + app := NewAppModel(store) + + updated, _ := app.toggleSwarmView() + resultApp := updated.(AppModel) + + if !resultApp.swarmFilter { + testing.Error("expected swarm filter enabled after toggle") + } + + updated2, _ := resultApp.toggleSwarmView() + resultApp2 := updated2.(AppModel) + + if resultApp2.swarmFilter { + testing.Error("expected swarm filter disabled after second toggle") + } +} From 31430c47c39eb82a572395bb14d26f37dd85ec11 Mon Sep 17 00:00:00 2001 From: Robin White Date: Thu, 19 Mar 2026 13:24:07 -0400 Subject: [PATCH 32/41] fix(tui): set connected status when PTY sessions are created or removed --- internal/tui/app_pty.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/internal/tui/app_pty.go b/internal/tui/app_pty.go index 52ff8e7..24445aa 100644 --- a/internal/tui/app_pty.go +++ b/internal/tui/app_pty.go @@ -114,6 +114,10 @@ func (app *AppModel) stopAndRemovePTY(threadID string) { } ptySession.Stop() delete(app.ptySessions, threadID) + hasNoSessions := len(app.ptySessions) == 0 + if hasNoSessions { + app.statusBar.SetConnected(false) + } } func (app AppModel) togglePin() (tea.Model, tea.Cmd) { @@ -159,6 +163,7 @@ func (app *AppModel) ensurePTYSession(threadID string) { return } app.ptySessions[threadID] = ptySession + app.statusBar.SetConnected(true) } func (app AppModel) pinnedIndex(threadID string) int { From d9bb01efa2b3e6d05948e20c9b1f53be0f65ea84 Mon Sep 17 00:00:00 2001 From: Robin White Date: Thu, 19 Mar 2026 16:24:09 -0400 Subject: [PATCH 33/41] docs: add live process spawning and orchestrator bootstrap design --- ...26-03-19-live-spawn-orchestrator-design.md | 155 ++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 docs/plans/2026-03-19-live-spawn-orchestrator-design.md diff --git a/docs/plans/2026-03-19-live-spawn-orchestrator-design.md b/docs/plans/2026-03-19-live-spawn-orchestrator-design.md new file mode 100644 index 0000000..eec2c6f --- /dev/null +++ b/docs/plans/2026-03-19-live-spawn-orchestrator-design.md @@ -0,0 +1,155 @@ +# Live Process Spawning & Orchestrator Bootstrap Design + +## Overview + +Close the gap between DJ's agent pool bookkeeping and actual Codex process management. `pool.Spawn()` becomes a live operation that starts a real `codex proto` child process, wires its JSON-RPC event stream into the pool, and auto-approves execution requests. A new `SpawnOrchestrator()` method bootstraps a coordinator agent on startup that receives user tasks and emits `dj-command` spawn blocks to fan out work across persona-typed workers. + +## Decisions + +- **Protocol**: JSON-RPC via `codex proto` (app-server mode) — matches existing architecture +- **Persona injection**: First turn message contains persona instructions + task +- **Approval policy**: Auto-approve all exec/file requests — agents run fully autonomously +- **Orchestrator**: Auto-spawns on startup when `auto_orchestrate = true` in config + +## Section 1: Client Initialize Method + +Add `Initialize()` to `appserver.Client`. Sends the JSON-RPC handshake Codex expects on connection: + +```json +{"jsonrpc":"2.0","id":"dj-1","method":"initialize","params":{"clientInfo":{"name":"dj","version":"0.1.0"}}} +``` + +Fire-and-forget — the ReadLoop handles the response. No blocking wait needed. + +**File**: `internal/appserver/client_send.go` + +## Section 2: Live Process Spawning + +`pool.Spawn()` gains a real process lifecycle. After creating the `AgentProcess` struct: + +1. `client := appserver.NewClient(pool.command, pool.args...)` +2. `client.Start(ctx)` — spawns `codex proto` as a child process +3. `client.Initialize()` — sends the handshake +4. Start a goroutine running `client.ReadLoop(handler)` where the handler: + - Wraps each message as `PoolEvent{AgentID, Message}` + - Pushes to `pool.events` channel + - Detects `MethodExecApproval` / `MethodFileApproval` and auto-responds with `client.SendApproval(requestID, true)` before forwarding +5. `client.SendUserInput(buildWorkerPrompt(persona, task))` — first turn +6. Set `agent.Status = AgentStatusActive`, store the client on the agent + +The pool stores a `context.Context` (created during `NewAgentPool`) so `StopAll` can cancel all child processes. + +**Files**: `internal/pool/pool.go`, `internal/pool/spawn.go` (new, extracted from pool.go for clarity) + +## Section 3: Orchestrator Bootstrap + +New method `SpawnOrchestrator(signals *roster.RepoSignals)` on `AgentPool`: + +- Creates an agent with `Role=RoleOrchestrator`, no persona ID +- First turn message is the orchestrator prompt (Section 4) +- No task — orchestrator idles until the user submits one +- Called from `main.go` during startup when `auto_orchestrate = true` + +When the user presses `n` and types a task, the TUI sends it to the orchestrator via `client.SendUserInput()`. The orchestrator analyzes and emits `dj-command` spawn blocks. The existing `CommandParser` + `handlePoolEvent` pipeline processes those. + +**Files**: `internal/pool/pool.go`, `internal/pool/orchestrator.go` (new) + +## Section 4: Prompt Templates + +New `internal/pool/prompts.go` file with prompt construction functions. + +**Worker prompt** (first turn message): + +``` +You are acting as the {Name} specialist. + +{persona.Content} + +Your task: {task} +``` + +**Orchestrator prompt** (first turn message): + +``` +You are DJ's orchestrator. You coordinate a team of specialist agents to accomplish tasks. + +Available personas: +- {id}: {description} +[...one line per persona] + +Repo context: +Languages: {signals.Languages} +CI: {signals.CIProvider} +Lint: {signals.LintConfig} + +To spawn an agent, emit a fenced code block: +```dj-command +{"action":"spawn","persona":"architect","task":"Design the auth module"} +``` + +To message an existing agent: +```dj-command +{"action":"message","target":"architect-1","content":"Please add rate limiting"} +``` + +When done coordinating, emit: +```dj-command +{"action":"complete","content":"Summary of results"} +``` + +Analyze the user's request, decide which specialists to spawn, and coordinate their work. +``` + +## Section 5: Auto-Approval + +In the ReadLoop handler for each agent, before forwarding events: + +```go +if isApprovalRequest(message) { + client.SendApproval(message.ID, true) +} +pool.events <- PoolEvent{AgentID: agentID, Message: message} +``` + +The TUI still receives approval events for display (showing what commands were run, what files were changed). Agents never block waiting for human approval. + +## Section 6: Process Lifecycle and Cleanup + +- ReadLoop goroutine detects process exit when `scanner.Scan()` returns false +- Sends a synthetic completion event so the TUI updates the card status +- Pool marks agent as completed +- `StopAgent()` calls `client.Stop()` on the specific agent (already partially implemented) +- `StopAll()` iterates and stops all agents (already implemented, just needs client.Stop() calls) +- Graceful shutdown in `main.go` calls `pool.StopAll()` before exit + +## Section 7: Startup Flow + +Updated `main.go` flow: + +``` +1. config.Load() +2. roster.LoadPersonas() + LoadSignals() +3. pool.NewAgentPool(ctx, command, args, personas, maxAgents) +4. if auto_orchestrate && len(personas) > 0: + pool.SpawnOrchestrator(signals) +5. tui.NewAppModel(store, WithPool(pool)) +6. program.Run() +7. pool.StopAll() // graceful shutdown +``` + +## Section 8: TUI Integration Changes + +- `handleThreadCreated` for pool mode: when orchestrator is spawned, add it to the store so it appears on the canvas +- User presses `n` in pool mode → task sent to orchestrator (not creating a local thread) +- Manual `p` key still works for direct persona spawning (bypasses orchestrator) + +## New/Modified Files + +| File | Change | +|------|--------| +| `internal/appserver/client_send.go` | Add `Initialize()` method | +| `internal/pool/pool.go` | Add context, update `Spawn()` with live process, add `SpawnOrchestrator()` | +| `internal/pool/spawn.go` | New — extracted spawn logic with ReadLoop wiring | +| `internal/pool/orchestrator.go` | New — orchestrator bootstrap and prompt | +| `internal/pool/prompts.go` | New — worker and orchestrator prompt templates | +| `cmd/dj/main.go` | Call `SpawnOrchestrator()` on startup, pass context | From 36799295bbde752f9d9c19dcc0fc58ae010dd131 Mon Sep 17 00:00:00 2001 From: Robin White Date: Thu, 19 Mar 2026 16:54:29 -0400 Subject: [PATCH 34/41] feat(appserver): add Initialize method for JSON-RPC handshake --- internal/appserver/client_send.go | 20 ++++++++++++++++++++ internal/appserver/client_test.go | 29 +++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/internal/appserver/client_send.go b/internal/appserver/client_send.go index 08c2599..849b5c9 100644 --- a/internal/appserver/client_send.go +++ b/internal/appserver/client_send.go @@ -5,6 +5,26 @@ import ( "fmt" ) +const ( + initClientName = "dj" + initClientVersion = "0.1.0" +) + +func (client *Client) Initialize() error { + requestID := client.NextID() + request := &JSONRPCRequest{ + jsonRPCOutgoing: jsonRPCOutgoing{JSONRPC: jsonRPCVersion, ID: requestID}, + Method: MethodInitialize, + Params: map[string]interface{}{ + "clientInfo": map[string]string{ + "name": initClientName, + "version": initClientVersion, + }, + }, + } + return client.Send(request) +} + // Send writes a JSON-RPC request to the child's stdin as a JSONL line. func (client *Client) Send(request *JSONRPCRequest) error { return client.writeJSON(request) diff --git a/internal/appserver/client_test.go b/internal/appserver/client_test.go index 6833c0c..b9a5d65 100644 --- a/internal/appserver/client_test.go +++ b/internal/appserver/client_test.go @@ -192,6 +192,35 @@ func TestReadLoopParsesV2Request(test *testing.T) { serverWrite.Close() } +func TestClientInitialize(test *testing.T) { + client := NewClient(clientTestCommand) + ctx, cancel := context.WithTimeout(context.Background(), clientTestTimeout) + defer cancel() + + if err := client.Start(ctx); err != nil { + test.Fatalf(clientTestStartFail, err) + } + defer client.Stop() + + messages := make(chan JSONRPCMessage, clientTestChannelSize) + go client.ReadLoop(func(message JSONRPCMessage) { + messages <- message + }) + + if err := client.Initialize(); err != nil { + test.Fatalf("Initialize failed: %v", err) + } + + select { + case message := <-messages: + if message.Method != MethodInitialize { + test.Errorf(clientTestExpectedValue, MethodInitialize, message.Method) + } + case <-time.After(clientTestEventWait): + test.Fatal(clientTestTimeoutMsg) + } +} + func TestClientSendApproval(test *testing.T) { client := NewClient(clientTestCommand) From 6279a12fa266ff6c9c0c2bd0de5edf50f4a63996 Mon Sep 17 00:00:00 2001 From: Robin White Date: Thu, 19 Mar 2026 16:55:41 -0400 Subject: [PATCH 35/41] feat(pool): add worker and orchestrator prompt templates --- internal/pool/prompts.go | 64 +++++++++++++++++++++++++ internal/pool/prompts_test.go | 88 +++++++++++++++++++++++++++++++++++ 2 files changed, 152 insertions(+) create mode 100644 internal/pool/prompts.go create mode 100644 internal/pool/prompts_test.go diff --git a/internal/pool/prompts.go b/internal/pool/prompts.go new file mode 100644 index 0000000..d1d6129 --- /dev/null +++ b/internal/pool/prompts.go @@ -0,0 +1,64 @@ +package pool + +import ( + "fmt" + "strings" + + "github.com/robinojw/dj/internal/roster" +) + +const orchestratorPreamble = "You are DJ's orchestrator. You coordinate a team of specialist agents to accomplish tasks." + +const orchestratorFooter = "Analyze the user's request, decide which specialists to spawn, and coordinate their work." + +const workerPromptFmt = "You are acting as the %s specialist.\n\n%s\n\nYour task: %s" + +const fenceOpen = "```dj-command\n" + +const fenceClose = "```\n" + +func BuildWorkerPrompt(persona *roster.PersonaDefinition, task string) string { + return fmt.Sprintf(workerPromptFmt, persona.Name, persona.Content, task) +} + +func BuildOrchestratorPrompt(personas map[string]roster.PersonaDefinition, signals *roster.RepoSignals) string { + var builder strings.Builder + builder.WriteString(orchestratorPreamble) + builder.WriteString("\n\nAvailable personas:\n") + for id, persona := range personas { + fmt.Fprintf(&builder, "- %s: %s\n", id, persona.Description) + } + if signals != nil { + appendRepoContext(&builder, signals) + } + appendInstructions(&builder) + builder.WriteString("\n") + builder.WriteString(orchestratorFooter) + return builder.String() +} + +func appendRepoContext(builder *strings.Builder, signals *roster.RepoSignals) { + builder.WriteString("\nRepo context:\n") + fmt.Fprintf(builder, "Languages: %s\n", strings.Join(signals.Languages, ", ")) + if signals.CIProvider != "" { + fmt.Fprintf(builder, "CI: %s\n", signals.CIProvider) + } + if signals.LintConfig != "" { + fmt.Fprintf(builder, "Lint: %s\n", signals.LintConfig) + } +} + +func appendInstructions(builder *strings.Builder) { + builder.WriteString("\nTo spawn an agent, emit a fenced code block:\n") + builder.WriteString(fenceOpen) + builder.WriteString("{\"action\":\"spawn\",\"persona\":\"\",\"task\":\"\"}\n") + builder.WriteString(fenceClose) + builder.WriteString("\nTo message an existing agent:\n") + builder.WriteString(fenceOpen) + builder.WriteString("{\"action\":\"message\",\"target\":\"\",\"content\":\"\"}\n") + builder.WriteString(fenceClose) + builder.WriteString("\nWhen done coordinating, emit:\n") + builder.WriteString(fenceOpen) + builder.WriteString("{\"action\":\"complete\",\"content\":\"\"}\n") + builder.WriteString(fenceClose) +} diff --git a/internal/pool/prompts_test.go b/internal/pool/prompts_test.go new file mode 100644 index 0000000..327194e --- /dev/null +++ b/internal/pool/prompts_test.go @@ -0,0 +1,88 @@ +package pool + +import ( + "strings" + "testing" + + "github.com/robinojw/dj/internal/roster" +) + +const ( + testPromptPersonaName = "Architect" + testPromptPersonaContent = "You design systems." + testPromptTask = "Design the API" + testPromptPersonaDesc = "System design" + testPromptLanguage = "Go" + testPromptCI = "GitHub Actions" + testPromptLint = "golangci-lint" +) + +func TestBuildWorkerPrompt(testing *testing.T) { + persona := &roster.PersonaDefinition{ + Name: testPromptPersonaName, + Content: testPromptPersonaContent, + } + prompt := BuildWorkerPrompt(persona, testPromptTask) + + hasName := strings.Contains(prompt, testPromptPersonaName) + if !hasName { + testing.Error("expected prompt to contain persona name") + } + hasContent := strings.Contains(prompt, testPromptPersonaContent) + if !hasContent { + testing.Error("expected prompt to contain persona content") + } + hasTask := strings.Contains(prompt, testPromptTask) + if !hasTask { + testing.Error("expected prompt to contain task") + } +} + +func TestBuildOrchestratorPrompt(testing *testing.T) { + personas := map[string]roster.PersonaDefinition{ + testPersonaArchID: { + ID: testPersonaArchID, + Name: testPromptPersonaName, + Description: testPromptPersonaDesc, + }, + } + signals := &roster.RepoSignals{ + Languages: []string{testPromptLanguage}, + CIProvider: testPromptCI, + LintConfig: testPromptLint, + } + prompt := BuildOrchestratorPrompt(personas, signals) + + hasPreamble := strings.Contains(prompt, orchestratorPreamble) + if !hasPreamble { + testing.Error("expected prompt to contain preamble") + } + hasPersona := strings.Contains(prompt, testPromptPersonaDesc) + if !hasPersona { + testing.Error("expected prompt to contain persona description") + } + hasLanguage := strings.Contains(prompt, testPromptLanguage) + if !hasLanguage { + testing.Error("expected prompt to contain language") + } + hasInstructions := strings.Contains(prompt, "dj-command") + if !hasInstructions { + testing.Error("expected prompt to contain dj-command instructions") + } +} + +func TestBuildOrchestratorPromptNilSignals(testing *testing.T) { + personas := map[string]roster.PersonaDefinition{ + testPersonaTestID: { + ID: testPersonaTestID, + Name: testPersonaTestName, + Description: "Testing", + }, + } + prompt := BuildOrchestratorPrompt(personas, nil) + + hasNoRepoContext := !strings.Contains(prompt, "Repo context") + if !hasNoRepoContext { + testing.Error("expected no repo context when signals are nil") + } +} From 8227aad6728c674191f86d8bc8feb9927daece31 Mon Sep 17 00:00:00 2001 From: Robin White Date: Thu, 19 Mar 2026 16:57:17 -0400 Subject: [PATCH 36/41] feat(pool): add spawn infrastructure with auto-approval and ReadLoop wiring --- internal/pool/spawn.go | 59 +++++++++++++++++++++ internal/pool/spawn_test.go | 100 ++++++++++++++++++++++++++++++++++-- 2 files changed, 154 insertions(+), 5 deletions(-) create mode 100644 internal/pool/spawn.go diff --git a/internal/pool/spawn.go b/internal/pool/spawn.go new file mode 100644 index 0000000..1ddeeae --- /dev/null +++ b/internal/pool/spawn.go @@ -0,0 +1,59 @@ +package pool + +import ( + "context" + "fmt" + + "github.com/robinojw/dj/internal/appserver" +) + +func isApprovalRequest(message appserver.JSONRPCMessage) bool { + isRequest := message.IsRequest() + isExecApproval := message.Method == appserver.MethodExecApproval + isFileApproval := message.Method == appserver.MethodFileApproval + return isRequest && (isExecApproval || isFileApproval) +} + +func startAgentProcess( + ctx context.Context, + agent *AgentProcess, + command string, + args []string, + events chan<- PoolEvent, + prompt string, +) error { + client := appserver.NewClient(command, args...) + if err := client.Start(ctx); err != nil { + return fmt.Errorf("start agent %s: %w", agent.ID, err) + } + + if err := client.Initialize(); err != nil { + client.Stop() + return fmt.Errorf("initialize agent %s: %w", agent.ID, err) + } + + agent.Client = client + agent.Status = AgentStatusActive + + go runAgentReadLoop(agent, events) + + hasPrompt := prompt != "" + if !hasPrompt { + return nil + } + + if _, err := client.SendUserInput(prompt); err != nil { + return fmt.Errorf("send prompt to %s: %w", agent.ID, err) + } + + return nil +} + +func runAgentReadLoop(agent *AgentProcess, events chan<- PoolEvent) { + agent.Client.ReadLoop(func(message appserver.JSONRPCMessage) { + if isApprovalRequest(message) { + agent.Client.SendApproval(message.ID, true) + } + events <- PoolEvent{AgentID: agent.ID, Message: message} + }) +} diff --git a/internal/pool/spawn_test.go b/internal/pool/spawn_test.go index 69e882a..15aa18d 100644 --- a/internal/pool/spawn_test.go +++ b/internal/pool/spawn_test.go @@ -1,12 +1,21 @@ package pool -import "testing" +import ( + "context" + "testing" + "time" + + "github.com/robinojw/dj/internal/appserver" +) const ( - testPersonaArch = "architect" - testTaskSome = "some task" - zeroMaxAgents = 0 - nonexistentID = "nonexistent" + testPersonaArch = "architect" + testTaskSome = "some task" + zeroMaxAgents = 0 + nonexistentID = "nonexistent" + testProcessTimeout = 5 * time.Second + testJSONRPCVersion = "2.0" + testAgentID = "test-agent-1" ) func TestSpawnRejectsUnknownPersona(testing *testing.T) { @@ -41,3 +50,84 @@ func TestStopAgentNotFound(testing *testing.T) { testing.Error("expected error for nonexistent agent") } } + +func TestIsApprovalRequestExec(testing *testing.T) { + message := appserver.JSONRPCMessage{ + JSONRPC: testJSONRPCVersion, + ID: "req-1", + Method: appserver.MethodExecApproval, + } + if !isApprovalRequest(message) { + testing.Error("expected exec approval to be detected") + } +} + +func TestIsApprovalRequestFile(testing *testing.T) { + message := appserver.JSONRPCMessage{ + JSONRPC: testJSONRPCVersion, + ID: "req-2", + Method: appserver.MethodFileApproval, + } + if !isApprovalRequest(message) { + testing.Error("expected file approval to be detected") + } +} + +func TestIsApprovalRequestNotification(testing *testing.T) { + message := appserver.JSONRPCMessage{ + JSONRPC: testJSONRPCVersion, + Method: appserver.MethodThreadStarted, + } + if isApprovalRequest(message) { + testing.Error("expected notification to not be an approval request") + } +} + +func TestStartAgentProcess(testing *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), testProcessTimeout) + defer cancel() + + events := make(chan PoolEvent, poolEventChannelSize) + agent := &AgentProcess{ + ID: testAgentID, + Status: AgentStatusSpawning, + } + + err := startAgentProcess(ctx, agent, "cat", []string{}, events, "hello world") + if err != nil { + testing.Fatalf("startAgentProcess failed: %v", err) + } + defer agent.Client.Stop() + + if agent.Client == nil { + testing.Fatal("expected client to be set") + } + if agent.Status != AgentStatusActive { + testing.Errorf("expected status %s, got %s", AgentStatusActive, agent.Status) + } + + select { + case event := <-events: + if event.AgentID != testAgentID { + testing.Errorf("expected agent ID %s, got %s", testAgentID, event.AgentID) + } + case <-time.After(testProcessTimeout): + testing.Fatal("timeout waiting for event from agent process") + } +} + +func TestStartAgentProcessBadCommand(testing *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), testProcessTimeout) + defer cancel() + + events := make(chan PoolEvent, poolEventChannelSize) + agent := &AgentProcess{ + ID: "test-fail-1", + Status: AgentStatusSpawning, + } + + err := startAgentProcess(ctx, agent, "nonexistent-binary-xyz", []string{}, events, "hello") + if err == nil { + testing.Error("expected error for nonexistent command") + } +} From 945478ae78fbf95fec81e8b6f3e329adcd219870 Mon Sep 17 00:00:00 2001 From: Robin White Date: Fri, 20 Mar 2026 09:11:09 -0400 Subject: [PATCH 37/41] feat(pool): add SetContext and live process spawning to Spawn --- internal/pool/pool.go | 38 +++++++++++++++++++++-- internal/pool/spawn_test.go | 60 +++++++++++++++++++++++++++++++++++-- 2 files changed, 93 insertions(+), 5 deletions(-) diff --git a/internal/pool/pool.go b/internal/pool/pool.go index 468f98d..d169c83 100644 --- a/internal/pool/pool.go +++ b/internal/pool/pool.go @@ -1,6 +1,7 @@ package pool import ( + "context" "fmt" "sync" "sync/atomic" @@ -22,6 +23,7 @@ type AgentPool struct { personas map[string]roster.PersonaDefinition maxAgents int idCounter atomic.Int64 + ctx context.Context } func NewAgentPool(command string, args []string, personas []roster.PersonaDefinition, maxAgents int) *AgentPool { @@ -70,18 +72,42 @@ func (agentPool *AgentPool) Count() int { return len(agentPool.agents) } +func (agentPool *AgentPool) SetContext(ctx context.Context) { + agentPool.ctx = ctx +} + func (agentPool *AgentPool) Spawn(personaID string, task string, parentAgentID string) (string, error) { + agent, err := agentPool.registerAgent(personaID, task, parentAgentID) + if err != nil { + return "", err + } + + isLive := agentPool.ctx != nil + if !isLive { + return agent.ID, nil + } + + prompt := BuildWorkerPrompt(agent.Persona, task) + if err := startAgentProcess(agentPool.ctx, agent, agentPool.command, agentPool.args, agentPool.events, prompt); err != nil { + agentPool.removeAgent(agent.ID) + return "", fmt.Errorf("start agent: %w", err) + } + + return agent.ID, nil +} + +func (agentPool *AgentPool) registerAgent(personaID string, task string, parentAgentID string) (*AgentProcess, error) { agentPool.mu.Lock() defer agentPool.mu.Unlock() isAtCapacity := len(agentPool.agents) >= agentPool.maxAgents if isAtCapacity { - return "", fmt.Errorf("agent pool at capacity (%d)", agentPool.maxAgents) + return nil, fmt.Errorf("agent pool at capacity (%d)", agentPool.maxAgents) } persona, exists := agentPool.personas[personaID] if !exists { - return "", fmt.Errorf("unknown persona: %s", personaID) + return nil, fmt.Errorf("unknown persona: %s", personaID) } agentID := agentPool.nextAgentID(personaID) @@ -97,7 +123,13 @@ func (agentPool *AgentPool) Spawn(personaID string, task string, parentAgentID s } agentPool.agents[agentID] = agent - return agentID, nil + return agent, nil +} + +func (agentPool *AgentPool) removeAgent(agentID string) { + agentPool.mu.Lock() + defer agentPool.mu.Unlock() + delete(agentPool.agents, agentID) } func (agentPool *AgentPool) StopAgent(agentID string) error { diff --git a/internal/pool/spawn_test.go b/internal/pool/spawn_test.go index 15aa18d..4785522 100644 --- a/internal/pool/spawn_test.go +++ b/internal/pool/spawn_test.go @@ -6,6 +6,7 @@ import ( "time" "github.com/robinojw/dj/internal/appserver" + "github.com/robinojw/dj/internal/roster" ) const ( @@ -16,6 +17,10 @@ const ( testProcessTimeout = 5 * time.Second testJSONRPCVersion = "2.0" testAgentID = "test-agent-1" + testCatCommand = "cat" + testSpawnFailed = "Spawn failed: %v" + testExpectedStatus = "expected status %s, got %s" + testAgentExists = "expected agent to exist" ) func TestSpawnRejectsUnknownPersona(testing *testing.T) { @@ -93,7 +98,7 @@ func TestStartAgentProcess(testing *testing.T) { Status: AgentStatusSpawning, } - err := startAgentProcess(ctx, agent, "cat", []string{}, events, "hello world") + err := startAgentProcess(ctx, agent, testCatCommand, []string{}, events, "hello world") if err != nil { testing.Fatalf("startAgentProcess failed: %v", err) } @@ -103,7 +108,7 @@ func TestStartAgentProcess(testing *testing.T) { testing.Fatal("expected client to be set") } if agent.Status != AgentStatusActive { - testing.Errorf("expected status %s, got %s", AgentStatusActive, agent.Status) + testing.Errorf(testExpectedStatus, AgentStatusActive, agent.Status) } select { @@ -131,3 +136,54 @@ func TestStartAgentProcessBadCommand(testing *testing.T) { testing.Error("expected error for nonexistent command") } } + +func TestSpawnLiveStartsProcess(testing *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), testProcessTimeout) + defer cancel() + + personas := []roster.PersonaDefinition{ + {ID: testPersonaArchID, Name: testPersonaArchName, Content: "System design specialist."}, + } + agentPool := NewAgentPool(testCatCommand, []string{}, personas, DefaultMaxAgents) + agentPool.SetContext(ctx) + defer agentPool.StopAll() + + agentID, err := agentPool.Spawn(testPersonaArchID, testTask, "") + if err != nil { + testing.Fatalf(testSpawnFailed, err) + } + + agent, exists := agentPool.Get(agentID) + if !exists { + testing.Fatal(testAgentExists) + } + if agent.Client == nil { + testing.Fatal("expected client to be set on live spawn") + } + if agent.Status != AgentStatusActive { + testing.Errorf(testExpectedStatus, AgentStatusActive, agent.Status) + } +} + +func TestSpawnWithoutContextBookkeepingOnly(testing *testing.T) { + personas := []roster.PersonaDefinition{ + {ID: testPersonaArchID, Name: testPersonaArchName}, + } + agentPool := NewAgentPool("echo", []string{}, personas, DefaultMaxAgents) + + agentID, err := agentPool.Spawn(testPersonaArchID, testTask, "") + if err != nil { + testing.Fatalf(testSpawnFailed, err) + } + + agent, exists := agentPool.Get(agentID) + if !exists { + testing.Fatal(testAgentExists) + } + if agent.Client != nil { + testing.Error("expected client to be nil without context") + } + if agent.Status != AgentStatusSpawning { + testing.Errorf(testExpectedStatus, AgentStatusSpawning, agent.Status) + } +} From 1d74356ea43b38b2dcff8440f73afa201e0c1239 Mon Sep 17 00:00:00 2001 From: Robin White Date: Fri, 20 Mar 2026 09:12:11 -0400 Subject: [PATCH 38/41] feat(pool): add SpawnOrchestrator for coordinator agent bootstrap --- internal/pool/orchestrator.go | 50 +++++++++++++++++++ internal/pool/orchestrator_test.go | 79 ++++++++++++++++++++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 internal/pool/orchestrator.go create mode 100644 internal/pool/orchestrator_test.go diff --git a/internal/pool/orchestrator.go b/internal/pool/orchestrator.go new file mode 100644 index 0000000..ca5e8c0 --- /dev/null +++ b/internal/pool/orchestrator.go @@ -0,0 +1,50 @@ +package pool + +import ( + "fmt" + + "github.com/robinojw/dj/internal/orchestrator" + "github.com/robinojw/dj/internal/roster" +) + +const orchestratorAgentID = "orchestrator" + +func (agentPool *AgentPool) SpawnOrchestrator(signals *roster.RepoSignals) (string, error) { + agent, err := agentPool.registerOrchestrator() + if err != nil { + return "", err + } + + isLive := agentPool.ctx != nil + if !isLive { + return orchestratorAgentID, nil + } + + prompt := BuildOrchestratorPrompt(agentPool.personas, signals) + if err := startAgentProcess(agentPool.ctx, agent, agentPool.command, agentPool.args, agentPool.events, prompt); err != nil { + agentPool.removeAgent(orchestratorAgentID) + return "", fmt.Errorf("start orchestrator: %w", err) + } + + return orchestratorAgentID, nil +} + +func (agentPool *AgentPool) registerOrchestrator() (*AgentProcess, error) { + agentPool.mu.Lock() + defer agentPool.mu.Unlock() + + isAtCapacity := len(agentPool.agents) >= agentPool.maxAgents + if isAtCapacity { + return nil, fmt.Errorf("agent pool at capacity (%d)", agentPool.maxAgents) + } + + agent := &AgentProcess{ + ID: orchestratorAgentID, + Role: RoleOrchestrator, + Status: AgentStatusSpawning, + Parser: orchestrator.NewCommandParser(), + } + agentPool.agents[orchestratorAgentID] = agent + + return agent, nil +} diff --git a/internal/pool/orchestrator_test.go b/internal/pool/orchestrator_test.go new file mode 100644 index 0000000..86a4371 --- /dev/null +++ b/internal/pool/orchestrator_test.go @@ -0,0 +1,79 @@ +package pool + +import ( + "context" + "testing" + "time" + + "github.com/robinojw/dj/internal/roster" +) + +const ( + testOrchestratorTimeout = 5 * time.Second + testSpawnOrchFailed = "SpawnOrchestrator failed: %v" + testExpectedSGotS = "expected %s, got %s" + testEchoCommand = "echo" + testOrchestratorExists = "expected orchestrator to exist" +) + +func TestSpawnOrchestrator(testing *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), testOrchestratorTimeout) + defer cancel() + + personas := []roster.PersonaDefinition{ + {ID: testPersonaArchID, Name: testPersonaArchName, Description: "System design"}, + } + agentPool := NewAgentPool(testCatCommand, []string{}, personas, DefaultMaxAgents) + agentPool.SetContext(ctx) + defer agentPool.StopAll() + + agentID, err := agentPool.SpawnOrchestrator(nil) + if err != nil { + testing.Fatalf(testSpawnOrchFailed, err) + } + if agentID != orchestratorAgentID { + testing.Errorf(testExpectedSGotS, orchestratorAgentID, agentID) + } + + agent, exists := agentPool.GetOrchestrator() + if !exists { + testing.Fatal(testOrchestratorExists) + } + if agent.Role != RoleOrchestrator { + testing.Errorf(testExpectedSGotS, RoleOrchestrator, agent.Role) + } + if agent.Status != AgentStatusActive { + testing.Errorf(testExpectedStatus, AgentStatusActive, agent.Status) + } +} + +func TestSpawnOrchestratorWithoutContext(testing *testing.T) { + personas := []roster.PersonaDefinition{ + {ID: testPersonaArchID, Name: testPersonaArchName}, + } + agentPool := NewAgentPool(testEchoCommand, []string{}, personas, DefaultMaxAgents) + + agentID, err := agentPool.SpawnOrchestrator(nil) + if err != nil { + testing.Fatalf(testSpawnOrchFailed, err) + } + if agentID != orchestratorAgentID { + testing.Errorf(testExpectedSGotS, orchestratorAgentID, agentID) + } + + agent, exists := agentPool.GetOrchestrator() + if !exists { + testing.Fatal(testOrchestratorExists) + } + if agent.Client != nil { + testing.Error("expected client to be nil without context") + } +} + +func TestSpawnOrchestratorAtCapacity(testing *testing.T) { + agentPool := NewAgentPool(testEchoCommand, []string{}, nil, zeroMaxAgents) + _, err := agentPool.SpawnOrchestrator(nil) + if err == nil { + testing.Error("expected error when at capacity") + } +} From 748f1d026a6e98f571eb0a7774eeff1aaec2b446 Mon Sep 17 00:00:00 2001 From: Robin White Date: Fri, 20 Mar 2026 09:13:50 -0400 Subject: [PATCH 39/41] feat(tui): route n-key to orchestrator task input in pool mode --- internal/tui/app_inputbar.go | 25 +++++++++++++++++++++++++ internal/tui/app_keys.go | 9 ++++++++- internal/tui/app_swarm.go | 17 +++++++++++++++++ internal/tui/app_swarm_test.go | 33 +++++++++++++++++++++++++++++++++ internal/tui/msgs.go | 1 + 5 files changed, 84 insertions(+), 1 deletion(-) diff --git a/internal/tui/app_inputbar.go b/internal/tui/app_inputbar.go index 96781e2..69a0b4b 100644 --- a/internal/tui/app_inputbar.go +++ b/internal/tui/app_inputbar.go @@ -40,6 +40,8 @@ func (app AppModel) submitInputBar() (tea.Model, tea.Cmd) { return app.executeSpawn(value) case IntentSendMessage: return app.executeSendMessage(value) + case IntentOrchestratorTask: + return app.executeOrchestratorTask(value) } return app.dismissInputBar() } @@ -67,6 +69,29 @@ func (app AppModel) executeSpawn(task string) (tea.Model, tea.Cmd) { return app, nil } +func (app AppModel) executeOrchestratorTask(task string) (tea.Model, tea.Cmd) { + app.inputBarVisible = false + app.inputBar.Reset() + + if app.pool == nil { + return app, nil + } + + orchestrator, exists := app.pool.GetOrchestrator() + if !exists { + app.statusBar.SetError("No orchestrator running") + return app, nil + } + + if orchestrator.Client == nil { + app.statusBar.SetError("Orchestrator not connected") + return app, nil + } + + orchestrator.Client.SendUserInput(task) + return app, nil +} + func (app AppModel) executeSendMessage(content string) (tea.Model, tea.Cmd) { app.inputBarVisible = false targetID := app.pendingTargetAgentID diff --git a/internal/tui/app_keys.go b/internal/tui/app_keys.go index e78b6c8..8fb5424 100644 --- a/internal/tui/app_keys.go +++ b/internal/tui/app_keys.go @@ -64,7 +64,7 @@ func (app AppModel) handleRune(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "t": app.toggleCanvasMode() case "n": - return app, app.createThread() + return app.handleNewTask() case "?": app.helpVisible = !app.helpVisible case " ": @@ -83,6 +83,13 @@ func (app AppModel) handleRune(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return app, nil } +func (app AppModel) handleNewTask() (tea.Model, tea.Cmd) { + if app.pool != nil { + return app.promptOrchestratorTask() + } + return app, app.createThread() +} + func (app AppModel) createThread() tea.Cmd { *app.sessionCounter++ counter := *app.sessionCounter diff --git a/internal/tui/app_swarm.go b/internal/tui/app_swarm.go index 69527ee..15d5c10 100644 --- a/internal/tui/app_swarm.go +++ b/internal/tui/app_swarm.go @@ -117,6 +117,23 @@ func extractAgentID(label string) string { return label } +func (app AppModel) promptOrchestratorTask() (tea.Model, tea.Cmd) { + if app.pool == nil { + return app, nil + } + + _, exists := app.pool.GetOrchestrator() + if !exists { + app.statusBar.SetError("No orchestrator running") + return app, nil + } + + app.inputBar = NewInputBarModel("Task" + inputBarPromptSuffix) + app.inputBarVisible = true + app.inputBarIntent = IntentOrchestratorTask + return app, nil +} + func (app AppModel) killAgent() (tea.Model, tea.Cmd) { if app.pool == nil { return app, nil diff --git a/internal/tui/app_swarm_test.go b/internal/tui/app_swarm_test.go index 911f6da..a16c8a4 100644 --- a/internal/tui/app_swarm_test.go +++ b/internal/tui/app_swarm_test.go @@ -167,6 +167,39 @@ func TestDispatchAgentPickShowsInputBar(testing *testing.T) { } } +func TestPromptOrchestratorTaskShowsInputBar(testing *testing.T) { + store := state.NewThreadStore() + personas := []roster.PersonaDefinition{ + {ID: testSwarmPersonaID, Name: testSwarmPersonaName}, + } + agentPool := poolpkg.NewAgentPool(testSwarmCommand, []string{}, personas, testSwarmMaxAgents) + agentPool.SpawnOrchestrator(nil) + app := NewAppModel(store, WithPool(agentPool)) + + updated, _ := app.promptOrchestratorTask() + resultApp := updated.(AppModel) + + if !resultApp.inputBarVisible { + testing.Error("expected input bar visible for orchestrator task") + } + if resultApp.inputBarIntent != IntentOrchestratorTask { + testing.Error("expected orchestrator task intent") + } +} + +func TestPromptOrchestratorTaskNoOrchestrator(testing *testing.T) { + store := state.NewThreadStore() + agentPool := poolpkg.NewAgentPool(testSwarmCommand, []string{}, nil, testSwarmMaxAgents) + app := NewAppModel(store, WithPool(agentPool)) + + updated, _ := app.promptOrchestratorTask() + resultApp := updated.(AppModel) + + if resultApp.inputBarVisible { + testing.Error("expected input bar hidden when no orchestrator") + } +} + func TestToggleSwarmViewFiltersCanvas(testing *testing.T) { store := state.NewThreadStore() app := NewAppModel(store) diff --git a/internal/tui/msgs.go b/internal/tui/msgs.go index 9f23f73..5196cb3 100644 --- a/internal/tui/msgs.go +++ b/internal/tui/msgs.go @@ -13,6 +13,7 @@ type InputIntent int const ( IntentSpawnTask InputIntent = iota IntentSendMessage + IntentOrchestratorTask ) type ThreadCreatedMsg struct { From 75f8b7b02c25312967ac4f6f65fc808c055665f5 Mon Sep 17 00:00:00 2001 From: Robin White Date: Fri, 20 Mar 2026 09:14:47 -0400 Subject: [PATCH 40/41] feat(main): wire orchestrator bootstrap and context into startup flow --- cmd/dj/main.go | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/cmd/dj/main.go b/cmd/dj/main.go index 9640c7c..feedde5 100644 --- a/cmd/dj/main.go +++ b/cmd/dj/main.go @@ -1,6 +1,7 @@ package main import ( + "context" "fmt" "os" "path/filepath" @@ -41,22 +42,27 @@ func runApp(cmd *cobra.Command, args []string) error { return fmt.Errorf("load config: %w", err) } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + store := state.NewThreadStore() var opts []tui.AppOption + var agentPool *pool.AgentPool personas, signals := loadRoster(cfg) hasPersonas := len(personas) > 0 shouldUsePool := hasPersonas && cfg.Roster.AutoOrchestrate if shouldUsePool { - agentPool := pool.NewAgentPool( + agentPool = pool.NewAgentPool( cfg.AppServer.Command, cfg.AppServer.Args, personas, cfg.Pool.MaxAgents, ) + agentPool.SetContext(ctx) opts = append(opts, tui.WithPool(agentPool)) - _ = signals + bootOrchestrator(agentPool, signals, store) } else { client := appserver.NewClient(cfg.AppServer.Command, cfg.AppServer.Args...) defer client.Stop() @@ -73,9 +79,28 @@ func runApp(cmd *cobra.Command, args []string) error { finalApp.StopAllPTYSessions() } + if agentPool != nil { + agentPool.StopAll() + } + return err } +func bootOrchestrator(agentPool *pool.AgentPool, signals *roster.RepoSignals, store *state.ThreadStore) { + agentID, err := agentPool.SpawnOrchestrator(signals) + if err != nil { + fmt.Fprintf(os.Stderr, "orchestrator: %v\n", err) + return + } + store.Add(agentID, "Orchestrator") + thread, exists := store.Get(agentID) + if !exists { + return + } + thread.AgentProcessID = agentID + thread.AgentRole = pool.RoleOrchestrator +} + func loadRoster(cfg *config.Config) ([]roster.PersonaDefinition, *roster.RepoSignals) { personaDir := filepath.Join(cfg.Roster.Path, "personas") personas, err := roster.LoadPersonas(personaDir) From 4960763bc08658f9f23140722478651ea99b9b57 Mon Sep 17 00:00:00 2001 From: Robin White Date: Fri, 20 Mar 2026 09:48:14 -0400 Subject: [PATCH 41/41] chore(roster): add YAML frontmatter to all persona files --- .roster/personas/accessibility.md | 24 ++++++++++++++++++++++++ .roster/personas/api.md | 24 ++++++++++++++++++++++++ .roster/personas/architect.md | 24 ++++++++++++++++++++++++ .roster/personas/data.md | 24 ++++++++++++++++++++++++ .roster/personas/design.md | 24 ++++++++++++++++++++++++ .roster/personas/devops.md | 24 ++++++++++++++++++++++++ .roster/personas/docs.md | 24 ++++++++++++++++++++++++ .roster/personas/performance.md | 24 ++++++++++++++++++++++++ .roster/personas/reviewer.md | 24 ++++++++++++++++++++++++ .roster/personas/security.md | 24 ++++++++++++++++++++++++ .roster/personas/test.md | 24 ++++++++++++++++++++++++ 11 files changed, 264 insertions(+) create mode 100644 .roster/personas/accessibility.md create mode 100644 .roster/personas/api.md create mode 100644 .roster/personas/architect.md create mode 100644 .roster/personas/data.md create mode 100644 .roster/personas/design.md create mode 100644 .roster/personas/devops.md create mode 100644 .roster/personas/docs.md create mode 100644 .roster/personas/performance.md create mode 100644 .roster/personas/reviewer.md create mode 100644 .roster/personas/security.md create mode 100644 .roster/personas/test.md diff --git a/.roster/personas/accessibility.md b/.roster/personas/accessibility.md new file mode 100644 index 0000000..a21bf26 --- /dev/null +++ b/.roster/personas/accessibility.md @@ -0,0 +1,24 @@ +--- +id: accessibility +name: Accessibility +description: WCAG compliance, keyboard navigation, screen reader compatibility, and ARIA usage +--- + +## Principles + +- WCAG 2.1 AA is the minimum standard +- Semantic HTML first, ARIA as a supplement +- Every interaction must work with keyboard alone +- Test with real assistive technology, not just automated tools + +## Codebase Context + +Review the project's existing accessibility patterns, ARIA usage, and focus management before adding new interactive elements. + +## Scope + +- WCAG 2.1 AA compliance +- Keyboard navigation and focus management +- Screen reader compatibility +- ARIA attributes and landmarks +- Colour contrast and visual accessibility \ No newline at end of file diff --git a/.roster/personas/api.md b/.roster/personas/api.md new file mode 100644 index 0000000..4ace4c6 --- /dev/null +++ b/.roster/personas/api.md @@ -0,0 +1,24 @@ +--- +id: api +name: API Designer +description: REST and GraphQL API design, versioning, error handling, and contract testing +--- + +## Principles + +- APIs are contracts — breaking changes require versioning +- Consistent error responses across all endpoints +- Use standard HTTP semantics correctly +- Design for the consumer, not the implementation + +## Codebase Context + +Review existing API patterns, error handling conventions, and versioning strategy before adding or modifying endpoints. + +## Scope + +- REST and GraphQL API design +- API versioning strategy +- Error handling and response format +- Request validation and documentation +- API contract testing \ No newline at end of file diff --git a/.roster/personas/architect.md b/.roster/personas/architect.md new file mode 100644 index 0000000..d01a0cc --- /dev/null +++ b/.roster/personas/architect.md @@ -0,0 +1,24 @@ +--- +id: architect +name: Architect +description: System architecture, module boundaries, dependency management, and API design +--- + +## Principles + +- Favour simplicity over cleverness; prefer boring technology +- Design for change: loose coupling, high cohesion +- Make dependencies explicit and minimise them +- Every public API is a contract — treat it as such + +## Codebase Context + +Review the repository's module boundaries, dependency graph, and package structure before proposing changes. Understand existing patterns before introducing new ones. + +## Scope + +- Module and package structure +- Dependency management and version policy +- API design (internal and external) +- Data flow and system boundaries +- Scalability and performance architecture \ No newline at end of file diff --git a/.roster/personas/data.md b/.roster/personas/data.md new file mode 100644 index 0000000..8fd7d05 --- /dev/null +++ b/.roster/personas/data.md @@ -0,0 +1,24 @@ +--- +id: data +name: Data Engineer +description: Database schema design, migrations, data modelling, and ETL processes +--- + +## Principles + +- Schema changes must be backwards compatible +- Migrations should be reversible +- Normalise by default, denormalise with justification +- Data integrity constraints belong in the database, not just the application + +## Codebase Context + +Review existing schema, migration history, and ORM patterns before proposing data model changes. Understand the project's migration tooling. + +## Scope + +- Database schema design +- Migration creation and management +- Data modelling and relationships +- Query performance and indexing +- Data pipeline and ETL processes \ No newline at end of file diff --git a/.roster/personas/design.md b/.roster/personas/design.md new file mode 100644 index 0000000..d7abd53 --- /dev/null +++ b/.roster/personas/design.md @@ -0,0 +1,24 @@ +--- +id: design +name: Design Engineer +description: UI component implementation, design system adherence, and visual consistency +--- + +## Principles + +- Match the design system exactly — pixel-level fidelity matters +- Components should be composable and reusable +- Responsive by default; mobile-first where applicable +- Accessibility is a design concern, not an afterthought + +## Codebase Context + +Identify the project's design system, component library, and styling approach before building new components. Reuse existing tokens and patterns. + +## Scope + +- UI component implementation +- Design system token usage +- Layout and responsive behaviour +- Visual regression prevention +- Component API design \ No newline at end of file diff --git a/.roster/personas/devops.md b/.roster/personas/devops.md new file mode 100644 index 0000000..d1995f0 --- /dev/null +++ b/.roster/personas/devops.md @@ -0,0 +1,24 @@ +--- +id: devops +name: DevOps Engineer +description: CI/CD pipelines, container management, deployment automation, and infrastructure as code +--- + +## Principles + +- Automate everything that runs more than twice +- Builds should be fast, reproducible, and deterministic +- Infrastructure as code — no manual changes +- Monitor first, then alert on actionable conditions + +## Codebase Context + +Understand the project's CI/CD pipeline, deployment targets, and infrastructure setup before making changes. Respect existing automation patterns. + +## Scope + +- CI/CD pipeline configuration +- Docker and container management +- Deployment automation +- Infrastructure as code +- Monitoring and alerting setup \ No newline at end of file diff --git a/.roster/personas/docs.md b/.roster/personas/docs.md new file mode 100644 index 0000000..8c8b391 --- /dev/null +++ b/.roster/personas/docs.md @@ -0,0 +1,24 @@ +--- +id: docs +name: Documentation +description: README guides, API reference docs, architecture decision records, and inline documentation +--- + +## Principles + +- Write for the reader who has no context +- Keep docs close to the code they describe +- Examples are worth more than explanations +- Documentation is a product — maintain it like one + +## Codebase Context + +Review existing documentation patterns, README structure, and inline comment style before writing. Match the project's voice and level of detail. + +## Scope + +- README and getting-started guides +- API reference documentation +- Architecture decision records +- Inline code documentation +- Runbook and operational docs \ No newline at end of file diff --git a/.roster/personas/performance.md b/.roster/personas/performance.md new file mode 100644 index 0000000..4598c82 --- /dev/null +++ b/.roster/personas/performance.md @@ -0,0 +1,24 @@ +--- +id: performance +name: Performance Engineer +description: Application profiling, caching strategy, bundle size analysis, and query optimisation +--- + +## Principles + +- Measure before optimising — intuition is often wrong +- Optimise the critical path first +- Cache aggressively but invalidate correctly +- Small payloads and lazy loading by default + +## Codebase Context + +Profile the application before proposing changes. Understand existing caching layers, bundle configuration, and database query patterns. + +## Scope + +- Application profiling and bottleneck identification +- Bundle size analysis and reduction +- Database query optimisation +- Caching strategy and implementation +- Network request optimisation \ No newline at end of file diff --git a/.roster/personas/reviewer.md b/.roster/personas/reviewer.md new file mode 100644 index 0000000..3b5da53 --- /dev/null +++ b/.roster/personas/reviewer.md @@ -0,0 +1,24 @@ +--- +id: reviewer +name: Code Reviewer +description: Code review for correctness, convention adherence, naming, and error handling +--- + +## Principles + +- Review for correctness first, style second +- Be specific and actionable in feedback +- Distinguish between blocking issues and suggestions +- Assume good intent; ask questions before asserting + +## Codebase Context + +Know the project's coding standards, linter configuration, and team conventions before reviewing. Focus comments on violations of established patterns. + +## Scope + +- Code correctness and logic errors +- Adherence to project conventions +- Error handling completeness +- Naming and readability +- Performance and security implications \ No newline at end of file diff --git a/.roster/personas/security.md b/.roster/personas/security.md new file mode 100644 index 0000000..f3cc415 --- /dev/null +++ b/.roster/personas/security.md @@ -0,0 +1,24 @@ +--- +id: security +name: Security Engineer +description: Authentication, input validation, secrets management, and OWASP compliance +--- + +## Principles + +- Never trust user input — validate at every boundary +- Secrets belong in vaults, not in code +- Follow the principle of least privilege +- Defence in depth — no single control should be the only protection + +## Codebase Context + +Identify the project's authentication mechanism, secrets management approach, and existing security controls before making changes. + +## Scope + +- Authentication and authorisation +- Input validation and sanitisation +- Secrets and credential management +- Dependency vulnerability scanning +- OWASP Top 10 compliance \ No newline at end of file diff --git a/.roster/personas/test.md b/.roster/personas/test.md new file mode 100644 index 0000000..4c41b57 --- /dev/null +++ b/.roster/personas/test.md @@ -0,0 +1,24 @@ +--- +id: test +name: Test Engineer +description: Test strategy, TDD, coverage analysis, and flaky test diagnosis +--- + +## Principles + +- Write tests first when practical (TDD) +- Test behaviour, not implementation details +- Each test should have a single reason to fail +- Fast tests enable fast feedback — keep the suite quick + +## Codebase Context + +Understand the existing test framework, patterns, and conventions before adding tests. Match the project's testing style. + +## Scope + +- Unit and integration test strategy +- Test coverage analysis +- Test quality and maintainability +- Flaky test diagnosis and resolution +- Test data management \ No newline at end of file