Compare commits

...

10 Commits

Author SHA1 Message Date
Ed Zynda 93905d4d77 fix(acp): remove startup message from stdio output 2026-03-15 11:38:31 +03:00
Ed Zynda 7268ccdf4d perf(ui): throttle stream rendering with chunk coalescing and render cache
Streaming chunks now accumulate in a pending buffer and flush on a 16ms
tick (~60fps) instead of triggering a full markdown re-render on every
chunk. Between flushes, View() returns a cached string — no markdown
parsing, no lipgloss styling, no terminal escape sequence churn. This is
especially impactful for inline rendering (no alt screen) where each
frame requires cursor repositioning across the full view height.
2026-03-15 11:36:04 +03:00
Ed Zynda 9f59fa42dc fix: resolve golangci-lint issues
- Use strings.Cut instead of strings.Index (modernize)
- Remove unused session registry methods (load, remove)
2026-03-14 17:30:36 +03:00
Ed Zynda 8af7ca8455 refactor(ui): simplify tool names in spinner display
Show 'Subagent' instead of 'spawn_subagent' and remove 'Executing' prefix
for cleaner parallel tool status display.
2026-03-14 17:25:40 +03:00
Ed Zynda 424847f0db feat: enable parallel tool execution with multi-tool status display
- Mark read-only core tools as parallel-safe (read, grep, find, ls)
- Mark spawn_subagent as parallel-safe for concurrent task delegation
- Update UI to track multiple active tools during parallel execution
- Display 'Running: tool1, tool2, ...' in spinner for concurrent tools
- Add test for parallel tool execution scenarios

Fantasy already supports parallel execution via ToolInfo.Parallel field.
Tools marked parallel run concurrently (up to 5 at a time).
2026-03-14 17:24:20 +03:00
Ed Zynda 4c126ca41b feat(ui): show clean summary for subagent results instead of raw output
- Add custom renderer for spawn_subagent tool showing status + 3-line preview
- Pass toolArgs through ToolExecutionEvent to show task in spinner
- Display 'Subagent: <task>' during execution instead of generic message
- Compact mode shows concise one-line status summary
2026-03-14 17:04:50 +03:00
Ed Zynda 4bdc4f75cc chore: remove openspec directory 2026-03-09 23:10:15 +03:00
Ed Zynda bbd8975ca0 feat: add first-class subagent support for task delegation
Implement 4-phase subagent system enabling LLM and extensions to spawn,
manage, and orchestrate child Kit instances for parallel task execution.

- Phase 1: SDK API with SpawnSubagent() for extensions
- Phase 2: spawn_subagent core tool for LLM usage
- Phase 3: Session hierarchy with ParentSessionID tracking
- Phase 4: Provider pooling for concurrent model access

New files:
- internal/extensions/subagent.go: SpawnSubagent implementation
- internal/core/subagent.go: Core tool definition
- internal/models/pool.go: Provider pool for concurrency
- examples/extensions/subagent-test.go: Test extension
- openspec/subagent-support.md: Design specification
2026-03-09 23:07:27 +03:00
Ed Zynda e613a07773 feat: add ACP server mode (kit acp)
Implement Agent Client Protocol server allowing ACP-compatible clients
(e.g. OpenCode) to drive Kit as a remote coding agent over stdio.

- internal/acpserver/agent.go: acp.Agent implementation bridging Kit's
  LLM execution, tool system, and event bus to ACP session updates
- internal/acpserver/session.go: session registry mapping ACP sessions
  to persisted Kit JSONL tree sessions
- cmd/acp.go: cobra subcommand wiring stdio JSON-RPC connection
- Add acp-go-sdk dependency, update README with ACP docs
2026-03-09 21:41:10 +03:00
Ed Zynda 1d3b4f8d56 feat: add skill subcommand to install kit-extensions skill via skills.sh 2026-03-09 14:24:09 +03:00
31 changed files with 2598 additions and 33 deletions
+20
View File
@@ -24,6 +24,7 @@ A powerful, extensible AI coding agent CLI with multi-provider support, built-in
- **Interactive TUI**: Rich terminal interface powered by Bubble Tea with streaming, syntax highlighting, and custom rendering
- **Session Management**: Tree-based conversation history with branching support
- **Non-Interactive Mode**: Script-friendly positional args with JSON output
- **ACP Server**: Run Kit as an [Agent Client Protocol](https://agentclientprotocol.com) agent over stdio
- **Go SDK**: Embed Kit in your own applications
## Installation
@@ -82,6 +83,20 @@ kit "Run tests" --quiet
kit "Quick question" --no-session
```
### ACP Server Mode
Kit can run as an [ACP (Agent Client Protocol)](https://agentclientprotocol.com) agent server, enabling ACP-compatible clients (such as [OpenCode](https://github.com/sst/opencode)) to drive Kit as a remote coding agent over stdio.
```bash
# Start Kit as an ACP server (communicates via JSON-RPC 2.0 on stdin/stdout)
kit acp
# With debug logging to stderr
kit acp --debug
```
The ACP server exposes Kit's full capabilities — LLM execution, tool calls (bash, read, write, edit, grep, etc.), and session persistence — over the standard ACP protocol. Sessions are persisted to Kit's normal JSONL session files, so they can be resumed later.
## Configuration
Kit looks for configuration in the following locations (in order of priority):
@@ -188,6 +203,10 @@ kit update-models # Update local model database from models.dev
kit extensions list # List discovered extensions
kit extensions validate # Validate extension files
kit extensions init # Generate example extension template
# ACP server
kit acp # Start as ACP agent (stdio JSON-RPC)
kit acp --debug # With debug logging to stderr
```
## Extension System
@@ -458,6 +477,7 @@ internal/extensions/ - Yaegi extension system
internal/core/ - Built-in tools
internal/tools/ - MCP tool integration
internal/config/ - Configuration management
internal/acpserver/ - ACP (Agent Client Protocol) server
internal/session/ - Session persistence
internal/models/ - Provider and model management
examples/extensions/ - Example extension files
+13 -1
View File
@@ -64,8 +64,20 @@
"name": "yaegi",
"url": "https://github.com/traefik/yaegi",
"branch": "master"
},
{
"type": "git",
"name": "acp-go-sdk",
"url": "https://github.com/coder/acp-go-sdk",
"branch": "main"
},
{
"type": "git",
"name": "opencode",
"url": "https://github.com/anomalyco/opencode",
"branch": "dev"
}
],
"model": "claude-haiku-4-5",
"provider": "opencode"
}
}
+65
View File
@@ -0,0 +1,65 @@
package cmd
import (
"fmt"
"log/slog"
"os"
"os/signal"
"syscall"
acp "github.com/coder/acp-go-sdk"
"github.com/mark3labs/kit/internal/acpserver"
"github.com/spf13/cobra"
)
var acpCmd = &cobra.Command{
Use: "acp",
Short: "Start Kit as an ACP agent server",
Long: `Start Kit as an ACP (Agent Client Protocol) agent server.
Communicates over stdio (stdin/stdout) using JSON-RPC 2.0 with
newline-delimited JSON, compatible with OpenCode and other ACP clients.
The server exposes Kit's LLM execution, tool system, and session
management via the Agent Client Protocol. Sessions are persisted
to Kit's standard JSONL session files.`,
RunE: runACP,
}
func init() {
rootCmd.AddCommand(acpCmd)
}
func runACP(cmd *cobra.Command, _ []string) error {
// Create the ACP agent implementation.
agent := acpserver.NewAgent()
defer agent.Close()
// Create the stdio connection. The SDK reads JSON-RPC from stdin and
// writes responses to stdout.
conn := acp.NewAgentSideConnection(agent, os.Stdout, os.Stdin)
// Wire the connection back to the agent so it can send session updates.
agent.SetAgentConnection(conn)
// Enable debug logging to stderr if requested.
if debugMode {
conn.SetLogger(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
Level: slog.LevelDebug,
})))
}
// Wait for either the client to disconnect or a signal.
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
select {
case <-conn.Done():
fmt.Fprintln(os.Stderr, "kit: ACP client disconnected")
case sig := <-sigCh:
fmt.Fprintf(os.Stderr, "kit: received %s, shutting down\n", sig)
}
return nil
}
+3
View File
@@ -924,6 +924,9 @@ func runNormalMode(ctx context.Context) error {
Index: resp.Index,
}
},
SpawnSubagent: func(config extensions.SubagentConfig) (*extensions.SubagentHandle, *extensions.SubagentResult, error) {
return extensions.SpawnSubagent(config)
},
})
kitInstance.EmitSessionStart()
}
+58
View File
@@ -0,0 +1,58 @@
package cmd
import (
"fmt"
"os"
"os/exec"
"github.com/spf13/cobra"
)
// skillCmd installs the kit-extensions skill via the skills.sh CLI (npx skills).
// This teaches AI agents how to create Kit extensions with full knowledge of
// the extension API, lifecycle events, widgets, tools, commands, and Yaegi constraints.
var skillCmd = &cobra.Command{
Use: "skill",
Short: "Install the Kit extensions skill via skills.sh",
Long: `Install the kit-extensions skill that teaches AI agents how to create
Kit extensions. Uses the skills.sh CLI (npx skills) to install the skill
from the Kit repository.
The skill provides comprehensive documentation of Kit's extension API including
lifecycle events, custom tools, slash commands, widgets, editor interceptors,
tool renderers, and critical Yaegi interpreter constraints.
Example:
kit skill`,
RunE: runSkill,
}
func init() {
rootCmd.AddCommand(skillCmd)
}
func runSkill(_ *cobra.Command, _ []string) error {
npx, err := exec.LookPath("npx")
if err != nil {
return fmt.Errorf("npx not found in PATH — install Node.js to use this command: %w", err)
}
args := []string{
"skills",
"add",
"mark3labs/kit",
"--skill",
"kit-extensions",
}
cmd := exec.Command(npx, args...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("skills install failed: %w", err)
}
return nil
}
+164
View File
@@ -0,0 +1,164 @@
//go:build ignore
// Subagent Test Extension — Tests the new first-class subagent API
//
// Commands:
//
// /subtest <task> — spawn a blocking subagent and print result
// /subbg <task> — spawn a background subagent with live output
//
// Usage: kit -e examples/extensions/subagent-test.go
package main
import (
"fmt"
"strings"
"sync"
"time"
"kit/ext"
)
var (
mu sync.Mutex
latestCtx ext.Context
hasCtx bool
)
func Init(api ext.API) {
// Keep context fresh
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
mu.Lock()
latestCtx = ctx
hasCtx = true
mu.Unlock()
ctx.PrintInfo(
"Subagent Test Extension loaded\n\n" +
"/subtest <task> Spawn blocking subagent\n" +
"/subbg <task> Spawn background subagent\n\n" +
"The LLM can also use the spawn_subagent tool.")
})
api.OnAgentEnd(func(_ ext.AgentEndEvent, ctx ext.Context) {
mu.Lock()
latestCtx = ctx
mu.Unlock()
})
// Command: /subtest <task> — blocking subagent
api.RegisterCommand(ext.CommandDef{
Name: "subtest",
Description: "Spawn a blocking subagent: /subtest <task>",
Execute: func(args string, ctx ext.Context) (string, error) {
mu.Lock()
latestCtx = ctx
hasCtx = true
mu.Unlock()
task := strings.TrimSpace(args)
if task == "" {
return "Usage: /subtest <task>", nil
}
ctx.PrintInfo(fmt.Sprintf("Spawning blocking subagent for: %s", task))
start := time.Now()
_, result, err := ctx.SpawnSubagent(ext.SubagentConfig{
Prompt: task,
Timeout: 2 * time.Minute,
Blocking: true,
})
elapsed := time.Since(start)
if err != nil {
return fmt.Sprintf("Spawn error: %v", err), nil
}
if result == nil {
return "No result returned", nil
}
if result.Error != nil {
return fmt.Sprintf("Subagent failed (exit %d) after %ds: %v\n\nPartial output:\n%s",
result.ExitCode, int(elapsed.Seconds()), result.Error, truncate(result.Response, 2000)), nil
}
response := fmt.Sprintf("Subagent completed in %ds", int(elapsed.Seconds()))
if result.Usage != nil {
response += fmt.Sprintf(" (tokens: %d in / %d out)", result.Usage.InputTokens, result.Usage.OutputTokens)
}
response += fmt.Sprintf("\n\nResult:\n%s", truncate(result.Response, 4000))
return response, nil
},
})
// Command: /subbg <task> — background subagent with callbacks
api.RegisterCommand(ext.CommandDef{
Name: "subbg",
Description: "Spawn a background subagent: /subbg <task>",
Execute: func(args string, ctx ext.Context) (string, error) {
mu.Lock()
latestCtx = ctx
hasCtx = true
mu.Unlock()
task := strings.TrimSpace(args)
if task == "" {
return "Usage: /subbg <task>", nil
}
ctx.PrintInfo(fmt.Sprintf("Spawning background subagent for: %s", task))
start := time.Now()
handle, _, err := ctx.SpawnSubagent(ext.SubagentConfig{
Prompt: task,
Timeout: 2 * time.Minute,
OnOutput: func(chunk string) {
// Live output - could update a widget here
fmt.Print(chunk)
},
OnComplete: func(result ext.SubagentResult) {
elapsed := time.Since(start)
mu.Lock()
c := latestCtx
ok := hasCtx
mu.Unlock()
if !ok {
return
}
if result.Error != nil {
c.SendMessage(fmt.Sprintf("Background subagent failed after %ds: %v",
int(elapsed.Seconds()), result.Error))
return
}
msg := fmt.Sprintf("Background subagent completed in %ds", int(elapsed.Seconds()))
if result.Usage != nil {
msg += fmt.Sprintf(" (tokens: %d in / %d out)", result.Usage.InputTokens, result.Usage.OutputTokens)
}
msg += fmt.Sprintf("\n\nResult:\n%s", truncate(result.Response, 4000))
c.SendMessage(msg)
},
})
if err != nil {
return fmt.Sprintf("Spawn error: %v", err), nil
}
return fmt.Sprintf("Background subagent spawned (ID: %s). Results will be delivered when complete.", handle.ID), nil
},
})
}
func truncate(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max] + "\n\n... [truncated]"
}
+1
View File
@@ -59,6 +59,7 @@ require (
github.com/charmbracelet/x/windows v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/coder/acp-go-sdk v0.6.3 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
+2
View File
@@ -114,6 +114,8 @@ github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJ
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik=
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4=
github.com/coder/acp-go-sdk v0.6.3 h1:LsXQytehdjKIYJnoVWON/nf7mqbiarnyuyE3rrjBsXQ=
github.com/coder/acp-go-sdk v0.6.3/go.mod h1:yKzM/3R9uELp4+nBAwwtkS0aN1FOFjo11CNPy37yFko=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+253
View File
@@ -0,0 +1,253 @@
// Package acpserver implements a Kit-backed ACP (Agent Client Protocol) agent.
//
// It bridges Kit's LLM execution, tool system, and session management to the
// ACP protocol over stdio, allowing ACP clients (such as OpenCode) to drive
// Kit as a remote coding agent.
package acpserver
import (
"context"
"encoding/json"
"fmt"
"sync/atomic"
"github.com/charmbracelet/log"
acp "github.com/coder/acp-go-sdk"
kit "github.com/mark3labs/kit/pkg/kit"
)
// Version is injected at build time; fallback to "dev".
var Version = "dev"
// Agent implements the acp.Agent interface, delegating to Kit for LLM
// execution, tool calls, and session management.
type Agent struct {
conn *acp.AgentSideConnection
registry *sessionRegistry
// toolCallCounter provides unique IDs for tool calls within a turn.
toolCallCounter atomic.Int64
}
// NewAgent creates a new ACP agent backed by Kit.
func NewAgent() *Agent {
return &Agent{
registry: newSessionRegistry(),
}
}
// SetAgentConnection stores the connection so the agent can send session
// updates (streaming, tool calls, etc.) back to the ACP client. This follows
// the AgentConnAware duck-typing pattern from the SDK.
func (a *Agent) SetAgentConnection(conn *acp.AgentSideConnection) {
a.conn = conn
}
// Close shuts down all active sessions.
func (a *Agent) Close() {
a.registry.closeAll()
}
// ---------------------------------------------------------------------------
// acp.Agent interface implementation
// ---------------------------------------------------------------------------
// Authenticate handles authentication requests. Kit doesn't require auth for
// local stdio usage, so this is a no-op.
func (a *Agent) Authenticate(_ context.Context, _ acp.AuthenticateRequest) (acp.AuthenticateResponse, error) {
return acp.AuthenticateResponse{}, nil
}
// Initialize negotiates capabilities with the ACP client.
func (a *Agent) Initialize(_ context.Context, params acp.InitializeRequest) (acp.InitializeResponse, error) {
log.Debug("acp: initialize", "protocol_version", params.ProtocolVersion)
return acp.InitializeResponse{
ProtocolVersion: acp.ProtocolVersion(1),
AgentCapabilities: acp.AgentCapabilities{
LoadSession: true,
PromptCapabilities: acp.PromptCapabilities{
EmbeddedContext: true,
Image: true,
},
},
AgentInfo: &acp.Implementation{
Name: "Kit",
Version: Version,
},
}, nil
}
// NewSession creates a new Kit session for the given working directory.
func (a *Agent) NewSession(ctx context.Context, params acp.NewSessionRequest) (acp.NewSessionResponse, error) {
cwd := params.Cwd
if cwd == "" {
return acp.NewSessionResponse{}, acp.NewInvalidParams("cwd is required")
}
log.Debug("acp: new_session", "cwd", cwd)
sess, err := a.registry.create(ctx, cwd)
if err != nil {
return acp.NewSessionResponse{}, fmt.Errorf("create session: %w", err)
}
return acp.NewSessionResponse{
SessionId: acp.SessionId(sess.sessionID),
}, nil
}
// Prompt handles the main agent execution. It subscribes to Kit's event bus,
// converts events to ACP session updates, and runs the prompt through Kit's
// full turn lifecycle (hooks, LLM, tool calls, persistence).
func (a *Agent) Prompt(ctx context.Context, params acp.PromptRequest) (acp.PromptResponse, error) {
sessionID := string(params.SessionId)
sess, ok := a.registry.get(sessionID)
if !ok {
return acp.PromptResponse{}, acp.NewInvalidParams(
fmt.Sprintf("session not found: %s", sessionID),
)
}
// Extract text from prompt content blocks.
promptText := extractPromptText(params.Prompt)
if promptText == "" {
return acp.PromptResponse{}, acp.NewInvalidParams("empty prompt")
}
log.Debug("acp: prompt", "session", sessionID, "prompt_len", len(promptText))
// Create a cancellable context for this prompt turn.
promptCtx, cancel := context.WithCancel(ctx)
sess.setCancel(cancel)
defer sess.clearCancel()
// Subscribe to Kit events and stream them as ACP session updates.
unsub := a.subscribeEvents(promptCtx, sess.kit, params.SessionId)
defer unsub()
// Run the prompt through Kit's full turn lifecycle.
_, err := sess.kit.PromptResult(promptCtx, promptText)
if err != nil {
if promptCtx.Err() != nil {
return acp.PromptResponse{
StopReason: acp.StopReasonCancelled,
}, nil
}
return acp.PromptResponse{}, fmt.Errorf("prompt failed: %w", err)
}
return acp.PromptResponse{
StopReason: acp.StopReasonEndTurn,
}, nil
}
// Cancel cancels the ongoing prompt for a session.
func (a *Agent) Cancel(_ context.Context, params acp.CancelNotification) error {
sessionID := string(params.SessionId)
sess, ok := a.registry.get(sessionID)
if !ok {
return nil // No-op if session doesn't exist.
}
log.Debug("acp: cancel", "session", sessionID)
sess.cancelPrompt()
return nil
}
// SetSessionMode is a no-op for now — Kit doesn't have built-in session modes.
func (a *Agent) SetSessionMode(_ context.Context, _ acp.SetSessionModeRequest) (acp.SetSessionModeResponse, error) {
return acp.SetSessionModeResponse{}, nil
}
// ---------------------------------------------------------------------------
// Event streaming: Kit events → ACP SessionUpdate notifications
// ---------------------------------------------------------------------------
// subscribeEvents subscribes to Kit's event bus and forwards events as ACP
// session update notifications to the client.
func (a *Agent) subscribeEvents(ctx context.Context, k *kit.Kit, sessionID acp.SessionId) func() {
return k.Subscribe(func(e kit.Event) {
// Don't send updates after the context is cancelled.
if ctx.Err() != nil {
return
}
var update *acp.SessionUpdate
switch ev := e.(type) {
case kit.MessageUpdateEvent:
u := acp.UpdateAgentMessageText(ev.Chunk)
update = &u
case kit.ReasoningDeltaEvent:
u := acp.UpdateAgentThoughtText(ev.Delta)
update = &u
case kit.ToolCallEvent:
tcID := acp.ToolCallId(fmt.Sprintf("tc_%d", a.toolCallCounter.Add(1)))
u := acp.StartToolCall(tcID, ev.ToolName,
acp.WithStartStatus(acp.ToolCallStatusInProgress),
acp.WithStartRawInput(parseToolArgs(ev.ToolArgs)),
)
update = &u
case kit.ToolResultEvent:
tcID := acp.ToolCallId(fmt.Sprintf("tc_%d", a.toolCallCounter.Load()))
status := acp.ToolCallStatusCompleted
if ev.IsError {
status = acp.ToolCallStatusFailed
}
u := acp.UpdateToolCall(tcID,
acp.WithUpdateStatus(status),
acp.WithUpdateContent([]acp.ToolCallContent{
acp.ToolContent(acp.TextBlock(ev.Result)),
}),
)
update = &u
case kit.ToolCallContentEvent:
u := acp.UpdateAgentMessageText(ev.Content)
update = &u
}
if update != nil {
_ = a.conn.SessionUpdate(ctx, acp.SessionNotification{
SessionId: sessionID,
Update: *update,
})
}
})
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
// extractPromptText extracts the concatenated text content from ACP content
// blocks. Non-text blocks are ignored for now.
func extractPromptText(blocks []acp.ContentBlock) string {
var text string
for _, block := range blocks {
if block.Text != nil {
if text != "" {
text += "\n"
}
text += block.Text.Text
}
}
return text
}
// parseToolArgs attempts to parse a JSON tool args string into a map for
// structured display. Falls back to a simple string wrapper.
func parseToolArgs(args string) any {
if args == "" {
return nil
}
var m map[string]any
if err := json.Unmarshal([]byte(args), &m); err == nil {
return m
}
return map[string]any{"input": args}
}
+106
View File
@@ -0,0 +1,106 @@
package acpserver
import (
"context"
"fmt"
"sync"
kit "github.com/mark3labs/kit/pkg/kit"
)
// acpSession maps an ACP session to a Kit instance with its own tree session.
type acpSession struct {
kit *kit.Kit
cancelFn context.CancelFunc // cancels the current prompt
cancelMu sync.Mutex
cwd string
sessionID string // Kit-generated session ID (from JSONL header)
}
// sessionRegistry is a thread-safe registry of ACP session ID → Kit sessions.
type sessionRegistry struct {
mu sync.RWMutex
sessions map[string]*acpSession // ACP session ID → session
}
func newSessionRegistry() *sessionRegistry {
return &sessionRegistry{
sessions: make(map[string]*acpSession),
}
}
// create creates a new Kit instance with a persisted tree session for the
// given working directory. The Kit-generated session ID is used as the ACP
// session ID so the mapping is 1:1.
func (r *sessionRegistry) create(ctx context.Context, cwd string) (*acpSession, error) {
kitInstance, err := kit.New(ctx, &kit.Options{
SessionDir: cwd,
Quiet: true,
Streaming: true,
})
if err != nil {
return nil, fmt.Errorf("create kit instance: %w", err)
}
sessionID := kitInstance.GetSessionID()
if sessionID == "" {
_ = kitInstance.Close()
return nil, fmt.Errorf("kit instance has no session ID")
}
sess := &acpSession{
kit: kitInstance,
cwd: cwd,
sessionID: sessionID,
}
r.mu.Lock()
r.sessions[sessionID] = sess
r.mu.Unlock()
return sess, nil
}
// get retrieves a session by ACP session ID.
func (r *sessionRegistry) get(sessionID string) (*acpSession, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
s, ok := r.sessions[sessionID]
return s, ok
}
// closeAll closes all sessions.
func (r *sessionRegistry) closeAll() {
r.mu.Lock()
defer r.mu.Unlock()
for id, sess := range r.sessions {
if sess.kit != nil {
_ = sess.kit.Close()
}
delete(r.sessions, id)
}
}
// cancelPrompt cancels the current prompt for a session, if any.
func (s *acpSession) cancelPrompt() {
s.cancelMu.Lock()
defer s.cancelMu.Unlock()
if s.cancelFn != nil {
s.cancelFn()
s.cancelFn = nil
}
}
// setCancel stores a cancel function for the current prompt.
func (s *acpSession) setCancel(cancel context.CancelFunc) {
s.cancelMu.Lock()
defer s.cancelMu.Unlock()
s.cancelFn = cancel
}
// clearCancel clears the stored cancel function (called when prompt completes).
func (s *acpSession) clearCancel() {
s.cancelMu.Lock()
defer s.cancelMu.Unlock()
s.cancelFn = nil
}
+3 -3
View File
@@ -44,7 +44,7 @@ type AgentConfig struct {
type ToolCallHandler func(toolName, toolArgs string)
// ToolExecutionHandler is a function type for handling tool execution start/end events.
type ToolExecutionHandler func(toolName string, isStarting bool)
type ToolExecutionHandler func(toolName, toolArgs string, isStarting bool)
// ToolResultHandler is a function type for handling tool results.
type ToolResultHandler func(toolName, toolArgs, result string, isError bool)
@@ -288,7 +288,7 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
// Notify tool execution starting
if onToolExecution != nil {
onToolExecution(tc.ToolName, true)
onToolExecution(tc.ToolName, tc.Input, true)
}
return nil
@@ -301,7 +301,7 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
}
// Notify tool execution finished
if onToolExecution != nil {
onToolExecution(tr.ToolName, false)
onToolExecution(tr.ToolName, currentToolArgs, false)
}
if onToolResult != nil {
+1 -1
View File
@@ -534,7 +534,7 @@ func (a *App) subscribeSDKEvents(sendFn func(tea.Msg)) func() {
case kit.ToolCallEvent:
sendFn(ToolCallStartedEvent{ToolName: ev.ToolName, ToolArgs: ev.ToolArgs})
case kit.ToolExecutionStartEvent:
sendFn(ToolExecutionEvent{ToolName: ev.ToolName, IsStarting: true})
sendFn(ToolExecutionEvent{ToolName: ev.ToolName, ToolArgs: ev.ToolArgs, IsStarting: true})
case kit.ToolExecutionEndEvent:
sendFn(ToolExecutionEvent{ToolName: ev.ToolName, IsStarting: false})
case kit.ToolResultEvent:
+2
View File
@@ -30,6 +30,8 @@ type ToolCallStartedEvent struct {
type ToolExecutionEvent struct {
// ToolName is the name of the tool being executed.
ToolName string
// ToolArgs is the JSON-encoded arguments for the tool call (only set when IsStarting is true).
ToolArgs string
// IsStarting is true when execution is beginning, false when it is complete.
IsStarting bool
}
+1
View File
@@ -39,6 +39,7 @@ func NewFindTool(opts ...ToolOption) fantasy.AgentTool {
},
},
Required: []string{"pattern"},
Parallel: true,
},
handler: func(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
return executeFind(ctx, call, cfg.WorkDir)
+1
View File
@@ -59,6 +59,7 @@ func NewGrepTool(opts ...ToolOption) fantasy.AgentTool {
},
},
Required: []string{"pattern"},
Parallel: true,
},
handler: func(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
return executeGrep(ctx, call, cfg.WorkDir)
+1
View File
@@ -33,6 +33,7 @@ func NewLsTool(opts ...ToolOption) fantasy.AgentTool {
},
},
Required: []string{},
Parallel: true,
},
handler: func(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
return executeLs(ctx, call, cfg.WorkDir)
+1
View File
@@ -38,6 +38,7 @@ func NewReadTool(opts ...ToolOption) fantasy.AgentTool {
},
},
Required: []string{"path"},
Parallel: true,
},
handler: func(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
return executeRead(ctx, call, cfg.WorkDir)
+121
View File
@@ -0,0 +1,121 @@
package core
import (
"context"
"fmt"
"time"
"charm.land/fantasy"
"github.com/mark3labs/kit/internal/extensions"
)
const defaultSubagentTimeout = 5 * time.Minute
const maxSubagentTimeout = 30 * time.Minute
type subagentArgs struct {
Task string `json:"task"`
Model string `json:"model,omitempty"`
SystemPrompt string `json:"system_prompt,omitempty"`
TimeoutSeconds int `json:"timeout_seconds,omitempty"`
}
// NewSubagentTool creates the spawn_subagent core tool.
func NewSubagentTool(opts ...ToolOption) fantasy.AgentTool {
return &coreTool{
info: fantasy.ToolInfo{
Name: "spawn_subagent",
Description: `Spawn a background subagent to perform a task autonomously.
The subagent runs as a separate Kit instance with full tool access. Use this to:
- Delegate independent subtasks that can run in parallel
- Perform research or analysis without blocking your main work
- Execute tasks that benefit from a fresh context window
The subagent result is returned when it completes. For long-running tasks,
consider breaking them into smaller focused subtasks.
Example use cases:
- "Research the authentication patterns in this codebase"
- "Write unit tests for the UserService class"
- "Analyze the performance bottlenecks in the database queries"`,
Parameters: map[string]any{
"task": map[string]any{
"type": "string",
"description": "The complete task description for the subagent to perform",
},
"model": map[string]any{
"type": "string",
"description": "Optional model override (e.g. 'anthropic/claude-haiku-3-5-20241022' for faster/cheaper tasks)",
},
"system_prompt": map[string]any{
"type": "string",
"description": "Optional system prompt for domain-specific guidance",
},
"timeout_seconds": map[string]any{
"type": "number",
"description": "Maximum execution time in seconds (default: 300, max: 1800)",
},
},
Required: []string{"task"},
Parallel: true,
},
handler: func(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
return executeSubagent(ctx, call)
},
}
}
func executeSubagent(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
var args subagentArgs
if err := parseArgs(call.Input, &args); err != nil {
return fantasy.NewTextErrorResponse("task parameter is required"), nil
}
if args.Task == "" {
return fantasy.NewTextErrorResponse("task parameter is required"), nil
}
// Determine timeout
timeout := defaultSubagentTimeout
if args.TimeoutSeconds > 0 {
timeout = min(time.Duration(args.TimeoutSeconds)*time.Second, maxSubagentTimeout)
}
// Spawn subagent in blocking mode
_, result, err := extensions.SpawnSubagent(extensions.SubagentConfig{
Prompt: args.Task,
Model: args.Model,
SystemPrompt: args.SystemPrompt,
Timeout: timeout,
Blocking: true,
})
if err != nil {
return fantasy.NewTextErrorResponse(fmt.Sprintf("Failed to spawn subagent: %v", err)), nil
}
if result.Error != nil {
// Subagent failed but we still have partial output
response := fmt.Sprintf("Subagent failed (exit code %d) after %ds.\n\nError: %v",
result.ExitCode, int(result.Elapsed.Seconds()), result.Error)
if result.Response != "" {
response += fmt.Sprintf("\n\nPartial output:\n%s", truncateResponse(result.Response, 8000))
}
return fantasy.NewTextErrorResponse(response), nil
}
// Build successful response
response := fmt.Sprintf("Subagent completed successfully in %ds.", int(result.Elapsed.Seconds()))
if result.Usage != nil {
response += fmt.Sprintf(" (tokens: %d in / %d out)", result.Usage.InputTokens, result.Usage.OutputTokens)
}
response += fmt.Sprintf("\n\nResult:\n%s", truncateResponse(result.Response, 12000))
return fantasy.NewTextResponse(response), nil
}
// truncateResponse limits the response length to avoid overwhelming context windows.
func truncateResponse(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen] + "\n\n... [truncated — " + fmt.Sprintf("%d", len(s)-maxLen) + " bytes omitted]"
}
+1
View File
@@ -96,5 +96,6 @@ func AllTools(opts ...ToolOption) []fantasy.AgentTool {
NewGrepTool(opts...),
NewFindTool(opts...),
NewLsTool(opts...),
NewSubagentTool(opts...),
}
}
+35
View File
@@ -491,6 +491,41 @@ type Context struct {
// },
// })
ReloadExtensions func() error
// SpawnSubagent spawns a child Kit instance to perform a task autonomously.
// The subagent runs as a separate subprocess with full tool access but
// isolated session and extensions (--no-session --no-extensions).
//
// When config.Blocking is true, blocks until completion and returns the
// result directly (handle is nil). When false, returns immediately with
// a handle for monitoring/cancellation.
//
// Example — blocking call:
//
// _, result, err := ctx.SpawnSubagent(ext.SubagentConfig{
// Prompt: "Research authentication patterns in this codebase",
// Blocking: true,
// Timeout: 2 * time.Minute,
// })
// if err != nil {
// ctx.PrintError("spawn failed: " + err.Error())
// return
// }
// ctx.PrintInfo("Subagent result:\n" + result.Response)
//
// Example — background spawn with callbacks:
//
// handle, _, _ := ctx.SpawnSubagent(ext.SubagentConfig{
// Prompt: "Write unit tests for UserService",
// OnOutput: func(chunk string) {
// // Live output streaming
// },
// OnComplete: func(result ext.SubagentResult) {
// ctx.SendMessage("Subagent finished:\n" + result.Response)
// },
// })
// // handle.Kill() to cancel, handle.Wait() to block
SpawnSubagent func(SubagentConfig) (*SubagentHandle, *SubagentResult, error)
}
// ---------------------------------------------------------------------------
+328
View File
@@ -0,0 +1,328 @@
// Package extensions provides subagent spawning capabilities for Kit extensions.
package extensions
import (
"bufio"
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"strings"
"sync"
"sync/atomic"
"time"
)
// ---------------------------------------------------------------------------
// Subagent types
// ---------------------------------------------------------------------------
// SubagentConfig configures a subagent spawn.
type SubagentConfig struct {
// Prompt is the task/instruction for the subagent (required).
Prompt string
// Model overrides the parent's model (e.g. "anthropic/claude-haiku-3-5-20241022").
// Empty string uses the parent's current model.
Model string
// SystemPrompt provides domain-specific instructions.
// Empty string uses the default system prompt.
SystemPrompt string
// Timeout limits execution time. Zero means 5 minute default.
Timeout time.Duration
// OnOutput streams stderr output chunks as the subagent runs.
// Called from a goroutine; must be safe for concurrent use.
OnOutput func(chunk string)
// OnComplete is called when the subagent finishes (success or error).
// Called from a goroutine; must be safe for concurrent use.
OnComplete func(result SubagentResult)
// Blocking, when true, makes SpawnSubagent wait for completion and
// return the result directly. When false (default), spawns in background
// and returns immediately with a handle.
Blocking bool
// ParentSessionID links the subagent's session to the parent (optional).
// When set, the subagent's session is persisted with a parent reference.
ParentSessionID string
}
// SubagentResult contains the outcome of a subagent execution.
type SubagentResult struct {
// Response is the subagent's final text response.
Response string
// Error is set if the subagent failed (nil on success).
Error error
// ExitCode is the subprocess exit code (0 = success).
ExitCode int
// Elapsed is the total execution time.
Elapsed time.Duration
// Usage contains token usage if available.
Usage *SubagentUsage
}
// SubagentUsage contains token usage from the subagent's run.
type SubagentUsage struct {
InputTokens int64
OutputTokens int64
}
// SubagentHandle provides control over a running subagent.
type SubagentHandle struct {
// ID is a unique identifier for this subagent instance.
ID string
proc *os.Process
done chan struct{}
result *SubagentResult
mu sync.Mutex
}
// Kill terminates the subagent process.
func (h *SubagentHandle) Kill() error {
h.mu.Lock()
proc := h.proc
h.mu.Unlock()
if proc != nil {
return proc.Kill()
}
return nil
}
// Wait blocks until the subagent completes and returns the result.
func (h *SubagentHandle) Wait() SubagentResult {
<-h.done
h.mu.Lock()
defer h.mu.Unlock()
if h.result != nil {
return *h.result
}
return SubagentResult{Error: fmt.Errorf("subagent completed without result")}
}
// Done returns a channel that closes when the subagent completes.
func (h *SubagentHandle) Done() <-chan struct{} {
return h.done
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
// subagentJSONOutput matches the JSON envelope produced by `kit --json`.
type subagentJSONOutput struct {
Response string `json:"response"`
Usage *struct {
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
} `json:"usage,omitempty"`
}
var subagentCounter uint64
func generateSubagentID() string {
n := atomic.AddUint64(&subagentCounter, 1)
return fmt.Sprintf("sub-%d-%d", time.Now().UnixNano(), n)
}
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"
}
// ---------------------------------------------------------------------------
// SpawnSubagent implementation
// ---------------------------------------------------------------------------
// SpawnSubagent spawns a child Kit instance to perform a task.
//
// When config.Blocking is true, blocks until completion and returns the result
// directly (handle is nil). When false, returns immediately with a handle for
// monitoring/cancellation.
//
// The subagent runs with --json --no-session --no-extensions flags by default,
// ensuring isolation from the parent's extensions and session state.
func SpawnSubagent(cfg SubagentConfig) (*SubagentHandle, *SubagentResult, error) {
if cfg.Prompt == "" {
return nil, nil, fmt.Errorf("prompt is required")
}
timeout := cfg.Timeout
if timeout == 0 {
timeout = 5 * time.Minute
}
kitBinary := findKitBinary()
// Build subprocess arguments.
args := []string{
"--json",
"--no-session",
"--no-extensions",
}
if cfg.Model != "" {
args = append(args, "--model", cfg.Model)
}
// Handle system prompt - write to temp file if provided.
var tmpFile *os.File
if cfg.SystemPrompt != "" {
var err error
tmpFile, err = os.CreateTemp("", "kit-subagent-*.txt")
if err != nil {
return nil, nil, fmt.Errorf("create temp file: %w", err)
}
if _, err := tmpFile.WriteString(cfg.SystemPrompt); err != nil {
_ = tmpFile.Close()
_ = os.Remove(tmpFile.Name())
return nil, nil, fmt.Errorf("write system prompt: %w", err)
}
_ = tmpFile.Close()
args = append(args, "--system-prompt", tmpFile.Name())
}
// Add the prompt as a positional argument.
args = append(args, cfg.Prompt)
// Create command with timeout context.
ctx, cancel := context.WithTimeout(context.Background(), timeout)
cmd := exec.CommandContext(ctx, kitBinary, args...)
cmd.Env = os.Environ()
stdout, err := cmd.StdoutPipe()
if err != nil {
cancel()
if tmpFile != nil {
_ = os.Remove(tmpFile.Name())
}
return nil, nil, fmt.Errorf("stdout pipe: %w", err)
}
stderr, err := cmd.StderrPipe()
if err != nil {
cancel()
if tmpFile != nil {
_ = os.Remove(tmpFile.Name())
}
return nil, nil, fmt.Errorf("stderr pipe: %w", err)
}
handle := &SubagentHandle{
ID: generateSubagentID(),
done: make(chan struct{}),
}
// Start the subprocess.
start := time.Now()
if err := cmd.Start(); err != nil {
cancel()
if tmpFile != nil {
_ = os.Remove(tmpFile.Name())
}
return nil, nil, fmt.Errorf("start subprocess: %w", err)
}
handle.mu.Lock()
handle.proc = cmd.Process
handle.mu.Unlock()
// Run the subprocess monitoring in a goroutine.
go func() {
defer close(handle.done)
defer cancel()
if tmpFile != nil {
defer func() { _ = os.Remove(tmpFile.Name()) }()
}
var wg sync.WaitGroup
var stdoutBuf strings.Builder
// Read stderr (live output).
wg.Go(func() {
scanner := bufio.NewScanner(stderr)
scanner.Buffer(make([]byte, 256*1024), 256*1024)
for scanner.Scan() {
line := scanner.Text()
if cfg.OnOutput != nil && strings.TrimSpace(line) != "" {
cfg.OnOutput(line + "\n")
}
}
})
// Read stdout (JSON output).
scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 256*1024), 256*1024)
for scanner.Scan() {
stdoutBuf.WriteString(scanner.Text() + "\n")
}
wg.Wait()
waitErr := cmd.Wait()
elapsed := time.Since(start)
// Build result.
result := SubagentResult{Elapsed: elapsed}
if waitErr != nil {
result.Error = waitErr
if exitErr, ok := waitErr.(*exec.ExitError); ok {
result.ExitCode = exitErr.ExitCode()
} else {
result.ExitCode = 1
}
}
// Parse JSON output.
raw := strings.TrimSpace(stdoutBuf.String())
var parsed subagentJSONOutput
if raw != "" && json.Unmarshal([]byte(raw), &parsed) == nil {
result.Response = parsed.Response
if parsed.Usage != nil {
result.Usage = &SubagentUsage{
InputTokens: parsed.Usage.InputTokens,
OutputTokens: parsed.Usage.OutputTokens,
}
}
} else {
// Fallback: use raw stdout.
result.Response = raw
}
handle.mu.Lock()
handle.result = &result
handle.proc = nil
handle.mu.Unlock()
if cfg.OnComplete != nil {
cfg.OnComplete(result)
}
}()
if cfg.Blocking {
// Wait for completion and return result directly.
<-handle.done
handle.mu.Lock()
r := handle.result
handle.mu.Unlock()
return nil, r, nil
}
return handle, nil, nil
}
+6
View File
@@ -110,6 +110,12 @@ func Symbols() interp.Exports {
"BeforeCompactEvent": reflect.ValueOf((*BeforeCompactEvent)(nil)),
"BeforeCompactResult": reflect.ValueOf((*BeforeCompactResult)(nil)),
// Subagent types
"SubagentConfig": reflect.ValueOf((*SubagentConfig)(nil)),
"SubagentResult": reflect.ValueOf((*SubagentResult)(nil)),
"SubagentUsage": reflect.ValueOf((*SubagentUsage)(nil)),
"SubagentHandle": reflect.ValueOf((*SubagentHandle)(nil)),
// Event structs
"ToolCallEvent": reflect.ValueOf((*ToolCallEvent)(nil)),
"ToolCallResult": reflect.ValueOf((*ToolCallResult)(nil)),
+193
View File
@@ -0,0 +1,193 @@
package models
import (
"context"
"sync"
"time"
"charm.land/fantasy"
)
// ProviderPool manages reusable LLM provider instances to reduce overhead
// when spawning multiple subagents or making repeated completion calls.
type ProviderPool struct {
mu sync.RWMutex
providers map[string]*pooledProvider
ttl time.Duration
closed bool
closeCh chan struct{}
}
type pooledProvider struct {
model fantasy.LanguageModel
closer func() error
providerOpts fantasy.ProviderOptions
created time.Time
lastUsed time.Time
refs int32
}
// DefaultPoolTTL is the default time-to-live for idle pooled providers.
const DefaultPoolTTL = 5 * time.Minute
// globalPool is the singleton provider pool instance.
var globalPool *ProviderPool
var poolOnce sync.Once
// GetGlobalPool returns the singleton provider pool instance.
func GetGlobalPool() *ProviderPool {
poolOnce.Do(func() {
globalPool = NewProviderPool(DefaultPoolTTL)
})
return globalPool
}
// NewProviderPool creates a provider pool with the given TTL for idle providers.
func NewProviderPool(ttl time.Duration) *ProviderPool {
p := &ProviderPool{
providers: make(map[string]*pooledProvider),
ttl: ttl,
closeCh: make(chan struct{}),
}
go p.cleanupLoop()
return p
}
// Get returns a provider for the model string, creating one if needed.
// The returned release function must be called when the provider is no longer
// needed. The provider may be reused by subsequent Get calls.
func (p *ProviderPool) Get(ctx context.Context, modelString string) (fantasy.LanguageModel, fantasy.ProviderOptions, func(), error) {
p.mu.Lock()
// Check if we have an existing provider.
if pp, ok := p.providers[modelString]; ok {
pp.refs++
pp.lastUsed = time.Now()
p.mu.Unlock()
return pp.model, pp.providerOpts, func() { p.release(modelString) }, nil
}
p.mu.Unlock()
// Create a new provider outside the lock.
config := &ProviderConfig{ModelString: modelString}
result, err := CreateProvider(ctx, config)
if err != nil {
return nil, nil, nil, err
}
p.mu.Lock()
defer p.mu.Unlock()
// Double-check: another goroutine may have created one while we were unlocked.
if pp, ok := p.providers[modelString]; ok {
// Close the one we just created and use the existing one.
if result.Closer != nil {
_ = result.Closer.Close()
}
pp.refs++
pp.lastUsed = time.Now()
return pp.model, pp.providerOpts, func() { p.release(modelString) }, nil
}
var closerFn func() error
if result.Closer != nil {
closerFn = result.Closer.Close
}
pp := &pooledProvider{
model: result.Model,
closer: closerFn,
providerOpts: result.ProviderOptions,
created: time.Now(),
lastUsed: time.Now(),
refs: 1,
}
p.providers[modelString] = pp
return pp.model, pp.providerOpts, func() { p.release(modelString) }, nil
}
func (p *ProviderPool) release(modelString string) {
p.mu.Lock()
defer p.mu.Unlock()
if pp, ok := p.providers[modelString]; ok {
pp.refs--
pp.lastUsed = time.Now()
}
}
func (p *ProviderPool) cleanupLoop() {
ticker := time.NewTicker(p.ttl / 2)
defer ticker.Stop()
for {
select {
case <-p.closeCh:
return
case <-ticker.C:
p.cleanup()
}
}
}
func (p *ProviderPool) cleanup() {
p.mu.Lock()
defer p.mu.Unlock()
now := time.Now()
for key, pp := range p.providers {
// Only clean up providers with no active references and past TTL.
if pp.refs <= 0 && now.Sub(pp.lastUsed) > p.ttl {
if pp.closer != nil {
_ = pp.closer()
}
delete(p.providers, key)
}
}
}
// Close shuts down the pool and releases all providers.
func (p *ProviderPool) Close() {
p.mu.Lock()
if p.closed {
p.mu.Unlock()
return
}
p.closed = true
close(p.closeCh)
for key, pp := range p.providers {
if pp.closer != nil {
_ = pp.closer()
}
delete(p.providers, key)
}
p.mu.Unlock()
}
// Stats returns current pool statistics.
func (p *ProviderPool) Stats() PoolStats {
p.mu.RLock()
defer p.mu.RUnlock()
stats := PoolStats{
TotalProviders: len(p.providers),
}
for _, pp := range p.providers {
if pp.refs > 0 {
stats.ActiveProviders++
} else {
stats.IdleProviders++
}
}
return stats
}
// PoolStats contains provider pool statistics.
type PoolStats struct {
TotalProviders int
ActiveProviders int
IdleProviders int
}
+4
View File
@@ -38,6 +38,10 @@ type SessionHeader struct {
Timestamp time.Time `json:"timestamp"` // creation time
Cwd string `json:"cwd"` // working directory
ParentSession string `json:"parent_session,omitempty"` // path to parent if forked
// Subagent fields (set when session is created by a subagent)
ParentSessionID string `json:"parent_session_id,omitempty"` // UUID of parent session
SubagentTask string `json:"subagent_task,omitempty"` // original task prompt
}
// Entry is the common structure shared by all tree entries (everything except
+32
View File
@@ -29,6 +29,12 @@ type SessionInfo struct {
// ParentSessionPath is the parent session path if this session was forked.
ParentSessionPath string
// ParentSessionID is the UUID of the parent session (for subagent sessions).
ParentSessionID string
// SubagentTask is the original task prompt (for subagent sessions).
SubagentTask string
// Created is when the session was first created.
Created time.Time
@@ -162,6 +168,8 @@ func extractSessionInfo(path string) (*SessionInfo, error) {
info.Created = h.Timestamp
info.Modified = h.Timestamp
info.ParentSessionPath = h.ParentSession
info.ParentSessionID = h.ParentSessionID
info.SubagentTask = h.SubagentTask
continue
}
@@ -245,3 +253,27 @@ func extractTextPreview(partsJSON json.RawMessage) string {
func DeleteSession(path string) error {
return os.Remove(path)
}
// ListChildSessions returns all sessions that have the given session ID as
// their parent. This is useful for finding subagent sessions spawned from
// a parent session. Results are sorted by creation time (newest first).
func ListChildSessions(parentID string) ([]SessionInfo, error) {
if parentID == "" {
return nil, nil
}
allSessions, err := ListAllSessions()
if err != nil {
return nil, err
}
var children []SessionInfo
for _, s := range allSessions {
if s.ParentSessionID == parentID {
children = append(children, s)
}
}
// Already sorted by modification time from ListAllSessions
return children, nil
}
+50 -7
View File
@@ -348,6 +348,9 @@ func TestStreamComponent_SpinnerKeepsRunningDuringStreaming(t *testing.T) {
// Receive first chunk — spinner should keep running.
c = sendStreamMsg(c, app.StreamChunkEvent{Content: "hello"})
// Flush pending chunks (simulates the 16ms tick firing).
c = sendStreamMsg(c, streamFlushTickMsg{})
if !c.spinning {
t.Fatal("expected spinning=true after first chunk")
}
@@ -372,6 +375,9 @@ func TestStreamComponent_ChunkAccumulation(t *testing.T) {
c = sendStreamMsg(c, app.StreamChunkEvent{Content: chunk})
}
// Flush pending chunks (simulates the 16ms tick firing).
c = sendStreamMsg(c, streamFlushTickMsg{})
got := c.streamContent.String()
want := "Hello, world!"
if got != want {
@@ -397,8 +403,8 @@ func TestStreamComponent_ToolExecution_IsStarting_ShowsSpinner(t *testing.T) {
if !c.spinning {
t.Fatal("expected spinning=true during tool execution")
}
if !strings.Contains(c.spinnerMsg, "exec_tool") {
t.Fatalf("expected spinnerMsg to contain tool name, got %q", c.spinnerMsg)
if len(c.activeTools) != 1 || !strings.Contains(c.activeTools[0], "exec_tool") {
t.Fatalf("expected activeTools to contain tool name, got %v", c.activeTools)
}
if cmd == nil {
t.Fatal("expected tick cmd from ToolExecutionEvent{IsStarting:true}")
@@ -410,7 +416,11 @@ func TestStreamComponent_ToolExecution_NotStarting_KeepsSpinning(t *testing.T) {
c := newTestStream()
// Start spinning first (simulating execution in progress).
c = sendStreamMsg(c, app.SpinnerEvent{Show: true})
c.spinnerMsg = "Executing some_tool…"
// Simulate a tool starting
c = sendStreamMsg(c, app.ToolExecutionEvent{
ToolName: "some_tool",
IsStarting: true,
})
c = sendStreamMsg(c, app.ToolExecutionEvent{
ToolName: "some_tool",
@@ -420,8 +430,41 @@ func TestStreamComponent_ToolExecution_NotStarting_KeepsSpinning(t *testing.T) {
if !c.spinning {
t.Fatal("expected spinning=true after tool execution finished (spinner keeps running)")
}
if c.spinnerMsg != "" {
t.Fatalf("expected spinnerMsg cleared after tool finished, got %q", c.spinnerMsg)
if len(c.activeTools) != 0 {
t.Fatalf("expected activeTools cleared after tool finished, got %v", c.activeTools)
}
}
// TestStreamComponent_ParallelToolExecution verifies multiple tools can run concurrently.
func TestStreamComponent_ParallelToolExecution(t *testing.T) {
c := newTestStream()
// Start three tools in parallel
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolName: "read", IsStarting: true})
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolName: "grep", IsStarting: true})
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolName: "find", IsStarting: true})
if len(c.activeTools) != 3 {
t.Fatalf("expected 3 active tools, got %d: %v", len(c.activeTools), c.activeTools)
}
// Check SpinnerView shows all tools
view := c.SpinnerView()
if !strings.Contains(view, "Running:") {
t.Fatalf("expected spinner view to contain 'Running:' for multiple tools, got %q", view)
}
// Finish one tool
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolName: "grep", IsStarting: false})
if len(c.activeTools) != 2 {
t.Fatalf("expected 2 active tools after one finished, got %d: %v", len(c.activeTools), c.activeTools)
}
// Finish remaining tools
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolName: "read", IsStarting: false})
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolName: "find", IsStarting: false})
if len(c.activeTools) != 0 {
t.Fatalf("expected 0 active tools after all finished, got %d: %v", len(c.activeTools), c.activeTools)
}
}
@@ -480,8 +523,8 @@ func TestStreamComponent_Reset(t *testing.T) {
if !c.timestamp.IsZero() {
t.Fatal("expected zero timestamp after Reset()")
}
if c.spinnerMsg != "" {
t.Fatalf("expected spinnerMsg empty after Reset(), got %q", c.spinnerMsg)
if len(c.activeTools) != 0 {
t.Fatalf("expected activeTools empty after Reset(), got %v", c.activeTools)
}
}
+153 -19
View File
@@ -69,6 +69,25 @@ func streamSpinnerTickCmd() tea.Cmd {
})
}
// streamFlushTickMsg fires when it's time to commit pending chunks to the
// main content builders and trigger a re-render. This coalesces rapid
// streaming chunks into fewer expensive markdown re-renders.
type streamFlushTickMsg struct{}
// streamFlushInterval is the coalescing window for stream chunks. Chunks
// arriving within this window are batched into a single render pass.
// 16ms ≈ 60 fps — fast enough to appear smooth, slow enough to coalesce
// bursts from the LLM provider.
const streamFlushInterval = 16 * time.Millisecond
// streamFlushTickCmd returns a tea.Cmd that fires streamFlushTickMsg after
// the coalescing interval.
func streamFlushTickCmd() tea.Cmd {
return tea.Tick(streamFlushInterval, func(_ time.Time) tea.Msg {
return streamFlushTickMsg{}
})
}
// streamPhase tracks what the StreamComponent is currently displaying.
type streamPhase int
@@ -114,16 +133,38 @@ type StreamComponent struct {
// spinnerFrame is the current frame index.
spinnerFrame int
// spinnerMsg is the label shown next to the KITT animation (e.g.
// "Executing tool_name…"). Empty string means no label.
spinnerMsg string
// activeTools tracks the names of tools currently executing in parallel.
// When multiple tools run concurrently, all are displayed in the spinner.
activeTools []string
// streamContent accumulates all streaming text chunks.
// streamContent holds committed streaming text (flushed from pending).
streamContent strings.Builder
// reasoningContent accumulates reasoning/thinking text chunks.
// reasoningContent holds committed reasoning text (flushed from pending).
reasoningContent strings.Builder
// pendingStream accumulates streaming text chunks between flush ticks.
// Chunks are written here immediately on arrival, then moved to
// streamContent when the flush tick fires.
pendingStream strings.Builder
// pendingReasoning accumulates reasoning chunks between flush ticks.
pendingReasoning strings.Builder
// flushPending is true while a flush tick is in-flight. Prevents
// scheduling duplicate ticks when multiple chunks arrive within
// the same coalescing window.
flushPending bool
// renderCache holds the last rendered output string. Reused by View()
// between flush ticks to avoid redundant markdown re-parsing.
renderCache string
// renderDirty is true when committed content has changed since the
// last render. Set on flush tick; cleared after render() rebuilds
// the cache.
renderDirty bool
// thinkingVisible controls whether reasoning blocks are shown or collapsed.
thinkingVisible bool
@@ -172,7 +213,12 @@ func (s *StreamComponent) SetHeight(h int) {
if h < 0 {
h = 0
}
s.height = h
if s.height != h {
s.height = h
// Invalidate cache — height clamp affects output.
s.renderCache = ""
s.renderDirty = true
}
}
// Reset clears all accumulated state so the component is ready for the next
@@ -181,16 +227,27 @@ func (s *StreamComponent) Reset() {
s.phase = streamPhaseIdle
s.spinning = false
s.spinnerFrame = 0
s.spinnerMsg = ""
s.activeTools = nil
s.streamContent.Reset()
s.reasoningContent.Reset()
s.pendingStream.Reset()
s.pendingReasoning.Reset()
s.flushPending = false
s.renderCache = ""
s.renderDirty = false
s.timestamp = time.Time{}
}
// GetRenderedContent returns the rendered assistant message from the accumulated
// streaming text. Returns empty string if no text has been accumulated. Used by
// the parent AppModel to flush content via tea.Println() before resetting.
//
// This commits any pending chunks first so the output includes all received
// content, not just what has been flushed by the tick.
func (s *StreamComponent) GetRenderedContent() string {
// Commit any pending chunks so the final output is complete.
s.commitPending()
var sections []string
// Include rendered reasoning block if present.
@@ -209,6 +266,21 @@ func (s *StreamComponent) GetRenderedContent() string {
return strings.Join(sections, "\n")
}
// commitPending moves any pending chunks to the committed content builders.
// Called before reading content for scrollback output or on flush tick.
func (s *StreamComponent) commitPending() {
if s.pendingStream.Len() > 0 {
s.streamContent.WriteString(s.pendingStream.String())
s.pendingStream.Reset()
s.renderDirty = true
}
if s.pendingReasoning.Len() > 0 {
s.reasoningContent.WriteString(s.pendingReasoning.String())
s.pendingReasoning.Reset()
s.renderDirty = true
}
}
// --------------------------------------------------------------------------
// tea.Model interface
// --------------------------------------------------------------------------
@@ -227,6 +299,9 @@ func (s *StreamComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
s.width = msg.Width
s.messageRenderer.SetWidth(s.width)
s.compactRenderer.SetWidth(s.width)
// Invalidate render cache — width change affects wrapping/styling.
s.renderCache = ""
s.renderDirty = true
case streamSpinnerTickMsg:
if s.spinning {
@@ -250,24 +325,37 @@ func (s *StreamComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
s.spinning = false
}
case streamFlushTickMsg:
s.flushPending = false
s.commitPending()
case app.ReasoningChunkEvent:
s.phase = streamPhaseActive
if s.timestamp.IsZero() {
s.timestamp = time.Now()
}
s.reasoningContent.WriteString(msg.Delta)
s.pendingReasoning.WriteString(msg.Delta)
if !s.flushPending {
s.flushPending = true
return s, streamFlushTickCmd()
}
case app.StreamChunkEvent:
s.phase = streamPhaseActive
if s.timestamp.IsZero() {
s.timestamp = time.Now()
}
s.streamContent.WriteString(msg.Content)
s.pendingStream.WriteString(msg.Content)
if !s.flushPending {
s.flushPending = true
return s, streamFlushTickCmd()
}
case app.ToolExecutionEvent:
if msg.IsStarting {
// Show the tool name on the spinner while the tool executes.
s.spinnerMsg = "Executing " + msg.ToolName + "…"
// Add tool to active list for parallel execution display.
toolDisplay := formatToolExecutionMessage(msg.ToolName, msg.ToolArgs)
s.activeTools = append(s.activeTools, toolDisplay)
s.spinnerFrame = 0
if !s.spinning {
s.phase = streamPhaseActive
@@ -275,8 +363,9 @@ func (s *StreamComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return s, streamSpinnerTickCmd()
}
} else {
// Tool finished — clear execution label but keep spinning.
s.spinnerMsg = ""
// Tool finished — remove from active list but keep spinning if others remain.
toolDisplay := formatToolExecutionMessage(msg.ToolName, msg.ToolArgs)
s.activeTools = removeFromSlice(s.activeTools, toolDisplay)
}
}
@@ -292,12 +381,20 @@ func (s *StreamComponent) View() tea.View {
// Internal rendering
// --------------------------------------------------------------------------
// render builds the full content string for the stream region.
// render builds the full content string for the stream region. Uses a render
// cache to avoid redundant markdown re-parsing between flush ticks. The cache
// is invalidated when committed content changes (flush tick), terminal width
// changes, or height/thinking visibility changes.
func (s *StreamComponent) render() string {
if s.phase == streamPhaseIdle {
return ""
}
// Return cached render if committed content hasn't changed.
if !s.renderDirty {
return s.renderCache
}
var sections []string
// Render reasoning/thinking block above the main text if present.
@@ -313,6 +410,8 @@ func (s *StreamComponent) render() string {
}
if len(sections) == 0 {
s.renderCache = ""
s.renderDirty = false
return ""
}
@@ -328,6 +427,8 @@ func (s *StreamComponent) render() string {
}
}
s.renderCache = content
s.renderDirty = false
return content
}
@@ -358,12 +459,18 @@ func (s *StreamComponent) renderReasoningBlock(reasoning string) string {
// SetThinkingVisible sets whether reasoning blocks are shown or collapsed.
func (s *StreamComponent) SetThinkingVisible(visible bool) {
s.thinkingVisible = visible
if s.thinkingVisible != visible {
s.thinkingVisible = visible
// Invalidate cache — thinking visibility affects rendered output.
s.renderCache = ""
s.renderDirty = true
}
}
// HasReasoning returns true if any reasoning content has been accumulated.
// HasReasoning returns true if any reasoning content has been accumulated
// (committed or pending).
func (s *StreamComponent) HasReasoning() bool {
return s.reasoningContent.Len() > 0
return s.reasoningContent.Len() > 0 || s.pendingReasoning.Len() > 0
}
// SpinnerView returns the rendered spinner line for the parent to embed in the
@@ -373,14 +480,22 @@ func (s *StreamComponent) SpinnerView() string {
return ""
}
frame := s.spinnerFrames[s.spinnerFrame%len(s.spinnerFrames)]
if s.spinnerMsg == "" {
if len(s.activeTools) == 0 {
return " " + frame
}
theme := GetTheme()
msgStyle := lipgloss.NewStyle().
Foreground(theme.Text).
Italic(true)
return " " + frame + " " + msgStyle.Render(s.spinnerMsg)
// Format active tools list
var toolsMsg string
if len(s.activeTools) == 1 {
toolsMsg = s.activeTools[0]
} else {
toolsMsg = "Running: " + strings.Join(s.activeTools, ", ")
}
return " " + frame + " " + msgStyle.Render(toolsMsg)
}
// renderStreamingText renders the accumulated streaming text as a live assistant
@@ -398,3 +513,22 @@ func (s *StreamComponent) renderStreamingText(text string) string {
msg := s.messageRenderer.RenderAssistantMessage(text, ts, s.modelName)
return msg.Content
}
// removeFromSlice removes the first occurrence of a string from a slice.
func removeFromSlice(slice []string, s string) []string {
for i, v := range slice {
if v == s {
return append(slice[:i], slice[i+1:]...)
}
}
return slice
}
// formatToolExecutionMessage creates a descriptive spinner message for tool execution.
// For spawn_subagent, it shows simply as "Subagent" with optional task preview.
func formatToolExecutionMessage(toolName, toolArgs string) string {
if toolName == "spawn_subagent" {
return "Subagent"
}
return toolName
}
+124
View File
@@ -49,6 +49,10 @@ func renderToolBody(toolName, toolArgs, toolResult string, width int) string {
if body := renderBashBody(toolResult, width); body != "" {
return body
}
case toolName == "spawn_subagent":
if body := renderSubagentBody(toolResult, width); body != "" {
return body
}
}
return "" // fall back to default
}
@@ -716,6 +720,8 @@ func renderToolBodyCompact(toolName, toolArgs, toolResult string, width int) str
case toolName == "bash" || toolName == "run_shell_cmd" ||
strings.Contains(toolName, "shell") || strings.Contains(toolName, "command"):
return renderBashCompact(toolResult, width)
case toolName == "spawn_subagent":
return renderSubagentCompact(toolResult)
}
return ""
}
@@ -870,3 +876,121 @@ func renderBashCompact(toolResult string, width int) string {
return lipgloss.NewStyle().Foreground(theme.Muted).Render(summary)
}
// ---------------------------------------------------------------------------
// Subagent tool renderers — show only summary, not full output
// ---------------------------------------------------------------------------
// renderSubagentBody renders a clean summary of subagent results.
// Extracts timing/token info and shows only a brief summary instead of raw output.
func renderSubagentBody(toolResult string, width int) string {
theme := getTheme()
result := strings.TrimSpace(toolResult)
if result == "" {
return ""
}
// Parse the subagent result format:
// "Subagent completed successfully in Xs. (tokens: N in / M out)\n\nResult:\n..."
// or "Subagent failed (exit code X) after Ys.\n\nError: ...\n\nPartial output:\n..."
lines := strings.Split(result, "\n")
if len(lines) == 0 {
return ""
}
// First line is always the status summary
statusLine := lines[0]
// Build a clean summary
var summary strings.Builder
summary.WriteString(lipgloss.NewStyle().Foreground(theme.Muted).Render(statusLine))
// For successful results, extract a brief preview of the actual result
if strings.Contains(statusLine, "successfully") {
// Find where "Result:" starts and extract a preview
if _, resultContent, found := strings.Cut(result, "Result:\n"); found {
resultContent = strings.TrimSpace(resultContent)
if resultContent != "" {
// Show first 3 meaningful lines as preview
preview := extractSubagentPreview(resultContent, 3, width-4)
if preview != "" {
summary.WriteString("\n\n")
summary.WriteString(lipgloss.NewStyle().
Foreground(theme.Muted).
Italic(true).
Render(preview))
}
}
}
}
return summary.String()
}
// extractSubagentPreview extracts the first N non-empty lines from content,
// truncating each line to maxWidth.
func extractSubagentPreview(content string, maxLines, maxWidth int) string {
lines := strings.Split(content, "\n")
var preview []string
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
}
// Truncate long lines
if len(trimmed) > maxWidth {
trimmed = trimmed[:maxWidth-3] + "..."
}
preview = append(preview, trimmed)
if len(preview) >= maxLines {
break
}
}
if len(preview) == 0 {
return ""
}
result := strings.Join(preview, "\n")
// Count remaining lines for "more" indicator
totalLines := 0
for _, line := range lines {
if strings.TrimSpace(line) != "" {
totalLines++
}
}
if totalLines > maxLines {
result += fmt.Sprintf("\n...(%d more lines)", totalLines-maxLines)
}
return result
}
// renderSubagentCompact returns a brief one-line summary for subagent results.
func renderSubagentCompact(toolResult string) string {
result := strings.TrimSpace(toolResult)
if result == "" {
return ""
}
theme := getTheme()
// Extract just the first line which contains the status
lines := strings.Split(result, "\n")
if len(lines) == 0 {
return ""
}
statusLine := lines[0]
// Make it more compact by removing redundant words
statusLine = strings.Replace(statusLine, "Subagent completed successfully in ", "Completed in ", 1)
statusLine = strings.Replace(statusLine, "Subagent failed", "Failed", 1)
return lipgloss.NewStyle().Foreground(theme.Muted).Italic(true).Render(statusLine)
}
+1
View File
@@ -111,6 +111,7 @@ func (e ToolCallEvent) EventType() EventType { return EventToolCall }
// ToolExecutionStartEvent fires when a tool begins executing.
type ToolExecutionStartEvent struct {
ToolName string
ToolArgs string
}
// EventType implements Event.
+2 -2
View File
@@ -1177,9 +1177,9 @@ func (m *Kit) generate(ctx context.Context, messages []fantasy.Message) (*agent.
func(toolName, toolArgs string) {
m.events.emit(ToolCallEvent{ToolName: toolName, ToolArgs: toolArgs})
},
func(toolName string, isStarting bool) {
func(toolName, toolArgs string, isStarting bool) {
if isStarting {
m.events.emit(ToolExecutionStartEvent{ToolName: toolName})
m.events.emit(ToolExecutionStartEvent{ToolName: toolName, ToolArgs: toolArgs})
} else {
m.events.emit(ToolExecutionEndEvent{ToolName: toolName})
}
+853
View File
@@ -0,0 +1,853 @@
---
name: kit-extensions
description: Guide for creating Kit extensions. Use when the user asks to build, create, or modify a Kit extension, add a custom tool, slash command, widget, keyboard shortcut, editor interceptor, tool renderer, or hook into any Kit lifecycle event.
---
# Kit Extensions Development Guide
Kit extensions are single-file Go programs interpreted at runtime by Yaegi. They hook into Kit's lifecycle, register custom tools and slash commands, display widgets, intercept editor input, render tool output, and more.
## Extension Structure
Every extension must export a `package main` with an `Init(api ext.API)` function:
```go
//go:build ignore
package main
import "kit/ext"
func Init(api ext.API) {
// Register event handlers, tools, commands, etc.
}
```
The `//go:build ignore` tag prevents `go build` from compiling the file directly.
## Extension Locations
Extensions are auto-loaded from these directories:
- `~/.config/kit/extensions/*.go` (global, single files)
- `~/.config/kit/extensions/*/main.go` (global, subdirectories)
- `.kit/extensions/*.go` (project-local, single files)
- `.kit/extensions/*/main.go` (project-local, subdirectories)
Or loaded explicitly:
```bash
kit -e path/to/extension.go
kit --extension path/to/extension.go
```
## Import Path
Extensions import the Kit API as `"kit/ext"`. The full standard library is available plus `os/exec` for subprocess spawning.
## API Overview
The `Init` function receives an `ext.API` object for registering handlers, and event handlers receive an `ext.Context` with runtime capabilities.
---
## Lifecycle Events
Kit provides 18 lifecycle events. Each handler receives an event struct and a `Context`.
### Session Events
```go
// Fired when session is loaded/created.
api.OnSessionStart(func(e ext.SessionStartEvent, ctx ext.Context) {
// e.SessionID string
})
// Fired when Kit is shutting down. Use for cleanup.
api.OnSessionShutdown(func(e ext.SessionShutdownEvent, ctx ext.Context) {
// No fields.
})
```
### Agent Turn Events
```go
// Before agent starts processing. Can inject system prompt or text.
api.OnBeforeAgentStart(func(e ext.BeforeAgentStartEvent, ctx ext.Context) *ext.BeforeAgentStartResult {
// e.Prompt string
// Return nil to pass through.
// Return &ext.BeforeAgentStartResult{SystemPrompt: &s} to augment system prompt.
// Return &ext.BeforeAgentStartResult{InjectText: &s} to inject text before prompt.
return nil
})
// Agent loop has started.
api.OnAgentStart(func(e ext.AgentStartEvent, ctx ext.Context) {
// e.Prompt string
})
// Agent finished responding.
api.OnAgentEnd(func(e ext.AgentEndEvent, ctx ext.Context) {
// e.Response string
// e.StopReason string — "completed", "cancelled", "error"
})
```
### Tool Events
```go
// Before a tool executes. Can block the call.
api.OnToolCall(func(e ext.ToolCallEvent, ctx ext.Context) *ext.ToolCallResult {
// e.ToolName string
// e.ToolCallID string
// e.Input string — JSON-encoded parameters
// e.Source string — "llm" or "user"
// Return nil to allow.
// Return &ext.ToolCallResult{Block: true, Reason: "..."} to block.
return nil
})
// Tool execution started (informational only).
api.OnToolExecutionStart(func(e ext.ToolExecutionStartEvent, ctx ext.Context) {
// e.ToolName string
})
// Tool execution ended (informational only).
api.OnToolExecutionEnd(func(e ext.ToolExecutionEndEvent, ctx ext.Context) {
// e.ToolName string
})
// After a tool returns. Can modify the result.
api.OnToolResult(func(e ext.ToolResultEvent, ctx ext.Context) *ext.ToolResultResult {
// e.ToolName string
// e.Input string
// e.Content string
// e.IsError bool
// Return nil to pass through.
// Return &ext.ToolResultResult{Content: &s} to replace content.
// Return &ext.ToolResultResult{IsError: &b} to change error status.
return nil
})
```
### Input Events
```go
// User submitted input. Can handle or transform it.
api.OnInput(func(e ext.InputEvent, ctx ext.Context) *ext.InputResult {
// e.Text string
// e.Source string — "interactive", "cli", "script", "queue"
// Return nil to pass through to agent.
// Return &ext.InputResult{Action: "handled"} to consume without sending to agent.
// Return &ext.InputResult{Action: "transform", Text: "new text"} to rewrite.
return nil
})
```
### Streaming Events
```go
api.OnMessageStart(func(e ext.MessageStartEvent, ctx ext.Context) {})
api.OnMessageUpdate(func(e ext.MessageUpdateEvent, ctx ext.Context) {
// e.Chunk string — streaming text chunk
})
api.OnMessageEnd(func(e ext.MessageEndEvent, ctx ext.Context) {
// e.Content string — full message content
})
```
### Model Events
```go
api.OnModelChange(func(e ext.ModelChangeEvent, ctx ext.Context) {
// e.NewModel string
// e.PreviousModel string
// e.Source string — "extension" or "user"
})
```
### Context Filtering
```go
// Before messages are sent to the LLM. Can filter, reorder, or inject messages.
api.OnContextPrepare(func(e ext.ContextPrepareEvent, ctx ext.Context) *ext.ContextPrepareResult {
// e.Messages []ext.ContextMessage
// Each ContextMessage has: Index int, Role string, Content string
// Index -1 means a new injected message (not from session).
// Return nil to pass through.
// Return &ext.ContextPrepareResult{Messages: msgs} to replace the context window.
return nil
})
```
### Session Control Events
```go
// Before forking the session tree. Can cancel.
api.OnBeforeFork(func(e ext.BeforeForkEvent, ctx ext.Context) *ext.BeforeForkResult {
// e.TargetID string, e.IsUserMessage bool, e.UserText string
return nil // or &ext.BeforeForkResult{Cancel: true, Reason: "..."}
})
// Before switching/clearing session. Can cancel.
api.OnBeforeSessionSwitch(func(e ext.BeforeSessionSwitchEvent, ctx ext.Context) *ext.BeforeSessionSwitchResult {
// e.Reason string — "new" or "clear"
return nil // or &ext.BeforeSessionSwitchResult{Cancel: true, Reason: "..."}
})
// Before context compaction. Can cancel.
api.OnBeforeCompact(func(e ext.BeforeCompactEvent, ctx ext.Context) *ext.BeforeCompactResult {
// e.EstimatedTokens, e.ContextLimit int
// e.UsagePercent float64, e.MessageCount int, e.IsAutomatic bool
return nil // or &ext.BeforeCompactResult{Cancel: true, Reason: "..."}
})
```
### Custom Events
```go
// Subscribe to custom events emitted by other extensions.
api.OnCustomEvent("event-name", func(data string) {
// data is arbitrary string payload
})
// Emit from Context:
ctx.EmitCustomEvent("event-name", "payload")
```
---
## Registering Tools
Tools are functions the LLM can invoke:
```go
api.RegisterTool(ext.ToolDef{
Name: "current_time",
Description: "Get the current date and time",
Parameters: `{"type":"object","properties":{}}`,
Execute: func(input string) (string, error) {
return time.Now().Format(time.RFC3339), nil
},
})
```
For long-running tools with cancellation and progress:
```go
api.RegisterTool(ext.ToolDef{
Name: "slow_task",
Description: "A long-running task with progress reporting",
Parameters: `{"type":"object","properties":{"query":{"type":"string"}}}`,
ExecuteWithContext: func(input string, tc ext.ToolContext) (string, error) {
for i := 0; i < 10; i++ {
if tc.IsCancelled() {
return "cancelled", nil
}
tc.OnProgress(fmt.Sprintf("Step %d/10...", i+1))
time.Sleep(time.Second)
}
return "done", nil
},
})
```
Parameters must be a JSON Schema string. The `input` argument is the JSON-encoded parameters from the LLM.
---
## Registering Slash Commands
Commands are user-facing actions invoked with `/name` in the input:
```go
api.RegisterCommand(ext.CommandDef{
Name: "echo",
Description: "Echo back the provided text",
Execute: func(args string, ctx ext.Context) (string, error) {
ctx.PrintInfo("You said: " + args)
return "", nil
},
// Optional tab-completion:
Complete: func(prefix string, ctx ext.Context) []string {
return []string{"hello", "world"}
},
})
```
Slash commands run in a dedicated goroutine (not a `tea.Cmd`), so they can safely block on prompts, I/O, etc.
---
## Registering Keyboard Shortcuts
```go
api.RegisterShortcut(ext.ShortcutDef{
Key: "ctrl+alt+p",
Description: "Toggle plan mode",
}, func(ctx ext.Context) {
// handler runs when shortcut is pressed
})
```
---
## Registering Options
Options are configurable values resolved from env vars, config, or defaults:
```go
api.RegisterOption(ext.OptionDef{
Name: "my-setting",
Description: "Controls something",
Default: "false",
})
// Read at runtime (resolution: env KIT_OPT_MY_SETTING > config options.my-setting > default):
val := ctx.GetOption("my-setting")
// Set at runtime:
ctx.SetOption("my-setting", "true")
```
---
## Context API Reference
The `ext.Context` struct provides runtime capabilities via function fields.
### Output
```go
ctx.Print("plain text") // plain output
ctx.PrintInfo("styled info block") // bordered info block
ctx.PrintError("styled error block") // red error block
ctx.PrintBlock(ext.PrintBlockOpts{ // custom styled block
Text: "content",
BorderColor: "#a6e3a1",
Subtitle: "my-ext",
})
ctx.RenderMessage("renderer-name", "content") // use a registered message renderer
```
### Message Injection
```go
ctx.SendMessage("prompt text") // inject message and trigger agent turn (queued)
ctx.CancelAndSend("new prompt") // cancel current turn, clear queue, send new message
```
### Widgets
Persistent UI elements displayed above or below the input area:
```go
ctx.SetWidget(ext.WidgetConfig{
ID: "my-widget",
Placement: ext.WidgetAbove, // or ext.WidgetBelow
Content: ext.WidgetContent{
Text: "Status: Active",
Markdown: false, // set true for markdown rendering
},
Style: ext.WidgetStyle{
BorderColor: "#a6e3a1", // hex color
NoBorder: false,
},
Priority: 0, // lower values render first
})
ctx.RemoveWidget("my-widget")
```
### Header and Footer
```go
ctx.SetHeader(ext.HeaderFooterConfig{
Content: ext.WidgetContent{Text: "My Header"},
Style: ext.WidgetStyle{BorderColor: "#89b4fa"},
})
ctx.RemoveHeader()
ctx.SetFooter(ext.HeaderFooterConfig{
Content: ext.WidgetContent{Text: "My Footer"},
Style: ext.WidgetStyle{BorderColor: "#585b70"},
})
ctx.RemoveFooter()
```
### Status Bar
```go
ctx.SetStatus("key", "PLAN MODE", 10) // key, text, priority (lower = further left)
ctx.RemoveStatus("key")
```
### Interactive Prompts
These block until the user responds (safe in slash commands and goroutines):
```go
// Selection list
result := ctx.PromptSelect(ext.PromptSelectConfig{
Message: "Pick one:",
Options: []string{"Option A", "Option B", "Option C"},
})
if !result.Cancelled {
// result.Value string, result.Index int
}
// Yes/No confirmation
result := ctx.PromptConfirm(ext.PromptConfirmConfig{
Message: "Are you sure?",
DefaultValue: false,
})
if !result.Cancelled {
// result.Value bool
}
// Text input
result := ctx.PromptInput(ext.PromptInputConfig{
Message: "Enter name:",
Placeholder: "my-project",
Default: "",
})
if !result.Cancelled {
// result.Value string
}
```
### Overlay Dialogs
Modal dialogs with optional action buttons:
```go
result := ctx.ShowOverlay(ext.OverlayConfig{
Title: "Confirmation",
Content: ext.WidgetContent{Text: "Are you sure you want to proceed?", Markdown: true},
Style: ext.OverlayStyle{BorderColor: "#f38ba8"},
Width: 60, // 0 = 60% of terminal width
MaxHeight: 20, // 0 = 80% of terminal height
Anchor: ext.OverlayCenter, // or ext.OverlayTopCenter, ext.OverlayBottomCenter
Actions: []string{"Confirm", "Cancel"},
})
if !result.Cancelled {
// result.Action string, result.Index int
}
```
### Editor Interceptor
Wrap the built-in text input with custom key handling and rendering:
```go
ctx.SetEditor(ext.EditorConfig{
HandleKey: func(key string, currentText string) ext.EditorKeyAction {
if key == "ctrl+s" {
return ext.EditorKeyAction{Type: ext.EditorKeySubmit, SubmitText: currentText}
}
return ext.EditorKeyAction{Type: ext.EditorKeyPassthrough}
},
Render: func(width int, defaultContent string) string {
return "[custom] " + defaultContent
},
})
ctx.ResetEditor() // remove interceptor
ctx.SetEditorText("prefilled") // set editor text content
```
**EditorKeyAction types:**
- `ext.EditorKeyPassthrough` — let the default editor handle the key
- `ext.EditorKeyConsumed` — swallow the key, do nothing
- `ext.EditorKeyRemap` — remap to a different key: `EditorKeyAction{Type: ext.EditorKeyRemap, RemappedKey: "up"}`
- `ext.EditorKeySubmit` — submit text: `EditorKeyAction{Type: ext.EditorKeySubmit, SubmitText: "text"}`
### UI Visibility
```go
ctx.SetUIVisibility(ext.UIVisibility{
HideStartupMessage: true,
HideStatusBar: true,
HideSeparator: true,
HideInputHint: true,
})
```
### Session Data
```go
stats := ctx.GetContextStats() // .EstimatedTokens, .ContextLimit, .UsagePercent, .MessageCount
msgs := ctx.GetMessages() // []ext.SessionMessage on current branch
path := ctx.GetSessionPath() // file path of session JSONL
// Persist custom data in the session tree:
id, err := ctx.AppendEntry("my-type", "data string")
entries := ctx.GetEntries("my-type") // []ext.ExtensionEntry{ID, EntryType, Data, Timestamp}
```
### Model Management
```go
err := ctx.SetModel("anthropic/claude-sonnet-4-20250514")
models := ctx.GetAvailableModels() // []ext.ModelInfoEntry
```
### Tool Management
```go
tools := ctx.GetAllTools() // []ext.ToolInfo{Name, Description, Source, Enabled}
ctx.SetActiveTools([]string{"read", "grep"}) // restrict to these tools only
ctx.SetActiveTools(nil) // re-enable all tools
```
### LLM Completions
Make standalone LLM calls (bypasses the agent tool loop):
```go
resp, err := ctx.Complete(ext.CompleteRequest{
Model: "", // empty = current model
System: "You are ...", // optional system prompt
Prompt: "Summarize...", // the prompt
MaxTokens: 1000, // 0 = provider default
OnChunk: func(chunk string) { /* streaming */ },
})
// resp.Text, resp.InputTokens, resp.OutputTokens, resp.Model
```
### TUI Suspension
Temporarily release the terminal for interactive subprocesses:
```go
ctx.SuspendTUI(func() {
cmd := exec.Command("vim", "file.go")
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Run()
})
```
### Application Control
```go
ctx.Exit() // graceful shutdown
err := ctx.ReloadExtensions() // hot-reload all extensions from disk
```
### Context Fields
```go
ctx.SessionID // string
ctx.CWD // string — current working directory
ctx.Model // string — active model name
ctx.Interactive // bool — true if running in TUI mode
```
---
## Tool Renderers
Customize how tool calls are displayed in the TUI:
```go
api.RegisterToolRenderer(ext.ToolRenderConfig{
ToolName: "bash",
DisplayName: "Shell", // replaces auto-capitalized name
BorderColor: "#89b4fa",
Background: "",
BodyMarkdown: true, // render body through markdown
RenderHeader: func(toolArgs string, width int) string {
var args struct{ Command string `json:"command"` }
json.Unmarshal([]byte(toolArgs), &args)
return "$ " + args.Command
},
RenderBody: func(toolResult string, isError bool, width int) string {
if isError {
return "ERROR: " + toolResult
}
return toolResult
},
})
```
## Message Renderers
Define named output styles for `ctx.RenderMessage()`:
```go
api.RegisterMessageRenderer(ext.MessageRendererConfig{
Name: "success",
Render: func(content string, width int) string {
return " " + content // green checkmark prefix
},
})
// Usage in handlers:
ctx.RenderMessage("success", "All tests passed")
```
---
## Critical Yaegi Constraints
### No Named Function References in Struct Fields
Yaegi has a bug where named function references assigned to struct fields return zero values across the interpreter boundary. Always use anonymous closure literals:
```go
// WRONG - will silently return zero values:
func myHandler(key, text string) ext.EditorKeyAction {
return ext.EditorKeyAction{Type: ext.EditorKeyPassthrough}
}
ctx.SetEditor(ext.EditorConfig{HandleKey: myHandler})
// CORRECT - use anonymous closure:
ctx.SetEditor(ext.EditorConfig{
HandleKey: func(key, text string) ext.EditorKeyAction {
return ext.EditorKeyAction{Type: ext.EditorKeyPassthrough}
},
})
```
This applies to ALL struct fields that take function values: `ToolDef.Execute`, `CommandDef.Execute`, `EditorConfig.HandleKey`, `EditorConfig.Render`, `ToolRenderConfig.RenderHeader`, `ToolRenderConfig.RenderBody`, etc.
### No Interfaces Across the Boundary
All extension-facing API types are concrete structs, never interfaces. Yaegi crashes on interface wrapper generation.
### Package-Level Variables for State
Yaegi supports package-level variables captured in closures. This is the standard way to maintain state across event callbacks:
```go
package main
import "kit/ext"
var callCount int
var lastTool string
func Init(api ext.API) {
api.OnToolResult(func(e ext.ToolResultEvent, ctx ext.Context) *ext.ToolResultResult {
callCount++
lastTool = e.ToolName
return nil
})
}
```
---
## Common Patterns
### Pattern: Tool Call Blocking
Block dangerous operations by intercepting tool calls:
```go
api.OnToolCall(func(tc ext.ToolCallEvent, ctx ext.Context) *ext.ToolCallResult {
if tc.ToolName == "bash" {
var input struct{ Command string `json:"command"` }
json.Unmarshal([]byte(tc.Input), &input)
if strings.Contains(input.Command, "rm -rf") {
return &ext.ToolCallResult{
Block: true,
Reason: "Dangerous command blocked",
}
}
}
return nil
})
```
### Pattern: System Prompt Injection
Augment the agent's behavior by injecting instructions:
```go
api.OnBeforeAgentStart(func(_ ext.BeforeAgentStartEvent, ctx ext.Context) *ext.BeforeAgentStartResult {
prompt := "Always respond with bullet points."
return &ext.BeforeAgentStartResult{SystemPrompt: &prompt}
})
```
### Pattern: Background Processing with SendMessage
Run work in a goroutine and inject results back:
```go
api.RegisterCommand(ext.CommandDef{
Name: "run",
Description: "Run a command in the background",
Execute: func(args string, ctx ext.Context) (string, error) {
go func() {
out, err := exec.Command("sh", "-c", args).CombinedOutput()
if err != nil {
ctx.SendMessage(fmt.Sprintf("Command failed: %s\n%s", err, out))
return
}
ctx.SendMessage(fmt.Sprintf("Command output:\n```\n%s\n```", out))
}()
return "Running in background...", nil
},
})
```
### Pattern: Ephemeral Context Injection
Inject information into every LLM turn without persisting in session history:
```go
api.OnContextPrepare(func(e ext.ContextPrepareEvent, ctx ext.Context) *ext.ContextPrepareResult {
data, err := os.ReadFile(".kit/context.md")
if err != nil {
return nil
}
injected := ext.ContextMessage{
Index: -1, // -1 = new message, not from session
Role: "system",
Content: string(data),
}
msgs := append([]ext.ContextMessage{injected}, e.Messages...)
return &ext.ContextPrepareResult{Messages: msgs}
})
```
### Pattern: Live Widget Updates
Update a widget periodically from a goroutine:
```go
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
go func() {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for range ticker.C {
ctx.SetWidget(ext.WidgetConfig{
ID: "clock",
Placement: ext.WidgetAbove,
Content: ext.WidgetContent{Text: time.Now().Format("15:04:05")},
Style: ext.WidgetStyle{BorderColor: "#89b4fa"},
})
}
}()
})
```
### Pattern: Spawning Kit as a Sub-Agent
Extensions can spawn Kit as a subprocess for delegation:
```bash
kit --quiet --no-session --no-extensions --system-prompt "You are a reviewer" --model anthropic/claude-sonnet-4-20250514 "Review this code"
```
Key flags: `--quiet` (stdout only, no TUI), `--no-session` (ephemeral), `--no-extensions` (prevent recursion), `--system-prompt` (string or file path).
---
## Testing Extensions
```bash
# Validate syntax of all discovered extensions
kit extensions validate
# List loaded extensions
kit extensions list
# Run with a specific extension
kit -e path/to/extension.go
# Run with multiple extensions
kit -e ext1.go -e ext2.go
# Disable all extensions
kit --no-extensions
# Generate an example extension scaffold
kit extensions init
```
---
## Complete Example: Plan Mode
A full extension that restricts the agent to read-only tools, with a slash command, keyboard shortcut, option, status bar indicator, and system prompt injection:
```go
//go:build ignore
package main
import (
"strings"
"kit/ext"
)
func Init(api ext.API) {
readOnlyTools := []string{"read", "grep", "find", "ls"}
var planActive bool
api.RegisterOption(ext.OptionDef{
Name: "plan",
Description: "Start in plan mode (read-only tools)",
Default: "false",
})
api.RegisterShortcut(ext.ShortcutDef{
Key: "ctrl+alt+p",
Description: "Toggle plan/explore mode",
}, func(ctx ext.Context) {
planActive = !planActive
applyMode(ctx, planActive, readOnlyTools)
})
api.RegisterCommand(ext.CommandDef{
Name: "plan",
Description: "Toggle plan/explore mode",
Execute: func(args string, ctx ext.Context) (string, error) {
planActive = !planActive
applyMode(ctx, planActive, readOnlyTools)
return "", nil
},
})
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
if strings.ToLower(ctx.GetOption("plan")) == "true" {
planActive = true
applyMode(ctx, true, readOnlyTools)
}
})
api.OnBeforeAgentStart(func(_ ext.BeforeAgentStartEvent, ctx ext.Context) *ext.BeforeAgentStartResult {
if !planActive {
return nil
}
prompt := `You are in PLAN MODE (read-only). You can ONLY read and search.
Focus on understanding, analysis, and generating plans.`
return &ext.BeforeAgentStartResult{SystemPrompt: &prompt}
})
}
func applyMode(ctx ext.Context, active bool, tools []string) {
if active {
ctx.SetActiveTools(tools)
ctx.SetStatus("plan-mode", "PLAN MODE (read-only)", 10)
ctx.PrintInfo("Plan mode ON")
} else {
ctx.SetActiveTools(nil)
ctx.RemoveStatus("plan-mode")
ctx.PrintInfo("Plan mode OFF")
}
}
```
## Key Files for Reference
- `internal/extensions/api.go` — Complete API type definitions
- `internal/extensions/runner.go` — Event dispatch and state management
- `internal/extensions/loader.go` — Yaegi interpreter setup
- `internal/extensions/symbols.go` — All types exported to extensions
- `examples/extensions/` — 25+ working example extensions