feat: add kit-kit meta-agent extension example

Port of Pi Pi (meta-agent with parallel expert subprocesses) to Kit's
extension system. Includes expert grid widget, query_experts tool,
custom footer, tool renderer, and orchestrator system prompt injection.

Also updates AGENTS.md with Yaegi gotchas, BubbleTea patterns, testing
recipes, and extension architecture notes.

Fixes golangci-lint issues: modernize min/max in overlay.go, replace
deprecated GetExtRunner() with new GetExtensionContext() SDK method,
remove broken --model flag from expert subprocess.
This commit is contained in:
Ed Zynda
2026-02-28 18:59:18 +03:00
parent 7747fc2033
commit eeecd5a843
9 changed files with 1039 additions and 12 deletions
+47
View File
@@ -39,6 +39,53 @@ Keep this managed block so 'openspec update' can refresh the instructions.
- Multi-provider LLM support via `llm.Provider` interface
- MCP client-server for tool integration
- Builtin servers: bash, fetch, todo, fs
- **Extension system** (`internal/extensions/`): Yaegi-interpreted Go, 13 lifecycle events, custom tools/commands/widgets/overlays/editor interceptors
- **TUI** (`internal/ui/`): Bubble Tea v2 parent-child model (`AppModel``InputComponent`, `StreamComponent`, etc.)
- **Decoupling pattern**: `cmd/root.go` has converter functions (e.g. `widgetProviderForUI()`) that bridge `internal/extensions/` types to `internal/ui/` types — the UI never imports extensions directly
## Key Patterns
### Yaegi (Extension Interpreter) Gotchas
- **No interfaces across boundary**: All extension-facing API types must be concrete structs, never interfaces. Yaegi crashes on interface wrapper generation.
- **Function field bug**: Named function references assigned to struct fields return zero values across the interpreter boundary. Always use anonymous closure literals:
```go
// WRONG: ctx.SetEditor(ext.EditorConfig{HandleKey: myHandler})
// RIGHT: ctx.SetEditor(ext.EditorConfig{HandleKey: func(k, t string) ext.EditorKeyAction { return myHandler(k, t) }})
```
- **Symbol exports**: Every new type exposed to extensions must be added to `internal/extensions/symbols.go`
### BubbleTea Integration
- **No `prog.Send()` from inside `Update()`**: Calling `prog.Send()` synchronously within a BubbleTea `Update()` handler deadlocks the event loop. Use `go appInstance.NotifyWidgetUpdate()` (async goroutine) instead.
- **Height measurement**: `distributeHeight()` in `model.go` must measure using the same render path as `View()`. If an interceptor wraps rendering, measure with the wrapper too, or layout will mismatch.
- **Channel-based prompts**: Extension prompt calls (PromptSelect, etc.) block on a `chan PromptResponse`. Extension slash commands run in dedicated goroutines (not `tea.Cmd`) to avoid stalling BubbleTea's Cmd scheduler.
### Extension State Management
- **Thread-safe maps on Runner**: Widget/header/footer/editor state lives on the Runner with `sync.RWMutex`, queried by UI via callbacks
- **Context function fields**: The `Context` struct uses function fields (`Print func(string)`, `SetWidget func(WidgetConfig)`) wired by closures in `cmd/root.go`
- **Package-level vars in extensions**: Yaegi supports package-level variables captured in closures — this is how extensions maintain state across event callbacks
### Unicode in Widget Text
- Widget content renders through `lipgloss.Style.Render()` which preserves ANSI escape codes
- Use rune-based width calculations (`len([]rune(s))`) not byte length (`len(s)`) when aligning box-drawing characters or multi-byte symbols
## Testing
### Interactive TUI Testing with tmux
Use tmux to test Kit interactively without blocking the agent:
```bash
tmux new-session -d -s kittest -x 120 -y 40 "output/kit -e examples/extensions/my-ext.go --no-session 2>kit_stderr.log"
sleep 3
tmux capture-pane -t kittest -p # read screen
tmux send-keys -t kittest '/command' Enter # send input
tmux kill-session -t kittest # cleanup
```
### Non-Interactive Kit (Subprocess Spawning)
Extensions can spawn Kit as a subprocess for sub-agent patterns:
```bash
kit --prompt "question" --quiet --no-session --no-extensions --system-prompt /path/to/prompt.txt --model provider/model
```
Key flags: `--quiet` (stdout only, no TUI), `--no-session` (ephemeral), `--no-extensions` (prevent recursive loading), `--system-prompt` (string or file path).
## External Repo Research
- **ALWAYS use `btca`** to search external repos (e.g. iteratr, other reference codebases)
+1 -4
View File
@@ -285,9 +285,6 @@ func extensionCommandsForUI(k *kit.Kit) []ui.ExtensionCommand {
if len(defs) == 0 {
return nil
}
// We still need the raw runner for GetContext() in the Execute closure.
// This is the last remaining use of GetExtRunner() in cmd/.
runner := k.GetExtRunner()
cmds := make([]ui.ExtensionCommand, 0, len(defs))
for _, d := range defs {
name := d.Name
@@ -298,7 +295,7 @@ func extensionCommandsForUI(k *kit.Kit) []ui.ExtensionCommand {
Name: name,
Description: d.Description,
Execute: func(args string) (string, error) {
return d.Execute(args, runner.GetContext())
return d.Execute(args, k.GetExtensionContext())
},
})
}
@@ -0,0 +1,36 @@
---
name: ext-expert
description: Kit extensions — tools, events, commands, widgets, editor interceptors
tools: read,grep,glob
---
You are an expert on Kit's extension system. Your job is to research and answer questions about how Kit extensions work.
## Key Files
- `internal/extensions/api.go` — Extension API surface, Context struct, all types
- `internal/extensions/runner.go` — Event dispatch, extension registry, widget/header/footer storage
- `internal/extensions/loader.go` — Yaegi interpreter setup, extension loading
- `internal/extensions/symbols.go` — Yaegi symbol exports
- `internal/extensions/events.go` — Event type definitions
- `examples/extensions/` — Example extensions demonstrating all features
## Architecture
Kit extensions are Go files interpreted at runtime by Yaegi. Each extension exports `func Init(api ext.API)` and uses the API to register:
- **Event handlers**: OnSessionStart, OnToolCall, OnToolResult, OnInput, OnAgentEnd, etc.
- **Custom tools**: ToolDef with name, description, JSON Schema parameters, Execute function
- **Slash commands**: CommandDef with name, description, Execute function (receives Context)
- **Tool renderers**: ToolRenderConfig with custom RenderHeader/RenderBody
- **Widgets**: ctx.SetWidget/RemoveWidget for persistent UI elements
- **Headers/Footers**: ctx.SetHeader/SetFooter for chrome customization
- **Editor interceptors**: ctx.SetEditor for key interception and render wrapping
- **Prompts/Overlays**: ctx.PromptSelect/PromptConfirm/PromptInput/ShowOverlay
## Critical Yaegi Limitations
- All function fields in structs must be anonymous closures, NOT named function references
- No interfaces exported to extensions — only concrete structs
- Extensions run in isolated interpreters with stdlib + os/exec access
When answering, cite specific file paths and line numbers. Provide concrete code examples.
@@ -0,0 +1,34 @@
---
name: llm-expert
description: Kit LLM system — providers, streaming, agent loop, tool execution
tools: read,grep,glob
---
You are an expert on Kit's LLM integration and agent system. Your job is to research and answer questions about how Kit communicates with language models and runs the agent loop.
## Key Files
- `internal/llm/provider.go` — Provider interface definition
- `internal/llm/anthropic/` — Anthropic Claude provider
- `internal/llm/openai/` — OpenAI-compatible provider (also used for Ollama)
- `internal/llm/google/` — Google Gemini provider
- `internal/agent/agent.go` — Agent loop: prompt -> LLM -> tool calls -> repeat
- `internal/agent/tools.go` — Tool registry, built-in tool definitions
- `internal/app/app.go` — App layer: RunOnce, RunOnceWithDisplay, event routing
- `pkg/kit/kit.go` — SDK: New(), configuration, extension management
## Architecture
Kit supports multiple LLM providers through the `llm.Provider` interface. The model flag format is `provider/model-name` (e.g., `anthropic/claude-sonnet-4-5`).
The agent loop in `internal/agent/` follows a standard ReAct pattern:
1. Send conversation history + system prompt to LLM
2. LLM responds with text and/or tool calls
3. Execute tool calls (MCP servers + extension tools)
4. Append tool results to conversation
5. Repeat until LLM produces a final text response (no tool calls)
Tool execution goes through MCP (Model Context Protocol) client-server architecture. Built-in MCP servers provide bash, file system, fetch, and todo tools.
The App layer (`internal/app/`) manages the lifecycle: creating the agent, routing events to the UI or CLI renderer, handling cancellation, and coordinating with extensions.
When answering, cite specific file paths and line numbers. Provide concrete code examples.
@@ -0,0 +1,27 @@
---
name: orchestrator
description: Kit Kit orchestrator system prompt template
---
You are Kit Kit, an orchestrator agent with {{EXPERT_COUNT}} domain experts: {{EXPERT_NAMES}}.
Your role is to coordinate these experts to research Kit's codebase and then synthesize their findings into working implementations.
## Available Experts
{{EXPERT_CATALOG}}
## Workflow
1. **Analyze** the user's request to identify which domains are relevant.
2. **Query** the relevant experts IN PARALLEL using the `query_experts` tool. Ask specific, targeted questions.
3. **Synthesize** the expert findings into a coherent understanding.
4. **Implement** — you are the ONLY agent that writes files. Experts are read-only researchers.
## Rules
- ALWAYS query experts before implementing. Never guess about Kit internals.
- Ask SPECIFIC questions: "How does SetWidget update the UI?" beats "Tell me about widgets."
- Query MULTIPLE experts in a single tool call when the task spans domains (they run in parallel).
- If an expert's answer is insufficient, query again with a more targeted question.
- Cite the file paths and patterns from expert responses in your implementation.
- When writing Kit extensions, remember the Yaegi closure wrapper pattern for all function fields.
@@ -0,0 +1,38 @@
---
name: tui-expert
description: Kit TUI — Bubble Tea v2 components, rendering, theming, layout
tools: read,grep,glob
---
You are an expert on Kit's terminal user interface. Your job is to research and answer questions about how Kit's TUI works.
## Key Files
- `internal/ui/model.go` — AppModel root component, View(), Update(), key handling, layout
- `internal/ui/input.go` — InputComponent wrapping textarea + autocomplete
- `internal/ui/overlay.go` — Modal overlay dialogs
- `internal/ui/prompt.go` — Interactive prompt overlays (select, confirm, input)
- `internal/ui/messages.go` — MessageRenderer for streaming messages
- `internal/ui/compact_renderer.go` — CompactRenderer for compact mode
- `internal/ui/block_renderer.go` — renderContentBlock() with functional options
- `internal/ui/theme.go` — Catppuccin-based theming (GetTheme)
- `internal/ui/commands.go` — ExtensionCommand type, slash command registry
- `internal/ui/model_test.go` — Tests with stubAppController mock
## Architecture
Kit uses Bubble Tea v2 for the TUI. The component hierarchy:
- **AppModel** — root component managing layout, key routing, and child components
- **InputComponent** — text area with autocomplete popup
- **StreamComponent** — streaming message display
- **TreeSelectorComponent** — session/model picker
- **promptOverlay** — interactive prompts (select, confirm, input)
- **overlayDialog** — modal overlay dialogs
Layout (top to bottom): header, stream, separator, widgets-above, input, widgets-below, footer, status bar.
Rendering uses lipgloss for styling with the Catppuccin Mocha color palette. Content blocks use `renderContentBlock()` with functional options for border, padding, background, and alignment.
Extension widgets integrate via callback functions (getWidgets, getHeader, getFooter) that query the extension runner through the SDK layer, keeping the UI decoupled from extensions.
When answering, cite specific file paths and line numbers. Provide concrete code examples.
+845
View File
@@ -0,0 +1,845 @@
//go:build ignore
// Kit Kit — Meta-agent that builds Kit agents
//
// A team of domain-specific research experts operate IN PARALLEL to gather
// documentation and patterns. The primary agent synthesizes their findings
// and WRITES the actual files.
//
// Each expert runs as a separate `kit` subprocess with a domain-specific
// system prompt. Experts are read-only researchers; the primary agent is
// the only writer.
//
// Commands:
//
// /experts — list available experts and their status
// /experts-grid N — set dashboard column count (default 3)
//
// Usage: kit -e examples/extensions/kit-kit.go
package main
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
"kit/ext"
)
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type expertDef struct {
Name string
Description string
Tools string
System string // system prompt body
File string
}
type expertState struct {
Def expertDef
Status string // "idle", "researching", "done", "error"
Question string
Elapsed time.Duration
LastLine string
QueryCount int
mu sync.Mutex
}
func (s *expertState) set(status, question, lastLine string, elapsed time.Duration) {
s.mu.Lock()
defer s.mu.Unlock()
if status != "" {
s.Status = status
}
if question != "" {
s.Question = question
}
if lastLine != "" {
s.LastLine = lastLine
}
if elapsed > 0 {
s.Elapsed = elapsed
}
}
func (s *expertState) snapshot() (string, string, string, time.Duration, int) {
s.mu.Lock()
defer s.mu.Unlock()
return s.Status, s.Question, s.LastLine, s.Elapsed, s.QueryCount
}
// ---------------------------------------------------------------------------
// Package-level state
// ---------------------------------------------------------------------------
var (
mu sync.Mutex
experts = map[string]*expertState{}
gridCols = 3
latestCtx ext.Context
hasCtx bool
kitBinary string // resolved path to kit executable
)
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
func displayName(name string) string {
parts := strings.Split(name, "-")
for i, w := range parts {
if len(w) > 0 {
parts[i] = strings.ToUpper(w[:1]) + w[1:]
}
}
return strings.Join(parts, " ")
}
func runeWidth(s string) int {
return len([]rune(s))
}
func truncate(s string, max int) string {
runes := []rune(s)
if len(runes) <= max {
return s
}
if max < 4 {
return string(runes[:max])
}
return string(runes[:max-3]) + "..."
}
func pad(s string, width int) string {
w := runeWidth(s)
if w >= width {
return string([]rune(s)[:width])
}
return s + strings.Repeat(" ", width-w)
}
// parseAgentFile reads a .md file with YAML-like frontmatter.
//
// ---
// name: ext-expert
// description: Extensions documentation
// tools: read,grep,glob
// ---
// System prompt body here ...
func parseAgentFile(path string) *expertDef {
raw, err := os.ReadFile(path)
if err != nil {
return nil
}
text := string(raw)
// Must start with "---\n"
if !strings.HasPrefix(text, "---\n") {
return nil
}
rest := text[4:]
idx := strings.Index(rest, "\n---\n")
if idx < 0 {
return nil
}
frontmatter := rest[:idx]
body := strings.TrimSpace(rest[idx+5:])
fm := map[string]string{}
for _, line := range strings.Split(frontmatter, "\n") {
i := strings.Index(line, ":")
if i > 0 {
fm[strings.TrimSpace(line[:i])] = strings.TrimSpace(line[i+1:])
}
}
if fm["name"] == "" {
return nil
}
return &expertDef{
Name: fm["name"],
Description: fm["description"],
Tools: fm["tools"],
System: body,
File: path,
}
}
func loadExperts(cwd string) {
mu.Lock()
defer mu.Unlock()
experts = map[string]*expertState{}
dir := filepath.Join(cwd, ".kit", "agents", "kit-kit")
entries, err := os.ReadDir(dir)
if err != nil {
return
}
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") {
continue
}
if e.Name() == "orchestrator.md" {
continue
}
def := parseAgentFile(filepath.Join(dir, e.Name()))
if def == nil {
continue
}
key := strings.ToLower(def.Name)
experts[key] = &expertState{
Def: *def,
Status: "idle",
}
}
}
func expertList() []*expertState {
mu.Lock()
defer mu.Unlock()
list := make([]*expertState, 0, len(experts))
for _, s := range experts {
list = append(list, s)
}
return list
}
func expertNames() string {
list := expertList()
names := make([]string, len(list))
for i, s := range list {
names[i] = displayName(s.Def.Name)
}
return strings.Join(names, ", ")
}
// ---------------------------------------------------------------------------
// Widget grid rendering
// ---------------------------------------------------------------------------
func renderCard(s *expertState, w int) []string {
status, question, lastLine, elapsed, queryCount := s.snapshot()
inner := w - 2 // inside the box-drawing borders
// Name line
name := truncate(displayName(s.Def.Name), inner-1)
// Status line
var icon string
switch status {
case "idle":
icon = "○"
case "researching":
icon = "◉"
case "done":
icon = "✓"
default:
icon = "✗"
}
statusText := icon + " " + status
if status != "idle" {
statusText += fmt.Sprintf(" %ds", int(elapsed.Seconds()))
}
if queryCount > 0 {
statusText += fmt.Sprintf(" (%d)", queryCount)
}
statusText = truncate(statusText, inner-1)
// Work line (question or description)
work := question
if work == "" {
work = s.Def.Description
}
work = truncate(work, inner-1)
// Last output line
last := lastLine
if last == "" {
last = "—"
}
last = truncate(last, inner-1)
// Build card (use rune width for box-drawing alignment)
topBar := "─ " + name + " "
if runeWidth(topBar) < inner {
topBar += strings.Repeat("─", inner-runeWidth(topBar))
}
return []string{
"┌" + truncate(topBar, inner) + "┐",
"│ " + pad(statusText, inner-1) + "│",
"│ " + pad(work, inner-1) + "│",
"│ " + pad(last, inner-1) + "│",
"└" + strings.Repeat("─", inner) + "┘",
}
}
func buildGrid() string {
list := expertList()
if len(list) == 0 {
return "No experts found. Add agent .md files to .kit/agents/kit-kit/"
}
cols := gridCols
if cols > len(list) {
cols = len(list)
}
// Card width: aim for ~28 chars per card
cardWidth := 28
gap := 1
var lines []string
for i := 0; i < len(list); i += cols {
end := i + cols
if end > len(list) {
end = len(list)
}
row := list[i:end]
// Render each card in this row
cards := make([][]string, len(row))
maxHeight := 0
for j, s := range row {
cards[j] = renderCard(s, cardWidth)
if len(cards[j]) > maxHeight {
maxHeight = len(cards[j])
}
}
// Merge columns line by line
for line := 0; line < maxHeight; line++ {
var parts []string
for _, card := range cards {
if line < len(card) {
parts = append(parts, card[line])
} else {
parts = append(parts, strings.Repeat(" ", cardWidth))
}
}
lines = append(lines, strings.Join(parts, strings.Repeat(" ", gap)))
}
}
return strings.Join(lines, "\n")
}
func updateWidget() {
mu.Lock()
ctx := latestCtx
ok := hasCtx
mu.Unlock()
if !ok {
return
}
ctx.SetWidget(ext.WidgetConfig{
ID: "kit-kit:grid",
Placement: ext.WidgetAbove,
Content: ext.WidgetContent{
Text: buildGrid(),
},
Style: ext.WidgetStyle{
NoBorder: true,
BorderColor: "",
},
Priority: 10,
})
}
func updateFooter() {
mu.Lock()
ctx := latestCtx
ok := hasCtx
mu.Unlock()
if !ok {
return
}
list := expertList()
active := 0
done := 0
for _, s := range list {
st, _, _, _, _ := s.snapshot()
switch st {
case "researching":
active++
case "done":
done++
}
}
var mid string
if active > 0 {
mid = fmt.Sprintf(" ◉ %d researching", active)
} else if done > 0 {
mid = fmt.Sprintf(" ✓ %d done", done)
}
text := fmt.Sprintf("%s | Kit Kit%s", ctx.Model, mid)
ctx.SetFooter(ext.HeaderFooterConfig{
Content: ext.WidgetContent{Text: text},
Style: ext.WidgetStyle{BorderColor: "#89b4fa"},
})
}
// ---------------------------------------------------------------------------
// Kit binary resolution
// ---------------------------------------------------------------------------
func findKitBinary() string {
// Try the current process executable first.
if exe, err := os.Executable(); err == nil {
if _, err := os.Stat(exe); err == nil {
return exe
}
}
// Fall back to PATH lookup.
if p, err := exec.LookPath("kit"); err == nil {
return p
}
return "kit"
}
// ---------------------------------------------------------------------------
// Expert query (subprocess)
// ---------------------------------------------------------------------------
func queryExpert(name, question string) (output string, exitCode int, elapsed time.Duration) {
mu.Lock()
state, ok := experts[strings.ToLower(name)]
mu.Unlock()
if !ok {
return fmt.Sprintf("Expert %q not found.", name), 1, 0
}
// Mark as researching.
state.mu.Lock()
if state.Status == "researching" {
state.mu.Unlock()
return fmt.Sprintf("Expert %q is already researching.", displayName(name)), 1, 0
}
state.Status = "researching"
state.Question = question
state.Elapsed = 0
state.LastLine = ""
state.QueryCount++
state.mu.Unlock()
updateWidget()
start := time.Now()
// Timer goroutine: update widget every second while researching.
done := make(chan struct{})
go func() {
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for {
select {
case <-done:
return
case <-ticker.C:
state.set("", "", "", time.Since(start))
updateWidget()
updateFooter()
}
}
}()
// Write system prompt to temp file.
tmpFile, err := os.CreateTemp("", "kit-kit-*.txt")
if err != nil {
close(done)
state.set("error", "", "temp file error: "+err.Error(), time.Since(start))
updateWidget()
updateFooter()
return "Error creating temp file: " + err.Error(), 1, time.Since(start)
}
defer os.Remove(tmpFile.Name())
if _, err := tmpFile.WriteString(state.Def.System); err != nil {
tmpFile.Close()
close(done)
state.set("error", "", "write error: "+err.Error(), time.Since(start))
updateWidget()
updateFooter()
return "Error writing system prompt: " + err.Error(), 1, time.Since(start)
}
tmpFile.Close()
// Build subprocess arguments. Don't pass --model; the subprocess
// inherits the same config/env and will use the same default.
args := []string{
"--prompt", question,
"--quiet",
"--no-session",
"--no-extensions",
"--system-prompt", tmpFile.Name(),
}
cmd := exec.Command(kitBinary, args...)
cmd.Env = os.Environ()
outBytes, err := cmd.CombinedOutput()
close(done)
elapsed = time.Since(start)
result := strings.TrimSpace(string(outBytes))
if err != nil {
// Extract a single-line summary for the card (no newlines).
errLine := result
if idx := strings.Index(errLine, "\n"); idx >= 0 {
errLine = errLine[:idx]
}
state.set("error", "", truncate(strings.TrimSpace(errLine), 80), elapsed)
updateWidget()
updateFooter()
code := 1
if exitErr, ok := err.(*exec.ExitError); ok {
code = exitErr.ExitCode()
}
return result, code, elapsed
}
// Success — extract last non-empty line for the card.
lines := strings.Split(result, "\n")
var lastLine string
for i := len(lines) - 1; i >= 0; i-- {
if strings.TrimSpace(lines[i]) != "" {
lastLine = lines[i]
break
}
}
state.set("done", "", truncate(lastLine, 60), elapsed)
updateWidget()
updateFooter()
return result, 0, elapsed
}
// ---------------------------------------------------------------------------
// Orchestrator system prompt
// ---------------------------------------------------------------------------
func buildOrchestratorPrompt(cwd string) string {
orchPath := filepath.Join(cwd, ".kit", "agents", "kit-kit", "orchestrator.md")
raw, err := os.ReadFile(orchPath)
if err != nil {
// Fallback: generate a basic orchestrator prompt.
return buildDefaultOrchestratorPrompt()
}
text := string(raw)
// Strip frontmatter if present.
if strings.HasPrefix(text, "---\n") {
if idx := strings.Index(text[4:], "\n---\n"); idx >= 0 {
text = strings.TrimSpace(text[4+idx+5:])
}
}
list := expertList()
catalog := buildExpertCatalog(list)
names := make([]string, len(list))
for i, s := range list {
names[i] = displayName(s.Def.Name)
}
text = strings.ReplaceAll(text, "{{EXPERT_COUNT}}", fmt.Sprintf("%d", len(list)))
text = strings.ReplaceAll(text, "{{EXPERT_NAMES}}", strings.Join(names, ", "))
text = strings.ReplaceAll(text, "{{EXPERT_CATALOG}}", catalog)
return text
}
func buildExpertCatalog(list []*expertState) string {
var sb strings.Builder
for _, s := range list {
fmt.Fprintf(&sb, "### %s\n", displayName(s.Def.Name))
fmt.Fprintf(&sb, "**Query as:** `%s`\n", s.Def.Name)
fmt.Fprintf(&sb, "%s\n\n", s.Def.Description)
}
return sb.String()
}
func buildDefaultOrchestratorPrompt() string {
list := expertList()
names := make([]string, len(list))
for i, s := range list {
names[i] = displayName(s.Def.Name)
}
catalog := buildExpertCatalog(list)
return fmt.Sprintf(`You are Kit Kit, an orchestrator agent with %d domain experts: %s.
Use the query_experts tool to consult experts IN PARALLEL before writing code.
Always query multiple experts at once when the task spans multiple domains.
## Available Experts
%s
## Workflow
1. Analyze the user's request to identify which domains are relevant.
2. Use query_experts to ask specific questions of the relevant experts.
3. Synthesize the expert findings into a coherent implementation.
4. Write the actual code/files — you are the only agent that writes.
## Rules
- ALWAYS query experts before implementing. Never guess.
- Ask SPECIFIC questions. "How does X work?" is better than "Tell me about X".
- Query multiple experts in a single call when possible (they run in parallel).
- If an expert returns insufficient info, query again with a more specific question.
`, len(list), strings.Join(names, ", "), catalog)
}
// ---------------------------------------------------------------------------
// Init
// ---------------------------------------------------------------------------
func Init(api ext.API) {
kitBinary = findKitBinary()
// ── Session Start: load experts, show grid ──
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
mu.Lock()
latestCtx = ctx
hasCtx = true
mu.Unlock()
loadExperts(ctx.CWD)
updateWidget()
updateFooter()
names := expertNames()
n := len(expertList())
if n > 0 {
ctx.PrintInfo(fmt.Sprintf(
"Kit Kit loaded — %d experts: %s\n\n"+
"/experts List experts and status\n"+
"/experts-grid N Set grid columns (1-5)\n\n"+
"Ask me to build any Kit component!",
n, names))
} else {
ctx.PrintInfo(
"Kit Kit loaded — no experts found.\n\n" +
"Add agent .md files to .kit/agents/kit-kit/ to get started.\n" +
"See examples/extensions/kit-kit-agents/ for samples.")
}
})
// ── Before Agent Start: inject orchestrator system prompt ──
api.OnBeforeAgentStart(func(_ ext.BeforeAgentStartEvent, ctx ext.Context) *ext.BeforeAgentStartResult {
mu.Lock()
latestCtx = ctx
mu.Unlock()
prompt := buildOrchestratorPrompt(ctx.CWD)
return &ext.BeforeAgentStartResult{SystemPrompt: &prompt}
})
// ── Agent End: update footer ──
api.OnAgentEnd(func(_ ext.AgentEndEvent, ctx ext.Context) {
mu.Lock()
latestCtx = ctx
mu.Unlock()
updateFooter()
})
// ── Session Shutdown: cleanup ──
api.OnSessionShutdown(func(_ ext.SessionShutdownEvent, ctx ext.Context) {
ctx.RemoveWidget("kit-kit:grid")
ctx.RemoveFooter()
})
// ── Tool: query_experts ──
api.RegisterTool(ext.ToolDef{
Name: "query_experts",
Description: `Query one or more Kit domain experts IN PARALLEL. All experts run simultaneously as concurrent subprocesses.
Pass an array of queries — each with an expert name and a specific question. All experts start at the same time and their results are returned together.
Available experts are loaded from .kit/agents/kit-kit/*.md at session start. The default set includes:
- ext-expert: Kit extensions — tools, events, commands, widgets, editor interceptors
- tui-expert: Kit TUI — Bubble Tea v2 components, rendering, theming, layout
- llm-expert: Kit LLM system — providers, streaming, agent loop, tool execution
Ask specific questions about what you need to BUILD. Each expert will return documentation excerpts, code patterns, and implementation guidance.`,
Parameters: `{
"type": "object",
"properties": {
"queries": {
"type": "array",
"description": "Array of expert queries to run in parallel",
"items": {
"type": "object",
"properties": {
"expert": {
"type": "string",
"description": "Expert name (e.g. ext-expert, tui-expert, llm-expert)"
},
"question": {
"type": "string",
"description": "Specific question about what you need to build"
}
},
"required": ["expert", "question"]
}
}
},
"required": ["queries"]
}`,
Execute: func(input string) (string, error) {
var params struct {
Queries []struct {
Expert string `json:"expert"`
Question string `json:"question"`
} `json:"queries"`
}
if err := json.Unmarshal([]byte(input), &params); err != nil {
return "", fmt.Errorf("invalid parameters: %w", err)
}
if len(params.Queries) == 0 {
return "No queries provided.", nil
}
// Launch all experts in parallel.
type result struct {
Expert string
Question string
Output string
ExitCode int
Elapsed time.Duration
}
results := make([]result, len(params.Queries))
var wg sync.WaitGroup
for i, q := range params.Queries {
wg.Add(1)
go func(idx int, expert, question string) {
defer wg.Done()
out, code, elapsed := queryExpert(expert, question)
results[idx] = result{
Expert: expert,
Question: question,
Output: out,
ExitCode: code,
Elapsed: elapsed,
}
}(i, q.Expert, q.Question)
}
wg.Wait()
// Build combined response.
var sb strings.Builder
for _, r := range results {
icon := "✓"
if r.ExitCode != 0 {
icon = "✗"
}
fmt.Fprintf(&sb, "## [%s] %s (%ds)\n\n",
icon, displayName(r.Expert), int(r.Elapsed.Seconds()))
out := r.Output
if len(out) > 12000 {
out = out[:12000] + "\n\n... [truncated — ask follow-up for more]"
}
sb.WriteString(out)
sb.WriteString("\n\n---\n\n")
}
return sb.String(), nil
},
})
// ── Tool Renderer: query_experts ──
api.RegisterToolRenderer(ext.ToolRenderConfig{
ToolName: "query_experts",
DisplayName: "Query Experts",
BorderColor: "#89b4fa",
RenderHeader: func(toolArgs string, width int) string {
var args struct {
Queries []struct {
Expert string `json:"expert"`
} `json:"queries"`
}
if err := json.Unmarshal([]byte(toolArgs), &args); err != nil {
return ""
}
names := make([]string, len(args.Queries))
for i, q := range args.Queries {
names[i] = displayName(q.Expert)
}
header := fmt.Sprintf("%d experts in parallel: %s",
len(args.Queries), strings.Join(names, ", "))
return truncate(header, width)
},
RenderBody: func(toolResult string, isError bool, width int) string {
if isError {
return "" // fall back to default
}
// Show compact summary: extract ## headers with status
var lines []string
for _, line := range strings.Split(toolResult, "\n") {
if strings.HasPrefix(line, "## [") {
lines = append(lines, line[3:]) // strip "## "
}
}
if len(lines) == 0 {
return ""
}
return strings.Join(lines, " · ")
},
})
// ── Command: /experts ──
api.RegisterCommand(ext.CommandDef{
Name: "experts",
Description: "List available Kit Kit experts and their status",
Execute: func(args string, ctx ext.Context) (string, error) {
mu.Lock()
latestCtx = ctx
mu.Unlock()
list := expertList()
if len(list) == 0 {
return "No experts loaded. Add agent .md files to .kit/agents/kit-kit/", nil
}
var sb strings.Builder
for _, s := range list {
status, _, _, _, qc := s.snapshot()
fmt.Fprintf(&sb, "%s (%s, queries: %d): %s\n",
displayName(s.Def.Name), status, qc, s.Def.Description)
}
return sb.String(), nil
},
})
// ── Command: /experts-grid ──
api.RegisterCommand(ext.CommandDef{
Name: "experts-grid",
Description: "Set expert grid columns: /experts-grid <1-5>",
Execute: func(args string, ctx ext.Context) (string, error) {
mu.Lock()
latestCtx = ctx
mu.Unlock()
args = strings.TrimSpace(args)
n := 0
if _, err := fmt.Sscanf(args, "%d", &n); err != nil || n < 1 || n > 5 {
return "Usage: /experts-grid <1-5>", nil
}
mu.Lock()
gridCols = n
mu.Unlock()
updateWidget()
return fmt.Sprintf("Grid set to %d columns.", n), nil
},
})
}
+2 -8
View File
@@ -159,10 +159,7 @@ func (o *overlayDialog) Render() string {
}
// Inner width accounts for border (2) + horizontal padding (2 left + 1 right).
innerWidth := dw - 5
if innerWidth < 10 {
innerWidth = 10
}
innerWidth := max(dw-5, 10)
// Render body text (potentially as markdown).
bodyText := o.content
@@ -184,10 +181,7 @@ func (o *overlayDialog) Render() string {
chromeLines += 2 // separator line + action bar
}
maxBodyLines := mh - chromeLines
if maxBodyLines < 1 {
maxBodyLines = 1
}
maxBodyLines := max(mh-chromeLines, 1)
scrollable := len(bodyLines) > maxBodyLines
if scrollable {
+9
View File
@@ -127,6 +127,15 @@ func (m *Kit) SetExtensionContext(ctx extensions.Context) {
}
}
// GetExtensionContext returns the current extension runtime context.
// Returns a zero Context if extensions are disabled.
func (m *Kit) GetExtensionContext() extensions.Context {
if m.extRunner != nil {
return m.extRunner.GetContext()
}
return extensions.Context{}
}
// EmitSessionStart fires the SessionStart event for extensions.
// No-op if extensions are disabled or no handlers are registered.
func (m *Kit) EmitSessionStart() {