mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-13 19:20:06 +00:00
e8e99b19a8
* Remove dead code: 5 unused symbols across internal packages
- internal/models: LoadModelSettingsFromConfig (zero refs)
- internal/prompts: PromptTemplate.ExpandWithArgs (zero refs)
- internal/app: NewMessageStore (tests migrated to NewMessageStoreWithMessages)
- internal/config: HasEnvVars (+ its test)
- internal/core: ContextWithSudoPassword (test migrated to context.WithValue)
* pkg/kit: use TreeManager alias in exported signatures
NewTreeManagerAdapter and InitTreeSession now spell their signatures with
the public kit.TreeManager alias instead of internal/session.TreeManager,
so go doc renders domain types rather than internal paths.
* Consolidate tool-kind classification into internal/extensions
coreToolKinds + toolKindFor were duplicated verbatim in
internal/extensions/wrapper.go and pkg/kit/events.go, risking silent
divergence between extension events and SDK events. Single source of
truth now lives in internal/extensions/toolkinds.go; pkg/kit re-exports
the constants.
* Consolidate Anthropic OAuth detection and usage-tracker refresh
The 'is the active Anthropic credential a stored OAuth token' check was
copy-pasted at 5 sites, all prefix-matching the magic string
'stored OAuth' produced in internal/auth. Now:
- internal/auth: new CredentialSourceOAuth constant + IsAnthropicOAuth()
- internal/ui: new UpdateUsageTrackerForModel(); CreateUsageTracker and
SetupCLI share lookupTrackableModel (SetupCLI no longer re-inlines the
tracker construction)
- cmd/root.go + cmd/extension_context.go: verbatim-duplicated tracker
refresh blocks replaced with ui.UpdateUsageTrackerForModel
- pkg/kit isAnthropicOAuth delegates to auth.IsAnthropicOAuth
- internal/models compares source against the constant
* pkg/kit: consolidate model-path helpers and argument tokenizer
- ExtractModelFromPath mis-parsed model IDs containing '/' (e.g.
'openrouter/meta/llama' -> 'meta'); it now delegates to
RemoveProviderFromModel and is deprecated alongside
ExtractProviderFromPath (-> GetCurrentProvider)
- parseFields delegated to prompts.ParseCommandArgs so extension argument
parsing and builtin prompt-template parsing share one quote/escape
grammar; ParseCommandArgs now also splits on tabs (superset of both
previous tokenizers)
* Unify the two {{variable}} template engines
internal/skills and pkg/kit/template_bridge each had their own grammar:
skills rejected '{{ name }}' (whitespace) but allowed digit-first names;
the bridge was the opposite. A template behaved differently depending on
whether it was loaded as a skill prompt or via the extension API.
internal/skills is now the single engine using the superset grammar
(\{\{\s*(\w+)\s*\}\}); pkg/kit ParseTemplate/RenderTemplate are thin
adapters over it. Expand is now regex-based so whitespace placeholders
expand consistently; missing variables are still left as-is.
* internal/ui: extract switchModel helper for model-switch flow
The model-selector handler (ModelSelectedMsg) and /model slash command
duplicated the full switch sequence (thinking-level fallback, setModel,
display-state update, preference persistence, ModelChange emit) and had
already drifted in ordering. Both now call a single switchModel method.
Display state is still updated directly (no prog.Send from Update).
* extbridge: extract shared BaseContext for extension wiring
cmd/extension_context.go and internal/acpserver/session.go each built a
giant extensions.Context literal, duplicating ~15 delegation closures
(GetContextStats, GetMessages, AppendEntry, options, SetModel core,
Complete, SpawnSubagent, ...) that had to be kept in sync by hand. New
data-access fields had to be wired in both places or ACP-mode extensions
silently got nil function fields.
extbridge.BaseContext now provides the headless half; both call sites
overlay only their UI-specific closures. As a side effect ACP mode gains
previously-missing APIs (state, tree navigation, skills, template
parsing, model resolution) that were nil before. The interactive TUI
keeps its exact SetModel/ReloadExtensions ordering via overrides.
* internal/tools: extract withOAuthRetry and marshalToolResult helpers
ExecuteTool repeated the OAuth-error/re-auth/retry stanza verbatim twice
(sync and task-augmented paths) and the marshal-and-wrap stanza four
times. Both are now single helpers with identical error strings, so a
fix to OAuth retry or error categorization applies everywhere at once.
* internal/ui: extract buildShareFile with defer-based cleanup
handleShareCommand repeated the close/remove/print/return cleanup chain
four times across its temp-file write error paths. File assembly now
lives in buildShareFile with a single deferred cleanup on error.
* cmd: extract flag validation, preference restore, and provider-URL routing from runNormalMode
runNormalMode opened with ~150 lines of policy logic (flag-combination
validation, persisted model/thinking-level preference restoration, and
two subtle --provider-url model-rewrite rules). These are now standalone
functions (validateModeFlags, restorePersistedPreferences,
applyProviderURLRouting) so the routing policy is independently readable
and testable. Behaviour unchanged; ordering preserved.
* fix: address review findings on SDK godoc and nil guard
- pkg/kit: remove internal package paths from exported godoc on
ParseTemplate and the ToolKind* constants (SDK doc surface must not
reference internal packages)
- internal/tools: guard marshalToolResult against a nil CallToolResult
(json.Marshal(nil) succeeds as 'null', then result.IsError panics if
a client returns nil result with nil error)
Skipped the TreeNode Children deep-copy suggestion: the slice already
comes from TreeManager.GetChildren which returns a fresh copy per call
into a throwaway intermediate, so no internal state is exposed.
213 lines
6.9 KiB
Go
213 lines
6.9 KiB
Go
package acpserver
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/charmbracelet/log"
|
|
"github.com/spf13/viper"
|
|
|
|
"github.com/mark3labs/kit/internal/extbridge"
|
|
"github.com/mark3labs/kit/internal/extensions"
|
|
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) {
|
|
// 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
|
|
kitInstance, err := kit.New(ctx, &kit.Options{
|
|
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"),
|
|
})
|
|
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)
|
|
}
|
|
|
|
sessionID := kitInstance.GetSessionID()
|
|
if sessionID == "" {
|
|
_ = kitInstance.Close()
|
|
return nil, fmt.Errorf("kit instance has no session ID")
|
|
}
|
|
|
|
// Wire extension context with headless implementations so extensions
|
|
// work in ACP mode. TUI-dependent features (widgets, prompts, editor)
|
|
// become no-ops or return cancelled; all data/model/tool APIs come from
|
|
// extbridge.BaseContext and work identically to interactive mode.
|
|
if kitInstance.Extensions().HasExtensions() {
|
|
// 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)
|
|
kitInstance.Extensions().EmitSessionStart()
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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
|
|
}
|