2026-03-09 21:41:10 +03:00
package acpserver
import (
"context"
"fmt"
2026-03-15 12:38:23 +03:00
"strings"
2026-03-09 21:41:10 +03:00
"sync"
2026-03-15 15:29:08 +03:00
"github.com/charmbracelet/log"
2026-06-02 14:41:35 +03:00
"github.com/spf13/viper"
2026-03-15 15:29:08 +03:00
2026-05-07 12:23:15 +03:00
"github.com/mark3labs/kit/internal/extbridge"
2026-03-15 15:29:08 +03:00
"github.com/mark3labs/kit/internal/extensions"
2026-03-09 21:41:10 +03:00
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 ) {
2026-06-02 14:41:35 +03:00
// Each ACP session gets its own isolated config store (CLI is left nil) so
// per-session SetModel / SetThinkingLevel calls cannot race or bleed across
// the sessionRegistry. We seed the relevant root-command flag values from
// the process-global store (which cobra populated from flags) so launching
// `kit acp -m <model> [--thinking-level ...] [--provider-url ...]` is still
// honored; .kit.yml and KIT_* env vars are loaded per session by kit.New.
streamOn := true
2026-03-09 21:41:10 +03:00
kitInstance , err := kit . New ( ctx , & kit . Options {
2026-06-02 14:41:35 +03:00
SessionDir : cwd ,
Quiet : true ,
Streaming : & streamOn ,
Model : viper . GetString ( "model" ) ,
ThinkingLevel : viper . GetString ( "thinking-level" ) ,
ProviderURL : viper . GetString ( "provider-url" ) ,
ProviderAPIKey : viper . GetString ( "provider-api-key" ) ,
2026-03-09 21:41:10 +03:00
} )
if err != nil {
2026-03-15 12:38:23 +03:00
// 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 )
}
2026-03-09 21:41:10 +03:00
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" )
}
2026-03-15 15:29:08 +03:00
// Wire extension context with headless implementations so extensions
// work in ACP mode. TUI-dependent features (widgets, prompts, editor)
2026-06-11 16:13:18 +03:00
// become no-ops or return cancelled; all data/model/tool APIs come from
// extbridge.BaseContext and work identically to interactive mode.
2026-03-29 13:19:51 +03:00
if kitInstance . Extensions ( ) . HasExtensions ( ) {
2026-06-11 16:13:18 +03:00
// Use a background context for subagent spawns: the create() ctx is
// request-scoped and may be cancelled before extensions spawn anything.
ec := extbridge . BaseContext ( context . Background ( ) , kitInstance )
ec . SessionID = sessionID
ec . CWD = cwd
ec . Model = kitInstance . GetModelString ( )
ec . Interactive = false
// Output — route through structured logger.
ec . Print = func ( text string ) { log . Debug ( "extension: print" , "text" , text ) }
ec . PrintInfo = func ( text string ) { log . Info ( "extension: info" , "text" , text ) }
ec . PrintError = func ( text string ) { log . Error ( "extension: error" , "text" , text ) }
ec . PrintBlock = func ( opts extensions . PrintBlockOpts ) {
log . Info ( "extension: block" , "subtitle" , opts . Subtitle , "text" , opts . Text )
}
// Message injection — no-ops for now; ACP clients drive prompts.
ec . SendMessage = func ( string ) { }
ec . CancelAndSend = func ( string ) { }
ec . Exit = func ( ) { }
// TUI widgets/chrome — silent no-ops (no TUI in ACP).
ec . SetWidget = func ( extensions . WidgetConfig ) { }
ec . RemoveWidget = func ( string ) { }
ec . SetHeader = func ( extensions . HeaderFooterConfig ) { }
ec . RemoveHeader = func ( ) { }
ec . SetFooter = func ( extensions . HeaderFooterConfig ) { }
ec . RemoveFooter = func ( ) { }
ec . SetEditor = func ( extensions . EditorConfig ) { }
ec . ResetEditor = func ( ) { }
ec . SetEditorText = func ( string ) { }
ec . SetUIVisibility = func ( extensions . UIVisibility ) { }
ec . SetStatus = func ( string , string , int ) { }
ec . RemoveStatus = func ( string ) { }
// Interactive prompts — return cancelled (no user to prompt).
ec . PromptSelect = func ( extensions . PromptSelectConfig ) extensions . PromptSelectResult {
return extensions . PromptSelectResult { Cancelled : true }
}
ec . PromptConfirm = func ( extensions . PromptConfirmConfig ) extensions . PromptConfirmResult {
return extensions . PromptConfirmResult { Cancelled : true }
}
ec . PromptInput = func ( extensions . PromptInputConfig ) extensions . PromptInputResult {
return extensions . PromptInputResult { Cancelled : true }
}
ec . ShowOverlay = func ( extensions . OverlayConfig ) extensions . OverlayResult {
return extensions . OverlayResult { Cancelled : true , Index : - 1 }
}
ec . SuspendTUI = func ( callback func ( ) ) error { callback ( ) ; return nil }
// Render — fall back to logging.
ec . RenderMessage = func ( name , content string ) {
renderer := kitInstance . Extensions ( ) . GetMessageRenderer ( name )
if renderer != nil && renderer . Render != nil {
content = renderer . Render ( content , 80 )
}
log . Info ( "extension: message" , "renderer" , name , "content" , content )
}
kitInstance . Extensions ( ) . SetContext ( ec )
2026-03-29 13:19:51 +03:00
kitInstance . Extensions ( ) . EmitSessionStart ( )
2026-03-15 15:29:08 +03:00
}
2026-03-09 21:41:10 +03:00
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 )
}
}
2026-05-04 16:23:12 +03:00
// remove closes and removes a single session by ID.
func ( r * sessionRegistry ) remove ( sessionID string ) {
r . mu . Lock ( )
defer r . mu . Unlock ( )
sess , ok := r . sessions [ sessionID ]
if ! ok {
return
}
if sess . kit != nil {
_ = sess . kit . Close ( )
}
delete ( r . sessions , sessionID )
}
2026-03-09 21:41:10 +03:00
// 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
}