Compare commits

...

7 Commits

Author SHA1 Message Date
Ed Zynda 6a6d201a50 add LSP diagnostics example extension
Adds an extension that starts language servers on demand and surfaces
diagnostics after file edits, following crush's LSP integration pattern.
Hooks into the edit tool lifecycle to diff pre/post diagnostics, display
a persistent widget, and expose lsp_diagnostics/lsp_hover tools plus
/lsp and /lsp-check slash commands.
2026-03-15 14:29:27 +03:00
Ed Zynda 930cbcb4f2 fix: use full GitHub URLs for file references in kit-extensions skill 2026-03-15 13:01:05 +03:00
Ed Zynda 12e1ef2036 skills 2026-03-15 12:55:47 +03:00
Ed Zynda a05da5f3ab fix(auth): support OAuth credentials in ACP mode and auto-refresh tokens
Remove the early ValidateEnvironment gate from CreateProvider that only
checked env vars and --provider-api-key, blocking stored OAuth credentials
from working. Each provider creation function already handles its own auth
resolution with clear error messages.

Update ValidateEnvironment to also check stored Anthropic credentials so
the model selector UI correctly shows Anthropic models for OAuth users.

Add automatic token refresh in oauthTransport so long-lived ACP sessions
survive token renewals. Surface actionable auth error messages in ACP
session creation.

Fix pre-existing staticcheck SA5011 warnings in test files.
2026-03-15 12:38:23 +03:00
Ed Zynda fefbf19b42 fix(acp): default mcpServers to empty array for clients that omit it 2026-03-15 11:57:30 +03:00
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
14 changed files with 2218 additions and 21 deletions
+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`](https://github.com/mark3labs/kit/blob/main/internal/extensions/api.go) — Complete API type definitions
- [`internal/extensions/runner.go`](https://github.com/mark3labs/kit/blob/main/internal/extensions/runner.go) — Event dispatch and state management
- [`internal/extensions/loader.go`](https://github.com/mark3labs/kit/blob/main/internal/extensions/loader.go) — Yaegi interpreter setup
- [`internal/extensions/symbols.go`](https://github.com/mark3labs/kit/blob/main/internal/extensions/symbols.go) — All types exported to extensions
- [`examples/extensions/`](https://github.com/mark3labs/kit/tree/main/examples/extensions) — 25+ working example extensions
+96 -4
View File
@@ -1,7 +1,11 @@
package cmd
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"io"
"log/slog"
"os"
"os/signal"
@@ -37,8 +41,10 @@ func runACP(cmd *cobra.Command, _ []string) error {
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)
// writes responses to stdout. We wrap stdin with a normalizer that
// fills in optional fields the SDK's generated validation requires
// (e.g. mcpServers) so clients that omit them still work.
conn := acp.NewAgentSideConnection(agent, os.Stdout, newACPNormalizer(os.Stdin))
// Wire the connection back to the agent so it can send session updates.
agent.SetAgentConnection(conn)
@@ -50,8 +56,6 @@ func runACP(cmd *cobra.Command, _ []string) error {
})))
}
fmt.Fprintln(os.Stderr, "kit: ACP server ready on stdio")
// Wait for either the client to disconnect or a signal.
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
@@ -65,3 +69,91 @@ func runACP(cmd *cobra.Command, _ []string) error {
return nil
}
// acpNormalizer wraps an io.Reader carrying newline-delimited JSON-RPC and
// patches incoming messages so that fields the SDK validates as required —
// but that some clients (e.g. Zed) omit — are defaulted. This avoids
// InvalidParams errors without forking the SDK.
type acpNormalizer struct {
scanner *bufio.Scanner
buf bytes.Buffer // leftover bytes from the last normalized line
}
func newACPNormalizer(r io.Reader) *acpNormalizer {
const maxMsg = 10 * 1024 * 1024 // 10 MB, matches SDK buffer
s := bufio.NewScanner(r)
s.Buffer(make([]byte, 0, 1024*1024), maxMsg)
return &acpNormalizer{scanner: s}
}
// Read satisfies io.Reader. It feeds one normalized JSON line (plus newline)
// per underlying scan, buffering across short caller reads.
func (n *acpNormalizer) Read(p []byte) (int, error) {
// Drain any leftover bytes from the previous line first.
if n.buf.Len() > 0 {
return n.buf.Read(p)
}
if !n.scanner.Scan() {
if err := n.scanner.Err(); err != nil {
return 0, err
}
return 0, io.EOF
}
line := n.scanner.Bytes()
normalized := normalizeACPLine(line)
n.buf.Write(normalized)
n.buf.WriteByte('\n')
return n.buf.Read(p)
}
// normalizeACPLine ensures session/new and session/load params contain an
// mcpServers array. Returns the original line unchanged for all other methods.
func normalizeACPLine(line []byte) []byte {
// Quick check: if it already contains mcpServers, nothing to do.
if bytes.Contains(line, []byte(`"mcpServers"`)) {
return line
}
// Only bother parsing if the method could be session/new or session/load.
if !bytes.Contains(line, []byte(`"session/new"`)) &&
!bytes.Contains(line, []byte(`"session/load"`)) {
return line
}
var msg struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id,omitempty"`
Method string `json:"method"`
Params json.RawMessage `json:"params,omitempty"`
}
if err := json.Unmarshal(line, &msg); err != nil {
return line
}
if msg.Method != "session/new" && msg.Method != "session/load" {
return line
}
// Patch params to include mcpServers: [].
var params map[string]json.RawMessage
if err := json.Unmarshal(msg.Params, &params); err != nil {
return line
}
if _, ok := params["mcpServers"]; ok {
return line
}
params["mcpServers"] = json.RawMessage(`[]`)
patched, err := json.Marshal(params)
if err != nil {
return line
}
msg.Params = patched
out, err := json.Marshal(msg)
if err != nil {
return line
}
return out
}
File diff suppressed because it is too large Load Diff
+1
View File
@@ -90,6 +90,7 @@ func (a *Agent) NewSession(ctx context.Context, params acp.NewSessionRequest) (a
sess, err := a.registry.create(ctx, cwd)
if err != nil {
log.Error("acp: session creation failed", "cwd", cwd, "error", err)
return acp.NewSessionResponse{}, fmt.Errorf("create session: %w", err)
}
+7
View File
@@ -3,6 +3,7 @@ package acpserver
import (
"context"
"fmt"
"strings"
"sync"
kit "github.com/mark3labs/kit/pkg/kit"
@@ -39,6 +40,12 @@ func (r *sessionRegistry) create(ctx context.Context, cwd string) (*acpSession,
Streaming: true,
})
if err != nil {
// Provide actionable guidance for provider auth errors, which are
// the most common failure mode when running via ACP.
msg := err.Error()
if strings.Contains(msg, "API key") || strings.Contains(msg, "credentials") || strings.Contains(msg, "OAuth") {
return nil, fmt.Errorf("provider authentication failed: %w — run 'kit auth login <provider>' or set the appropriate environment variable before starting 'kit acp'", err)
}
return nil, fmt.Errorf("create kit instance: %w", err)
}
+2
View File
@@ -51,6 +51,7 @@ func TestCredentialManager(t *testing.T) {
}
if creds == nil {
t.Fatal("Expected credentials to be returned")
return
}
if creds.APIKey != testAPIKey {
t.Errorf("Expected API key %s, got %s", testAPIKey, creds.APIKey)
@@ -236,6 +237,7 @@ func TestCredentialStorePersistence(t *testing.T) {
}
if creds == nil {
t.Fatal("Expected credentials to persist")
return
}
if creds.APIKey != testAPIKey {
t.Errorf("Expected API key %s, got %s", testAPIKey, creds.APIKey)
+18 -5
View File
@@ -210,10 +210,11 @@ func CreateProvider(ctx context.Context, config *ProviderConfig) (*ProviderResul
}
}
// Validate environment variables
if err := registry.ValidateEnvironment(provider, config.ProviderAPIKey); err != nil {
return nil, err
}
// NOTE: We intentionally skip registry.ValidateEnvironment() here.
// Each create*Provider function handles its own auth resolution and
// produces provider-specific error messages. The early env-var check
// was too narrow — it didn't account for stored credentials (e.g.
// OAuth tokens from 'kit auth login') and blocked valid auth paths.
// Validate config against known model limits when metadata is available
if modelInfo != nil {
@@ -1042,9 +1043,21 @@ type oauthTransport struct {
}
func (t *oauthTransport) RoundTrip(req *http.Request) (*http.Response, error) {
// Resolve the freshest available token. The credential manager
// automatically refreshes tokens nearing expiry (5-minute buffer).
// This keeps long-lived sessions (e.g. ACP) working across token
// renewals. Falls back to the originally-provided token if the
// credential manager is unavailable.
token := t.accessToken
if cm, err := auth.NewCredentialManager(); err == nil {
if fresh, err := cm.GetValidAccessToken(); err == nil && fresh != "" {
token = fresh
}
}
newReq := req.Clone(req.Context())
newReq.Header.Del("x-api-key")
newReq.Header.Set("Authorization", "Bearer "+t.accessToken)
newReq.Header.Set("Authorization", "Bearer "+token)
newReq.Header.Set("anthropic-beta", "oauth-2025-04-20")
newReq.Header.Set("anthropic-version", "2023-06-01")
+1
View File
@@ -78,6 +78,7 @@ func TestCreateOAuthHTTPClient(t *testing.T) {
if client == nil {
t.Fatal("expected non-nil client")
return
}
// Check that the transport is an oauthTransport
+18 -3
View File
@@ -6,6 +6,8 @@ import (
"fmt"
"os"
"strings"
"github.com/mark3labs/kit/internal/auth"
)
//go:embed embedded_models.json
@@ -171,14 +173,27 @@ func (r *ModelsRegistry) GetRequiredEnvVars(provider string) ([]string, error) {
return providerInfo.Env, nil
}
// ValidateEnvironment checks if required environment variables are set.
// Returns nil for providers not in the registry (unknown providers are
// assumed to handle auth themselves or via --provider-api-key).
// ValidateEnvironment checks if required credentials are available for a
// provider. It checks the explicit API key, stored credentials (for
// providers that support them, such as Anthropic OAuth), and environment
// variables. Returns nil for providers not in the registry (unknown
// providers are assumed to handle auth themselves or via --provider-api-key).
func (r *ModelsRegistry) ValidateEnvironment(provider string, apiKey string) error {
if apiKey != "" {
return nil
}
// For anthropic, also check stored credentials (OAuth / API key)
// since auth resolution goes through the credential manager, not
// just environment variables.
if provider == "anthropic" {
if cm, err := auth.NewCredentialManager(); err == nil {
if has, _ := cm.HasAnthropicCredentials(); has {
return nil
}
}
}
envVars, err := r.GetRequiredEnvVars(provider)
if err != nil {
// Unknown provider — nothing to validate
+6
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 {
+114 -9
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
@@ -118,12 +137,34 @@ type StreamComponent struct {
// 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
@@ -184,13 +230,24 @@ func (s *StreamComponent) Reset() {
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,19 +325,31 @@ 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 {
@@ -294,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.
@@ -315,6 +410,8 @@ func (s *StreamComponent) render() string {
}
if len(sections) == 0 {
s.renderCache = ""
s.renderDirty = false
return ""
}
@@ -330,6 +427,8 @@ func (s *StreamComponent) render() string {
}
}
s.renderCache = content
s.renderDirty = false
return content
}
@@ -360,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
+2
View File
@@ -24,6 +24,7 @@ func TestUsageTracker_OAuthCosts(t *testing.T) {
stats := regularTracker.GetLastRequestStats()
if stats == nil {
t.Fatal("Expected stats to be non-nil")
return
}
// Check that costs are calculated for regular API key
@@ -48,6 +49,7 @@ func TestUsageTracker_OAuthCosts(t *testing.T) {
oauthStats := oauthTracker.GetLastRequestStats()
if oauthStats == nil {
t.Fatal("Expected OAuth stats to be non-nil")
return
}
// Check that all costs are $0 for OAuth
+5
View File
@@ -24,6 +24,7 @@ func TestHookRegistry_RegisterAndRun(t *testing.T) {
got := hr.run("hello")
if got == nil {
t.Fatal("expected non-nil result")
return
}
if *got != "handled: hello" {
t.Errorf("expected 'handled: hello', got %q", *got)
@@ -51,6 +52,7 @@ func TestHookRegistry_FirstNonNilWins(t *testing.T) {
got := hr.run("test")
if got == nil {
t.Fatal("expected non-nil result")
return
}
if *got != "second: test" {
t.Errorf("expected 'second: test', got %q", *got)
@@ -77,6 +79,7 @@ func TestHookRegistry_PriorityOrdering(t *testing.T) {
got := hr.run("x")
if got == nil {
t.Fatal("expected non-nil result")
return
}
if *got != "high" {
t.Errorf("expected 'high' (priority 0 runs first), got %q", *got)
@@ -441,6 +444,7 @@ func TestBeforeTurnHook_PromptOverride(t *testing.T) {
result := hr.run(BeforeTurnHook{Prompt: "original"})
if result == nil {
t.Fatal("expected non-nil result")
return
}
if result.Prompt == nil || *result.Prompt != "modified prompt" {
t.Errorf("expected prompt override, got %v", result.Prompt)
@@ -462,6 +466,7 @@ func TestBeforeTurnHook_InjectSystemAndContext(t *testing.T) {
result := hr.run(BeforeTurnHook{Prompt: "hello"})
if result == nil {
t.Fatal("expected non-nil result")
return
}
if result.SystemPrompt == nil || *result.SystemPrompt != "be concise" {
t.Errorf("expected system prompt injection")
+5
View File
@@ -5,6 +5,11 @@
"source": "davis7dotsh/better-context",
"sourceType": "github",
"computedHash": "99bc5301f4f839a6f3be99d98955f32f1cd576c218731fa05fa54a003bd20e9b"
},
"kit-extensions": {
"source": "mark3labs/kit",
"sourceType": "github",
"computedHash": "9347a88bec46dd52727a672b6c8d058955f9f50dfe98708e0c63b85e0779ba96"
}
}
}