From e613a077736c7d874394e56685b7e9402a742200 Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Mon, 9 Mar 2026 21:41:10 +0300 Subject: [PATCH] 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 --- README.md | 20 +++ btca.config.jsonc | 14 +- cmd/acp.go | 67 +++++++++ go.mod | 1 + go.sum | 2 + internal/acpserver/agent.go | 253 ++++++++++++++++++++++++++++++++++ internal/acpserver/session.go | 162 ++++++++++++++++++++++ 7 files changed, 518 insertions(+), 1 deletion(-) create mode 100644 cmd/acp.go create mode 100644 internal/acpserver/agent.go create mode 100644 internal/acpserver/session.go diff --git a/README.md b/README.md index 2d225c0d..ff72002e 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/btca.config.jsonc b/btca.config.jsonc index e63f35ab..17cb362b 100644 --- a/btca.config.jsonc +++ b/btca.config.jsonc @@ -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" -} +} \ No newline at end of file diff --git a/cmd/acp.go b/cmd/acp.go new file mode 100644 index 00000000..a3650b06 --- /dev/null +++ b/cmd/acp.go @@ -0,0 +1,67 @@ +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, + }))) + } + + 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) + + 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 +} diff --git a/go.mod b/go.mod index d0754e55..f34f973a 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 4eed97f2..6b9eaedc 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/acpserver/agent.go b/internal/acpserver/agent.go new file mode 100644 index 00000000..1deaf55b --- /dev/null +++ b/internal/acpserver/agent.go @@ -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} +} diff --git a/internal/acpserver/session.go b/internal/acpserver/session.go new file mode 100644 index 00000000..2e277bfd --- /dev/null +++ b/internal/acpserver/session.go @@ -0,0 +1,162 @@ +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 +} + +// load opens an existing Kit session by scanning for a matching session ID +// in the given working directory. +func (r *sessionRegistry) load(ctx context.Context, acpSessionID string, cwd string) (*acpSession, error) { + // Find the session file by scanning the session directory. + sessions, err := kit.ListSessions(cwd) + if err != nil { + return nil, fmt.Errorf("list sessions: %w", err) + } + + var sessionPath string + for _, s := range sessions { + if s.ID == acpSessionID { + sessionPath = s.Path + break + } + } + if sessionPath == "" { + return nil, fmt.Errorf("session not found: %s", acpSessionID) + } + + kitInstance, err := kit.New(ctx, &kit.Options{ + SessionPath: sessionPath, + Quiet: true, + Streaming: true, + }) + if err != nil { + return nil, fmt.Errorf("open kit session: %w", err) + } + + sess := &acpSession{ + kit: kitInstance, + cwd: cwd, + sessionID: acpSessionID, + } + + r.mu.Lock() + r.sessions[acpSessionID] = 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 +} + +// remove closes and removes a session from the registry. +func (r *sessionRegistry) remove(sessionID string) { + r.mu.Lock() + sess, ok := r.sessions[sessionID] + if ok { + delete(r.sessions, sessionID) + } + r.mu.Unlock() + + if ok && sess.kit != nil { + _ = sess.kit.Close() + } +} + +// 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 +}