1413 lines
41 KiB
Go
1413 lines
41 KiB
Go
package claudecode
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"github.com/chenhg5/cc-connect/core"
|
|
)
|
|
|
|
func init() {
|
|
core.RegisterAgent("claudecode", New)
|
|
}
|
|
|
|
// Agent drives Claude Code CLI using --input-format stream-json
|
|
// and --permission-prompt-tool stdio for bidirectional communication.
|
|
//
|
|
// Permission modes (maps to Claude's --permission-mode):
|
|
// - "default": every tool call requires user approval
|
|
// - "acceptEdits": auto-approve file edit tools, ask for others
|
|
// - "plan": plan only, no execution until approved
|
|
// - "auto": Claude's automatic permission classifier
|
|
// - "bypassPermissions": auto-approve everything (alias: yolo)
|
|
type Agent struct {
|
|
workDir string
|
|
cliBin string // CLI binary name or path (default: "claude")
|
|
cliExtraArgs []string // extra args parsed from cli_path (e.g. ["code", "-t", "foo"])
|
|
configEnv []string // env vars from [projects.agent.options.env] — persists across SetSessionEnv calls
|
|
cliArgsFlag string // if set, claude args are passed as a single string via this flag (e.g. "-a")
|
|
model string
|
|
reasoningEffort string // "low" | "medium" | "high" | "max"
|
|
mode string // "default" | "acceptEdits" | "plan" | "auto" | "bypassPermissions" | "dontAsk"
|
|
allowedTools []string
|
|
disallowedTools []string
|
|
maxContextTokens int // optional: passed as --max-context-tokens when > 0
|
|
providers []core.ProviderConfig
|
|
activeIdx int // -1 = no provider set
|
|
sessionEnv []string
|
|
routerURL string // Claude Code Router URL (e.g., "http://127.0.0.1:3456")
|
|
routerAPIKey string // Claude Code Router API key (optional)
|
|
systemPrompt string // Custom system prompt to pass to Claude CLI
|
|
|
|
providerProxy *core.ProviderProxy // local proxy for third-party providers
|
|
proxyLocalURL string // local URL of the proxy
|
|
platformPrompt string // platform-specific formatting instructions
|
|
|
|
// spawnOpts controls OS-user isolation via run_as_user. Zero value
|
|
// means legacy spawn as the supervisor user. See core/runas.go.
|
|
spawnOpts core.SpawnOptions
|
|
|
|
mu sync.RWMutex
|
|
}
|
|
|
|
var claudeProviderManagedEnvVars = map[string]struct{}{
|
|
"CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST": {},
|
|
"CLAUDE_CODE_USE_BEDROCK": {},
|
|
"CLAUDE_CODE_USE_VERTEX": {},
|
|
"CLAUDE_CODE_USE_FOUNDRY": {},
|
|
"ANTHROPIC_BASE_URL": {},
|
|
"ANTHROPIC_BEDROCK_BASE_URL": {},
|
|
"ANTHROPIC_VERTEX_BASE_URL": {},
|
|
"ANTHROPIC_FOUNDRY_BASE_URL": {},
|
|
"ANTHROPIC_FOUNDRY_RESOURCE": {},
|
|
"ANTHROPIC_VERTEX_PROJECT_ID": {},
|
|
"CLOUD_ML_REGION": {},
|
|
"ANTHROPIC_API_KEY": {},
|
|
"ANTHROPIC_AUTH_TOKEN": {},
|
|
"CLAUDE_CODE_OAUTH_TOKEN": {},
|
|
"AWS_BEARER_TOKEN_BEDROCK": {},
|
|
"ANTHROPIC_FOUNDRY_API_KEY": {},
|
|
"CLAUDE_CODE_SKIP_BEDROCK_AUTH": {},
|
|
"CLAUDE_CODE_SKIP_VERTEX_AUTH": {},
|
|
"CLAUDE_CODE_SKIP_FOUNDRY_AUTH": {},
|
|
"ANTHROPIC_MODEL": {},
|
|
"ANTHROPIC_DEFAULT_HAIKU_MODEL": {},
|
|
"ANTHROPIC_DEFAULT_HAIKU_MODEL_DESCRIPTION": {},
|
|
"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME": {},
|
|
"ANTHROPIC_DEFAULT_HAIKU_MODEL_SUPPORTED_CAPABILITIES": {},
|
|
"ANTHROPIC_DEFAULT_OPUS_MODEL": {},
|
|
"ANTHROPIC_DEFAULT_OPUS_MODEL_DESCRIPTION": {},
|
|
"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME": {},
|
|
"ANTHROPIC_DEFAULT_OPUS_MODEL_SUPPORTED_CAPABILITIES": {},
|
|
|
|
// Provider-specific base URL env vars for thinking rewrite proxy routing.
|
|
// These are set by cc-connect when thinking override is needed for
|
|
// Bedrock/Vertex/Foundry providers that don't use base_url config.
|
|
"ANTHROPIC_BEDROCK_PROXY_BASE_URL": {},
|
|
"ANTHROPIC_VERTEX_PROXY_BASE_URL": {},
|
|
"ANTHROPIC_FOUNDRY_PROXY_BASE_URL": {},
|
|
"ANTHROPIC_DEFAULT_SONNET_MODEL": {},
|
|
"ANTHROPIC_DEFAULT_SONNET_MODEL_DESCRIPTION": {},
|
|
"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME": {},
|
|
"ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES": {},
|
|
"ANTHROPIC_SMALL_FAST_MODEL": {},
|
|
"ANTHROPIC_SMALL_FAST_MODEL_AWS_REGION": {},
|
|
"CLAUDE_CODE_SUBAGENT_MODEL": {},
|
|
}
|
|
|
|
var claudeProviderManagedEnvPrefixes = []string{
|
|
"VERTEX_REGION_CLAUDE_",
|
|
}
|
|
|
|
func New(opts map[string]any) (core.Agent, error) {
|
|
workDir, _ := opts["work_dir"].(string)
|
|
if workDir == "" {
|
|
workDir = "."
|
|
}
|
|
cliBin := "claude"
|
|
var cliExtraArgs []string
|
|
if cliPath, _ := opts["cli_path"].(string); cliPath != "" {
|
|
// NOTE: paths containing spaces are not supported because Fields
|
|
// splits on whitespace. Use a symlink or wrapper script instead.
|
|
parts := strings.Fields(cliPath)
|
|
cliBin = parts[0]
|
|
if len(parts) > 1 {
|
|
cliExtraArgs = parts[1:]
|
|
}
|
|
}
|
|
cliArgsFlag, _ := opts["cli_args_flag"].(string)
|
|
model, _ := opts["model"].(string)
|
|
reasoningEffort, _ := opts["reasoning_effort"].(string)
|
|
mode, _ := opts["mode"].(string)
|
|
mode = normalizePermissionMode(mode)
|
|
systemPrompt, _ := opts["system_prompt"].(string)
|
|
|
|
var allowedTools []string
|
|
if tools, ok := opts["allowed_tools"].([]any); ok {
|
|
for _, t := range tools {
|
|
if s, ok := t.(string); ok {
|
|
allowedTools = append(allowedTools, s)
|
|
}
|
|
}
|
|
}
|
|
|
|
var disallowedTools []string
|
|
if tools, ok := opts["disallowed_tools"].([]any); ok {
|
|
for _, t := range tools {
|
|
if s, ok := t.(string); ok {
|
|
disallowedTools = append(disallowedTools, s)
|
|
}
|
|
}
|
|
}
|
|
|
|
maxContextTokens := 0
|
|
switch v := opts["max_context_tokens"].(type) {
|
|
case int:
|
|
if v > 0 {
|
|
maxContextTokens = v
|
|
}
|
|
case int64:
|
|
if v > 0 {
|
|
maxContextTokens = int(v)
|
|
}
|
|
case float64:
|
|
if v > 0 {
|
|
maxContextTokens = int(v)
|
|
}
|
|
}
|
|
|
|
// Claude Code Router support
|
|
routerURL, _ := opts["router_url"].(string)
|
|
routerAPIKey, _ := opts["router_api_key"].(string)
|
|
|
|
// run_as_user: optional OS-user isolation. Injected into opts from
|
|
// the project-level config field by cmd/cc-connect/main.go.
|
|
spawnOpts := core.SpawnOptions{}
|
|
spawnOpts.RunAsUser, _ = opts["run_as_user"].(string)
|
|
if env, ok := opts["run_as_env"].([]any); ok {
|
|
for _, v := range env {
|
|
if s, ok := v.(string); ok {
|
|
spawnOpts.EnvAllowlist = append(spawnOpts.EnvAllowlist, s)
|
|
}
|
|
}
|
|
} else if env, ok := opts["run_as_env"].([]string); ok {
|
|
spawnOpts.EnvAllowlist = append(spawnOpts.EnvAllowlist, env...)
|
|
}
|
|
|
|
// When run_as_user is set, the target user's PATH is what matters;
|
|
// skip the supervisor-side LookPath check and let spawn fail loudly
|
|
// at runtime if the target doesn't have claude installed.
|
|
if !spawnOpts.IsolationMode() {
|
|
if _, err := exec.LookPath(cliBin); err != nil {
|
|
return nil, fmt.Errorf("claudecode: %q CLI not found in PATH, please install it first", cliBin)
|
|
}
|
|
}
|
|
|
|
// Parse project-level env from opts["env"] (set via [projects.agent.options.env] in config.toml).
|
|
// Stored separately from runtime sessionEnv so SetSessionEnv calls cannot overwrite it.
|
|
var configEnv []string
|
|
if envMap, ok := opts["env"].(map[string]string); ok {
|
|
for k, v := range envMap {
|
|
configEnv = append(configEnv, k+"="+v)
|
|
}
|
|
} else if envMap, ok := opts["env"].(map[string]any); ok {
|
|
for k, v := range envMap {
|
|
if s, ok := v.(string); ok {
|
|
configEnv = append(configEnv, k+"="+s)
|
|
}
|
|
}
|
|
}
|
|
|
|
return &Agent{
|
|
workDir: workDir,
|
|
cliBin: cliBin,
|
|
cliExtraArgs: cliExtraArgs,
|
|
cliArgsFlag: cliArgsFlag,
|
|
model: model,
|
|
reasoningEffort: normalizeEffort(reasoningEffort),
|
|
mode: mode,
|
|
systemPrompt: systemPrompt,
|
|
allowedTools: allowedTools,
|
|
disallowedTools: disallowedTools,
|
|
maxContextTokens: maxContextTokens,
|
|
configEnv: configEnv,
|
|
activeIdx: -1,
|
|
routerURL: routerURL,
|
|
routerAPIKey: routerAPIKey,
|
|
spawnOpts: spawnOpts,
|
|
}, nil
|
|
}
|
|
|
|
// normalizeEffort maps user-friendly aliases to Claude CLI --effort values.
|
|
func normalizeEffort(raw string) string {
|
|
switch strings.ToLower(strings.TrimSpace(raw)) {
|
|
case "":
|
|
return ""
|
|
case "low":
|
|
return "low"
|
|
case "medium", "med":
|
|
return "medium"
|
|
case "high":
|
|
return "high"
|
|
case "max":
|
|
return "max"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// normalizePermissionMode maps user-friendly aliases to Claude CLI values.
|
|
func normalizePermissionMode(raw string) string {
|
|
switch strings.ToLower(strings.TrimSpace(raw)) {
|
|
case "acceptedits", "accept-edits", "accept_edits", "edit":
|
|
return "acceptEdits"
|
|
case "plan":
|
|
return "plan"
|
|
case "auto":
|
|
return "auto"
|
|
case "bypasspermissions", "bypass-permissions", "bypass_permissions",
|
|
"yolo":
|
|
return "bypassPermissions"
|
|
case "dontask", "dont-ask", "dont_ask":
|
|
return "dontAsk"
|
|
default:
|
|
return "default"
|
|
}
|
|
}
|
|
|
|
func (a *Agent) Name() string { return "claudecode" }
|
|
func (a *Agent) CLIBinaryName() string { return a.cliBin }
|
|
func (a *Agent) CLIDisplayName() string { return "Claude" }
|
|
|
|
func (a *Agent) SetWorkDir(dir string) {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
a.workDir = dir
|
|
slog.Info("claudecode: work_dir changed", "work_dir", dir)
|
|
}
|
|
|
|
func (a *Agent) GetWorkDir() string {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
return a.workDir
|
|
}
|
|
|
|
func (a *Agent) SetModel(model string) {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
a.model = model
|
|
slog.Info("claudecode: model changed", "model", model)
|
|
}
|
|
|
|
func (a *Agent) GetModel() string {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
return core.GetProviderModel(a.providers, a.activeIdx, a.model)
|
|
}
|
|
|
|
func (a *Agent) SetReasoningEffort(effort string) {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
a.reasoningEffort = normalizeEffort(effort)
|
|
slog.Info("claudecode: reasoning effort changed", "effort", a.reasoningEffort)
|
|
}
|
|
|
|
func (a *Agent) GetReasoningEffort() string {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
return a.reasoningEffort
|
|
}
|
|
|
|
func (a *Agent) AvailableReasoningEfforts() []string {
|
|
return []string{"low", "medium", "high", "max"}
|
|
}
|
|
|
|
func (a *Agent) configuredModels() []core.ModelOption {
|
|
a.mu.RLock()
|
|
defer a.mu.RUnlock()
|
|
return core.GetProviderModels(a.providers, a.activeIdx)
|
|
}
|
|
|
|
func (a *Agent) AvailableModels(ctx context.Context) []core.ModelOption {
|
|
if models := a.configuredModels(); len(models) > 0 {
|
|
return models
|
|
}
|
|
if models := a.fetchModelsFromAPI(ctx); len(models) > 0 {
|
|
return models
|
|
}
|
|
return []core.ModelOption{
|
|
{Name: "sonnet", Desc: "Claude Sonnet (balanced)"},
|
|
{Name: "opus", Desc: "Claude Opus (most capable)"},
|
|
{Name: "opus[1m]", Desc: "Claude Opus (1M context)"},
|
|
{Name: "haiku", Desc: "Claude Haiku (fastest)"},
|
|
}
|
|
}
|
|
|
|
func (a *Agent) fetchModelsFromAPI(ctx context.Context) []core.ModelOption {
|
|
a.mu.Lock()
|
|
apiKey := ""
|
|
baseURL := ""
|
|
if a.activeIdx >= 0 && a.activeIdx < len(a.providers) {
|
|
apiKey = a.providers[a.activeIdx].APIKey
|
|
baseURL = a.providers[a.activeIdx].BaseURL
|
|
}
|
|
a.mu.Unlock()
|
|
|
|
if apiKey == "" {
|
|
apiKey = os.Getenv("ANTHROPIC_API_KEY")
|
|
}
|
|
if apiKey == "" {
|
|
return nil
|
|
}
|
|
if baseURL == "" {
|
|
baseURL = os.Getenv("ANTHROPIC_BASE_URL")
|
|
}
|
|
if baseURL == "" {
|
|
baseURL = "https://api.anthropic.com"
|
|
}
|
|
baseURL = strings.TrimRight(baseURL, "/")
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "GET", baseURL+"/v1/models", nil)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
req.Header.Set("x-api-key", apiKey)
|
|
req.Header.Set("anthropic-version", "2023-06-01")
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
slog.Debug("claudecode: failed to fetch models", "error", err)
|
|
return nil
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil
|
|
}
|
|
|
|
var result struct {
|
|
Data []struct {
|
|
ID string `json:"id"`
|
|
DisplayName string `json:"display_name"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return nil
|
|
}
|
|
|
|
var models []core.ModelOption
|
|
for _, m := range result.Data {
|
|
models = append(models, core.ModelOption{Name: m.ID, Desc: m.DisplayName})
|
|
}
|
|
return models
|
|
}
|
|
|
|
func (a *Agent) SetSessionEnv(env []string) {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
a.sessionEnv = env
|
|
}
|
|
|
|
func (a *Agent) SetPlatformPrompt(prompt string) {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
a.platformPrompt = prompt
|
|
}
|
|
|
|
// StartSession creates a persistent interactive Claude Code session.
|
|
func (a *Agent) StartSession(ctx context.Context, sessionID string) (core.AgentSession, error) {
|
|
a.mu.Lock()
|
|
tools := make([]string, len(a.allowedTools))
|
|
copy(tools, a.allowedTools)
|
|
disTools := make([]string, len(a.disallowedTools))
|
|
copy(disTools, a.disallowedTools)
|
|
maxTok := a.maxContextTokens
|
|
model := a.model
|
|
effort := a.reasoningEffort
|
|
workDir := a.workDir
|
|
mode := a.mode
|
|
extraEnv := a.runtimeEnvLocked()
|
|
|
|
activeIdx := a.activeIdx
|
|
var activeProviderName string
|
|
if activeIdx >= 0 && activeIdx < len(a.providers) {
|
|
activeProviderName = a.providers[activeIdx].Name
|
|
if m := a.providers[activeIdx].Model; m != "" {
|
|
model = m
|
|
}
|
|
}
|
|
slog.Debug("claudecode: StartSession provider state",
|
|
"activeIdx", activeIdx,
|
|
"activeProvider", activeProviderName,
|
|
"model", model,
|
|
"sessionID", sessionID,
|
|
"providerCount", len(a.providers))
|
|
platformPrompt := a.platformPrompt
|
|
systemPrompt := a.systemPrompt
|
|
// When router_url is set, --verbose conflicts with --output-format stream-json
|
|
// (verbose emits non-JSON text to stdout that corrupts the JSON stream).
|
|
disableVerbose := a.routerURL != ""
|
|
a.mu.Unlock()
|
|
|
|
return newClaudeSession(ctx, workDir, a.cliBin, a.cliExtraArgs, a.cliArgsFlag, model, effort, sessionID, mode, systemPrompt, tools, disTools, extraEnv, platformPrompt, disableVerbose, a.spawnOpts, maxTok)
|
|
}
|
|
|
|
func (a *Agent) ListSessions(ctx context.Context) ([]core.AgentSessionInfo, error) {
|
|
homeDir, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("claudecode: cannot determine home dir: %w", err)
|
|
}
|
|
|
|
a.mu.RLock()
|
|
workDir := a.workDir
|
|
a.mu.RUnlock()
|
|
absWorkDir, err := filepath.Abs(workDir)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("claudecode: resolve work_dir: %w", err)
|
|
}
|
|
|
|
projectDir := findProjectDir(homeDir, absWorkDir)
|
|
if projectDir == "" {
|
|
return nil, nil
|
|
}
|
|
|
|
entries, err := os.ReadDir(projectDir)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil, nil
|
|
}
|
|
return nil, fmt.Errorf("claudecode: read project dir: %w", err)
|
|
}
|
|
|
|
var sessions []core.AgentSessionInfo
|
|
for _, entry := range entries {
|
|
name := entry.Name()
|
|
if entry.IsDir() || !strings.HasSuffix(name, ".jsonl") {
|
|
continue
|
|
}
|
|
|
|
sessionID := strings.TrimSuffix(name, ".jsonl")
|
|
info, err := entry.Info()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
summary, msgCount := scanSessionMeta(filepath.Join(projectDir, name))
|
|
|
|
sessions = append(sessions, core.AgentSessionInfo{
|
|
ID: sessionID,
|
|
Summary: summary,
|
|
MessageCount: msgCount,
|
|
ModifiedAt: info.ModTime(),
|
|
})
|
|
}
|
|
|
|
sort.Slice(sessions, func(i, j int) bool {
|
|
return sessions[i].ModifiedAt.After(sessions[j].ModifiedAt)
|
|
})
|
|
|
|
return sessions, nil
|
|
}
|
|
|
|
func (a *Agent) DeleteSession(_ context.Context, sessionID string) error {
|
|
homeDir, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return fmt.Errorf("claudecode: cannot determine home dir: %w", err)
|
|
}
|
|
a.mu.RLock()
|
|
workDir := a.workDir
|
|
a.mu.RUnlock()
|
|
absWorkDir, err := filepath.Abs(workDir)
|
|
if err != nil {
|
|
return fmt.Errorf("claudecode: resolve work_dir: %w", err)
|
|
}
|
|
projectDir := findProjectDir(homeDir, absWorkDir)
|
|
if projectDir == "" {
|
|
return fmt.Errorf("session not found")
|
|
}
|
|
path := filepath.Join(projectDir, sessionID+".jsonl")
|
|
if _, err := os.Stat(path); os.IsNotExist(err) {
|
|
return fmt.Errorf("session file not found: %s", sessionID)
|
|
}
|
|
return os.Remove(path)
|
|
}
|
|
|
|
// extractStringContent attempts to extract a plain string from a json.RawMessage.
|
|
// Returns empty string if the raw message is not a JSON string.
|
|
func extractStringContent(raw json.RawMessage) string {
|
|
if len(raw) == 0 {
|
|
return ""
|
|
}
|
|
var s string
|
|
if err := json.Unmarshal(raw, &s); err != nil {
|
|
return ""
|
|
}
|
|
return s
|
|
}
|
|
|
|
func scanSessionMeta(path string) (string, int) {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return "", 0
|
|
}
|
|
defer f.Close()
|
|
|
|
scanner := bufio.NewScanner(f)
|
|
scanner.Buffer(make([]byte, 256*1024), 256*1024)
|
|
|
|
var summary string
|
|
var count int
|
|
|
|
for scanner.Scan() {
|
|
var entry struct {
|
|
Type string `json:"type"`
|
|
Message struct {
|
|
Content json.RawMessage `json:"content"`
|
|
} `json:"message"`
|
|
}
|
|
if err := json.Unmarshal(scanner.Bytes(), &entry); err != nil {
|
|
continue
|
|
}
|
|
if entry.Type == "user" || entry.Type == "assistant" {
|
|
count++
|
|
if entry.Type == "user" {
|
|
if s := extractStringContent(entry.Message.Content); s != "" {
|
|
summary = s
|
|
}
|
|
}
|
|
}
|
|
}
|
|
summary = stripXMLTags(summary)
|
|
summary = strings.TrimSpace(summary)
|
|
if utf8.RuneCountInString(summary) > 40 {
|
|
summary = string([]rune(summary)[:40]) + "..."
|
|
}
|
|
return summary, count
|
|
}
|
|
|
|
var xmlTagRe = regexp.MustCompile(`<[^>]+>`)
|
|
|
|
func stripXMLTags(s string) string {
|
|
return xmlTagRe.ReplaceAllString(s, "")
|
|
}
|
|
|
|
// GetSessionHistory reads the Claude Code JSONL transcript and returns user/assistant messages.
|
|
func (a *Agent) GetSessionHistory(_ context.Context, sessionID string, limit int) ([]core.HistoryEntry, error) {
|
|
homeDir, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
a.mu.RLock()
|
|
workDir := a.workDir
|
|
a.mu.RUnlock()
|
|
absWorkDir, _ := filepath.Abs(workDir)
|
|
projectDir := findProjectDir(homeDir, absWorkDir)
|
|
if projectDir == "" {
|
|
return nil, fmt.Errorf("claudecode: project dir not found")
|
|
}
|
|
|
|
path := filepath.Join(projectDir, sessionID+".jsonl")
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("claudecode: open session file: %w", err)
|
|
}
|
|
defer f.Close()
|
|
|
|
var entries []core.HistoryEntry
|
|
scanner := bufio.NewScanner(f)
|
|
scanner.Buffer(make([]byte, 256*1024), 256*1024)
|
|
|
|
for scanner.Scan() {
|
|
var raw struct {
|
|
Type string `json:"type"`
|
|
Timestamp string `json:"timestamp"`
|
|
Message struct {
|
|
Role string `json:"role"`
|
|
Content json.RawMessage `json:"content"`
|
|
} `json:"message"`
|
|
}
|
|
if json.Unmarshal(scanner.Bytes(), &raw) != nil {
|
|
continue
|
|
}
|
|
if raw.Type != "user" && raw.Type != "assistant" {
|
|
continue
|
|
}
|
|
|
|
ts, _ := time.Parse(time.RFC3339Nano, raw.Timestamp)
|
|
text := extractTextContent(raw.Message.Content)
|
|
if text == "" {
|
|
continue
|
|
}
|
|
|
|
entries = append(entries, core.HistoryEntry{
|
|
Role: raw.Type,
|
|
Content: text,
|
|
Timestamp: ts,
|
|
})
|
|
}
|
|
|
|
if limit > 0 && len(entries) > limit {
|
|
entries = entries[len(entries)-limit:]
|
|
}
|
|
return entries, nil
|
|
}
|
|
|
|
// extractTextContent extracts readable text from Claude Code message content.
|
|
// Content can be a plain string or an array of content blocks.
|
|
func extractTextContent(raw json.RawMessage) string {
|
|
if len(raw) == 0 {
|
|
return ""
|
|
}
|
|
|
|
// Try plain string first
|
|
var s string
|
|
if json.Unmarshal(raw, &s) == nil {
|
|
return s
|
|
}
|
|
|
|
// Try array of content blocks
|
|
var blocks []struct {
|
|
Type string `json:"type"`
|
|
Text string `json:"text"`
|
|
Thinking string `json:"thinking"`
|
|
}
|
|
if json.Unmarshal(raw, &blocks) != nil {
|
|
return ""
|
|
}
|
|
|
|
for _, b := range blocks {
|
|
if b.Type == "text" && b.Text != "" {
|
|
return b.Text
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (a *Agent) Stop() error { return nil }
|
|
|
|
// SetMode changes the permission mode for future sessions.
|
|
func (a *Agent) SetMode(mode string) {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
a.mode = normalizePermissionMode(mode)
|
|
slog.Info("claudecode: permission mode changed", "mode", a.mode)
|
|
}
|
|
|
|
// GetMode returns the current permission mode.
|
|
func (a *Agent) GetMode() string {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
return a.mode
|
|
}
|
|
|
|
// GetRunAsUser returns the target user for OS-isolation spawning, or ""
|
|
// if no isolation is configured. Set at construction from the project-level
|
|
// run_as_user field (injected into opts by cmd/cc-connect/main.go).
|
|
//
|
|
// This accessor exists specifically so multi-workspace mode can propagate
|
|
// run_as_user from the parent (project-level) agent into per-workspace
|
|
// agent instances created lazily by core.Engine.getOrCreateWorkspaceAgent.
|
|
// Without this, workspace agents are constructed with a fresh opts map
|
|
// that never contained run_as_user, silently dropping back to the legacy
|
|
// supervisor-user spawn path — which is exactly the leak cc-connect#496
|
|
// is designed to prevent.
|
|
func (a *Agent) GetRunAsUser() string {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
return a.spawnOpts.RunAsUser
|
|
}
|
|
|
|
// GetRunAsEnv returns the user-configured env allowlist extension (the
|
|
// run_as_env project field), which is merged with core.DefaultEnvAllowlist
|
|
// at spawn time. Returns nil if no extension is configured.
|
|
//
|
|
// Used by the multi-workspace propagation path alongside GetRunAsUser.
|
|
func (a *Agent) GetRunAsEnv() []string {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
if len(a.spawnOpts.EnvAllowlist) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]string, len(a.spawnOpts.EnvAllowlist))
|
|
copy(out, a.spawnOpts.EnvAllowlist)
|
|
return out
|
|
}
|
|
|
|
// WorkspaceAgentOptions returns a snapshot of user-configured options that
|
|
// must propagate to per-workspace agent instances created lazily by
|
|
// core.Engine.getOrCreateWorkspaceAgent. Without this snapshot, the engine
|
|
// constructs workspace agents from a fresh opts map and silently drops
|
|
// every claudecode field except mode/model — so cli_path, allowed_tools,
|
|
// and friends would only take effect on the project-level agent.
|
|
//
|
|
// Runtime-only state (providers, sessionEnv, providerProxy, platformPrompt)
|
|
// is intentionally omitted: providers are rewired separately by the engine
|
|
// after construction; the rest is per-session and recomputed.
|
|
//
|
|
// configEnv IS included because it comes from the static config file and must
|
|
// propagate to every workspace agent. sessionEnv is excluded (runtime-only).
|
|
//
|
|
// run_as_user / run_as_env are also omitted because the engine has its own
|
|
// dedicated propagation path via GetRunAsUser/GetRunAsEnv (see cc-connect#496).
|
|
func (a *Agent) WorkspaceAgentOptions() map[string]any {
|
|
a.mu.RLock()
|
|
defer a.mu.RUnlock()
|
|
|
|
opts := map[string]any{
|
|
"mode": a.mode,
|
|
}
|
|
if len(a.configEnv) > 0 {
|
|
envMap := make(map[string]string, len(a.configEnv))
|
|
for _, kv := range a.configEnv {
|
|
k, v, _ := strings.Cut(kv, "=")
|
|
envMap[k] = v
|
|
}
|
|
opts["env"] = envMap
|
|
}
|
|
if cliPath := snapshotCLIPath(a.cliBin, a.cliExtraArgs); cliPath != "" {
|
|
opts["cli_path"] = cliPath
|
|
}
|
|
if a.cliArgsFlag != "" {
|
|
opts["cli_args_flag"] = a.cliArgsFlag
|
|
}
|
|
if a.model != "" {
|
|
opts["model"] = a.model
|
|
}
|
|
if a.reasoningEffort != "" {
|
|
opts["reasoning_effort"] = a.reasoningEffort
|
|
}
|
|
if len(a.allowedTools) > 0 {
|
|
opts["allowed_tools"] = stringsToAny(a.allowedTools)
|
|
}
|
|
if len(a.disallowedTools) > 0 {
|
|
opts["disallowed_tools"] = stringsToAny(a.disallowedTools)
|
|
}
|
|
if a.maxContextTokens > 0 {
|
|
opts["max_context_tokens"] = a.maxContextTokens
|
|
}
|
|
if a.routerURL != "" {
|
|
opts["router_url"] = a.routerURL
|
|
}
|
|
if a.routerAPIKey != "" {
|
|
opts["router_api_key"] = a.routerAPIKey
|
|
}
|
|
return opts
|
|
}
|
|
|
|
// snapshotCLIPath rebuilds the cli_path opts string from cliBin and the
|
|
// extra-args tail captured at construction. Returns "" when only the
|
|
// default "claude" binary is in use, so we don't pollute the workspace
|
|
// opts with a redundant default.
|
|
func snapshotCLIPath(cliBin string, cliExtraArgs []string) string {
|
|
// Normalise empty to the default binary so we can reason about extra args.
|
|
if cliBin == "" {
|
|
cliBin = "claude"
|
|
}
|
|
if cliBin == "claude" && len(cliExtraArgs) == 0 {
|
|
return "" // default binary, no extra args — no need to persist
|
|
}
|
|
if len(cliExtraArgs) == 0 {
|
|
return cliBin
|
|
}
|
|
return cliBin + " " + strings.Join(cliExtraArgs, " ")
|
|
}
|
|
|
|
// stringsToAny copies a []string into a fresh []any so it round-trips
|
|
// through New()'s opts["..."].([]any) type assertion.
|
|
func stringsToAny(in []string) []any {
|
|
out := make([]any, len(in))
|
|
for i, s := range in {
|
|
out[i] = s
|
|
}
|
|
return out
|
|
}
|
|
|
|
// PermissionModes returns all supported permission modes.
|
|
func (a *Agent) PermissionModes() []core.PermissionModeInfo {
|
|
return []core.PermissionModeInfo{
|
|
{Key: "default", Name: "Default", NameZh: "默认", Desc: "Ask permission for every tool call", DescZh: "每次工具调用都需确认"},
|
|
{Key: "acceptEdits", Name: "Accept Edits", NameZh: "接受编辑", Desc: "Auto-approve file edits, ask for others", DescZh: "自动允许文件编辑,其他需确认"},
|
|
{Key: "plan", Name: "Plan Mode", NameZh: "计划模式", Desc: "Plan only, no execution until approved", DescZh: "只做规划不执行,审批后再执行"},
|
|
{Key: "auto", Name: "Auto", NameZh: "自动模式", Desc: "Claude decides when to ask for permission", DescZh: "由 Claude 自动判断何时需要确认"},
|
|
{Key: "bypassPermissions", Name: "YOLO", NameZh: "YOLO 模式", Desc: "Auto-approve everything", DescZh: "全部自动通过"},
|
|
{Key: "dontAsk", Name: "Don't Ask", NameZh: "静默拒绝", Desc: "Auto-deny tools unless pre-approved via allowed_tools or settings.json allow rules", DescZh: "未预授权的工具自动拒绝,不弹确认"},
|
|
}
|
|
}
|
|
|
|
// AddAllowedTools adds tools to the pre-allowed list (takes effect on next session).
|
|
func (a *Agent) AddAllowedTools(tools ...string) error {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
|
|
existing := make(map[string]bool)
|
|
for _, t := range a.allowedTools {
|
|
existing[t] = true
|
|
}
|
|
for _, tool := range tools {
|
|
if !existing[tool] {
|
|
a.allowedTools = append(a.allowedTools, tool)
|
|
existing[tool] = true
|
|
}
|
|
}
|
|
slog.Info("claudecode: updated allowed tools", "tools", tools, "total", len(a.allowedTools))
|
|
return nil
|
|
}
|
|
|
|
// GetAllowedTools returns the current list of pre-allowed tools.
|
|
func (a *Agent) GetAllowedTools() []string {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
result := make([]string, len(a.allowedTools))
|
|
copy(result, a.allowedTools)
|
|
return result
|
|
}
|
|
|
|
// GetDisallowedTools returns the current list of disallowed tools.
|
|
func (a *Agent) GetDisallowedTools() []string {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
result := make([]string, len(a.disallowedTools))
|
|
copy(result, a.disallowedTools)
|
|
return result
|
|
}
|
|
|
|
// ── CommandProvider implementation ────────────────────────────
|
|
|
|
func (a *Agent) CommandDirs() []string {
|
|
a.mu.RLock()
|
|
workDir := a.workDir
|
|
a.mu.RUnlock()
|
|
absDir, err := filepath.Abs(workDir)
|
|
if err != nil {
|
|
absDir = workDir
|
|
}
|
|
dirs := []string{filepath.Join(absDir, ".claude", "commands")}
|
|
if home, err := os.UserHomeDir(); err == nil {
|
|
dirs = append(dirs, filepath.Join(home, ".claude", "commands"))
|
|
}
|
|
return dirs
|
|
}
|
|
|
|
// ── SkillProvider implementation ──────────────────────────────
|
|
|
|
func (a *Agent) SkillDirs() []string {
|
|
a.mu.RLock()
|
|
workDir := a.workDir
|
|
a.mu.RUnlock()
|
|
absDir, err := filepath.Abs(workDir)
|
|
if err != nil {
|
|
absDir = workDir
|
|
}
|
|
return appendProjectClaudeSkillDirs(absDir, claudeConfigHomeDir())
|
|
}
|
|
|
|
// ── ContextCompressor implementation ──────────────────────────
|
|
|
|
func (a *Agent) CompressCommand() string { return "/compact" }
|
|
|
|
func claudeConfigHomeDir() string {
|
|
if dir := strings.TrimSpace(os.Getenv("CLAUDE_CONFIG_DIR")); dir != "" {
|
|
return dir
|
|
}
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return filepath.Join(home, ".claude")
|
|
}
|
|
|
|
func appendProjectClaudeSkillDirs(workDir, configHome string) []string {
|
|
home, _ := os.UserHomeDir()
|
|
projectDirs := walkUpClaudeSkillDirs(workDir, home)
|
|
if configHome == "" {
|
|
return projectDirs
|
|
}
|
|
return uniqueSkillDirs(append(projectDirs, filepath.Join(configHome, "skills")))
|
|
}
|
|
|
|
func walkUpClaudeSkillDirs(workDir, home string) []string {
|
|
current := filepath.Clean(workDir)
|
|
home = filepath.Clean(home)
|
|
stopAt := findGitRoot(current)
|
|
|
|
var dirs []string
|
|
for {
|
|
if home != "" && samePath(current, home) {
|
|
break
|
|
}
|
|
dirs = append(dirs, filepath.Join(current, ".claude", "skills"))
|
|
if stopAt != "" && samePath(current, stopAt) {
|
|
break
|
|
}
|
|
parent := filepath.Dir(current)
|
|
if parent == current {
|
|
break
|
|
}
|
|
current = parent
|
|
}
|
|
return uniqueSkillDirs(dirs)
|
|
}
|
|
|
|
func findGitRoot(start string) string {
|
|
current := filepath.Clean(start)
|
|
for {
|
|
gitPath := filepath.Join(current, ".git")
|
|
if _, err := os.Stat(gitPath); err == nil {
|
|
return current
|
|
}
|
|
parent := filepath.Dir(current)
|
|
if parent == current {
|
|
return ""
|
|
}
|
|
current = parent
|
|
}
|
|
}
|
|
|
|
func samePath(a, b string) bool {
|
|
if a == "" || b == "" {
|
|
return false
|
|
}
|
|
return filepath.Clean(a) == filepath.Clean(b)
|
|
}
|
|
|
|
func uniqueSkillDirs(paths []string) []string {
|
|
seen := make(map[string]struct{}, len(paths))
|
|
out := make([]string, 0, len(paths))
|
|
for _, path := range paths {
|
|
if path == "" {
|
|
continue
|
|
}
|
|
clean := filepath.Clean(path)
|
|
if _, ok := seen[clean]; ok {
|
|
continue
|
|
}
|
|
seen[clean] = struct{}{}
|
|
out = append(out, clean)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ── MemoryFileProvider implementation ─────────────────────────
|
|
|
|
func (a *Agent) ProjectMemoryFile() string {
|
|
a.mu.RLock()
|
|
workDir := a.workDir
|
|
a.mu.RUnlock()
|
|
absDir, err := filepath.Abs(workDir)
|
|
if err != nil {
|
|
absDir = workDir
|
|
}
|
|
return filepath.Join(absDir, "CLAUDE.md")
|
|
}
|
|
|
|
func (a *Agent) GlobalMemoryFile() string {
|
|
homeDir, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return filepath.Join(homeDir, ".claude", "CLAUDE.md")
|
|
}
|
|
|
|
func (a *Agent) HasSystemPromptSupport() bool { return true }
|
|
|
|
// ── ProviderSwitcher implementation ──────────────────────────
|
|
|
|
func (a *Agent) SetProviders(providers []core.ProviderConfig) {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
a.providers = providers
|
|
}
|
|
|
|
func (a *Agent) SetActiveProvider(name string) bool {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
a.stopProviderProxyLocked()
|
|
if name == "" {
|
|
a.activeIdx = -1
|
|
slog.Info("claudecode: provider cleared")
|
|
return true
|
|
}
|
|
for i, p := range a.providers {
|
|
if p.Name == name {
|
|
a.activeIdx = i
|
|
slog.Info("claudecode: provider switched", "provider", name)
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (a *Agent) GetActiveProvider() *core.ProviderConfig {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
if a.activeIdx < 0 || a.activeIdx >= len(a.providers) {
|
|
return nil
|
|
}
|
|
p := a.providers[a.activeIdx]
|
|
return &p
|
|
}
|
|
|
|
func (a *Agent) ListProviders() []core.ProviderConfig {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
result := make([]core.ProviderConfig, len(a.providers))
|
|
copy(result, a.providers)
|
|
return result
|
|
}
|
|
|
|
// providerEnvLocked returns env vars for the active provider. Caller must hold mu.
|
|
//
|
|
// When a custom base_url is configured:
|
|
// 1. We use ANTHROPIC_AUTH_TOKEN (Bearer) instead of ANTHROPIC_API_KEY
|
|
// (x-api-key). Claude Code validates API keys against api.anthropic.com
|
|
// which hangs for third-party endpoints; Bearer auth skips that check.
|
|
// 2. If the provider sets thinking (e.g. "disabled"), a local reverse proxy
|
|
// rewrites the thinking parameter for compatibility with providers that
|
|
// don't support adaptive thinking.
|
|
//
|
|
// For env-only providers (Bedrock, Vertex, Foundry) that don't set base_url
|
|
// but use CLAUDE_CODE_USE_BEDROCK/VERTEX/FOUNDRY env vars, the thinking
|
|
// rewrite proxy routes via ANTHROPIC_*_BASE_URL override env vars.
|
|
func (a *Agent) providerEnvLocked() []string {
|
|
if a.activeIdx < 0 || a.activeIdx >= len(a.providers) {
|
|
a.stopProviderProxyLocked()
|
|
return nil
|
|
}
|
|
p := a.providers[a.activeIdx]
|
|
var env []string
|
|
|
|
if p.BaseURL != "" {
|
|
if p.Thinking != "" {
|
|
if err := a.ensureProviderProxyLocked(p.BaseURL, p.Thinking); err != nil {
|
|
slog.Error("providerproxy: failed to start", "error", err)
|
|
env = append(env, "ANTHROPIC_BASE_URL="+p.BaseURL)
|
|
} else {
|
|
env = append(env, "ANTHROPIC_BASE_URL="+a.proxyLocalURL)
|
|
env = append(env, "NO_PROXY=127.0.0.1")
|
|
}
|
|
} else {
|
|
a.stopProviderProxyLocked()
|
|
env = append(env, "ANTHROPIC_BASE_URL="+p.BaseURL)
|
|
}
|
|
if p.APIKey != "" {
|
|
env = append(env, "ANTHROPIC_AUTH_TOKEN="+p.APIKey)
|
|
env = append(env, "ANTHROPIC_API_KEY=")
|
|
}
|
|
if p.Model != "" {
|
|
env = append(env, "ANTHROPIC_MODEL="+p.Model)
|
|
}
|
|
} else {
|
|
// Check for env-only providers (Bedrock, Vertex, Foundry) that need thinking rewrite.
|
|
if p.Thinking != "" {
|
|
providerType := detectEnvOnlyProviderType(p.Env)
|
|
if providerType != "" {
|
|
targetURL := getDefaultEndpointForProviderType(providerType)
|
|
if targetURL != "" {
|
|
if err := a.ensureProviderProxyLocked(targetURL, p.Thinking); err != nil {
|
|
slog.Error("providerproxy: failed to start for "+providerType, "error", err)
|
|
a.stopProviderProxyLocked()
|
|
} else {
|
|
// Route the provider-specific requests through our proxy.
|
|
baseURLEnvVar := getBaseURLEnvVarForProviderType(providerType)
|
|
env = append(env, baseURLEnvVar+"="+a.proxyLocalURL)
|
|
env = append(env, "NO_PROXY=127.0.0.1")
|
|
slog.Info("claudecode: thinking rewrite proxy enabled for "+providerType,
|
|
"target", targetURL, "local", a.proxyLocalURL, "thinking", p.Thinking)
|
|
}
|
|
} else {
|
|
a.stopProviderProxyLocked()
|
|
}
|
|
} else {
|
|
a.stopProviderProxyLocked()
|
|
}
|
|
} else {
|
|
a.stopProviderProxyLocked()
|
|
}
|
|
if p.APIKey != "" {
|
|
env = append(env, "ANTHROPIC_API_KEY="+p.APIKey)
|
|
}
|
|
}
|
|
|
|
for k, v := range p.Env {
|
|
env = append(env, k+"="+v)
|
|
}
|
|
slog.Debug("claudecode: providerEnv",
|
|
"provider", p.Name,
|
|
"model", p.Model,
|
|
"env", core.RedactEnv(env))
|
|
return env
|
|
}
|
|
|
|
func (a *Agent) runtimeEnvLocked() []string {
|
|
// configEnv (from config.toml [env]) is lower priority than provider keys or
|
|
// session-injected vars, but must survive SetSessionEnv calls (which only
|
|
// overwrite sessionEnv). Prepend it so later entries win on conflict.
|
|
env := append([]string(nil), a.configEnv...)
|
|
env = append(env, a.providerEnvLocked()...)
|
|
env = append(env, a.sessionEnv...)
|
|
|
|
if a.routerURL != "" {
|
|
env = append(env, "ANTHROPIC_BASE_URL="+a.routerURL)
|
|
env = append(env, "NO_PROXY=127.0.0.1")
|
|
env = append(env, "DISABLE_TELEMETRY=true")
|
|
env = append(env, "DISABLE_COST_WARNINGS=true")
|
|
}
|
|
if a.routerAPIKey != "" {
|
|
env = append(env, "ANTHROPIC_API_KEY="+a.routerAPIKey)
|
|
}
|
|
|
|
if !claudeEnvManagesProviderRouting(env) {
|
|
return env
|
|
}
|
|
return core.MergeEnv(env, []string{"CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST=1"})
|
|
}
|
|
|
|
func claudeEnvManagesProviderRouting(env []string) bool {
|
|
for _, entry := range env {
|
|
key, _, ok := strings.Cut(entry, "=")
|
|
if !ok {
|
|
continue
|
|
}
|
|
upper := strings.ToUpper(strings.TrimSpace(key))
|
|
if _, ok := claudeProviderManagedEnvVars[upper]; ok {
|
|
return true
|
|
}
|
|
for _, prefix := range claudeProviderManagedEnvPrefixes {
|
|
if strings.HasPrefix(upper, prefix) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (a *Agent) ensureProviderProxyLocked(targetURL, thinkingOverride string) error {
|
|
if a.providerProxy != nil && a.proxyLocalURL != "" {
|
|
return nil
|
|
}
|
|
a.stopProviderProxyLocked()
|
|
proxy, localURL, err := core.NewProviderProxy(targetURL, thinkingOverride)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
a.providerProxy = proxy
|
|
a.proxyLocalURL = localURL
|
|
return nil
|
|
}
|
|
|
|
func (a *Agent) stopProviderProxyLocked() {
|
|
if a.providerProxy != nil {
|
|
a.providerProxy.Close()
|
|
a.providerProxy = nil
|
|
a.proxyLocalURL = ""
|
|
}
|
|
}
|
|
|
|
// detectEnvOnlyProviderType checks if the provider uses Bedrock, Vertex, or Foundry
|
|
// via environment variables (without base_url). Returns "bedrock", "vertex", "foundry",
|
|
// or empty string if not detected.
|
|
func detectEnvOnlyProviderType(env map[string]string) string {
|
|
if env == nil {
|
|
return ""
|
|
}
|
|
if env["CLAUDE_CODE_USE_BEDROCK"] == "1" {
|
|
return "bedrock"
|
|
}
|
|
if env["CLAUDE_CODE_USE_VERTEX"] == "1" {
|
|
return "vertex"
|
|
}
|
|
if env["CLAUDE_CODE_USE_FOUNDRY"] == "1" {
|
|
return "foundry"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// getDefaultEndpointForProviderType returns the default API endpoint for Bedrock/Vertex/Foundry.
|
|
// Used as the proxy target when thinking rewrite is needed for env-only providers.
|
|
func getDefaultEndpointForProviderType(providerType string) string {
|
|
switch providerType {
|
|
case "bedrock":
|
|
// Bedrock cross-region inference endpoint; works with AWS SDK auth.
|
|
// User can override region via AWS_REGION or CLOUD_ML_REGION env var.
|
|
return "https://bedrock-runtime.us-east-1.amazonaws.com"
|
|
case "vertex":
|
|
// Vertex AI endpoint; requires CLOUD_ML_REGION env var for region.
|
|
return "https://us-east1-aiplatform.googleapis.com"
|
|
case "foundry":
|
|
// Anthropic Foundry internal endpoint (rarely used externally).
|
|
return "https://api.anthropic.com"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// getBaseURLEnvVarForProviderType returns the environment variable name that
|
|
// Claude Code uses to override the base URL for Bedrock/Vertex/Foundry providers.
|
|
func getBaseURLEnvVarForProviderType(providerType string) string {
|
|
switch providerType {
|
|
case "bedrock":
|
|
return "ANTHROPIC_BEDROCK_BASE_URL"
|
|
case "vertex":
|
|
return "ANTHROPIC_VERTEX_BASE_URL"
|
|
case "foundry":
|
|
return "ANTHROPIC_FOUNDRY_BASE_URL"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// summarizeInput produces a short human-readable description of tool input.
|
|
func summarizeInput(tool string, input any) string {
|
|
m, ok := input.(map[string]any)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
|
|
switch tool {
|
|
case "Read", "Edit", "Write":
|
|
if fp, ok := m["file_path"].(string); ok {
|
|
return fp
|
|
}
|
|
case "Bash":
|
|
if cmd, ok := m["command"].(string); ok {
|
|
return cmd
|
|
}
|
|
case "Grep":
|
|
if p, ok := m["pattern"].(string); ok {
|
|
return p
|
|
}
|
|
case "Glob":
|
|
if p, ok := m["pattern"].(string); ok {
|
|
return p
|
|
}
|
|
if p, ok := m["glob_pattern"].(string); ok {
|
|
return p
|
|
}
|
|
}
|
|
|
|
b, err := json.Marshal(m)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
// parseUserQuestions extracts structured questions from AskUserQuestion input.
|
|
func parseUserQuestions(input map[string]any) []core.UserQuestion {
|
|
questionsRaw, ok := input["questions"].([]any)
|
|
if !ok || len(questionsRaw) == 0 {
|
|
return nil
|
|
}
|
|
var questions []core.UserQuestion
|
|
for _, qRaw := range questionsRaw {
|
|
qMap, ok := qRaw.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
q := core.UserQuestion{
|
|
Question: strVal(qMap, "question"),
|
|
Header: strVal(qMap, "header"),
|
|
MultiSelect: boolVal(qMap, "multiSelect"),
|
|
}
|
|
if optsRaw, ok := qMap["options"].([]any); ok {
|
|
for _, oRaw := range optsRaw {
|
|
oMap, ok := oRaw.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
q.Options = append(q.Options, core.UserQuestionOption{
|
|
Label: strVal(oMap, "label"),
|
|
Description: strVal(oMap, "description"),
|
|
})
|
|
}
|
|
}
|
|
if q.Question != "" {
|
|
questions = append(questions, q)
|
|
}
|
|
}
|
|
return questions
|
|
}
|
|
|
|
func strVal(m map[string]any, key string) string {
|
|
v, _ := m[key].(string)
|
|
return v
|
|
}
|
|
|
|
func boolVal(m map[string]any, key string) bool {
|
|
v, _ := m[key].(bool)
|
|
return v
|
|
}
|
|
|
|
// encodeClaudeProjectKey converts an absolute path to Claude Code's project key format.
|
|
// Claude Code encodes paths by:
|
|
// 1. Replacing path separators (/ or \) with "-"
|
|
// 2. Replacing colons (:) with "-" (Windows drive letters)
|
|
// 3. Replacing underscores (_) with "-"
|
|
// 4. Replacing spaces and tildes (~) with "-" (common in macOS iCloud paths like
|
|
// "/Users/x/Library/Mobile Documents/com~apple~CloudDocs/...")
|
|
// 5. Replacing all non-ASCII characters with "-"
|
|
func encodeClaudeProjectKey(absPath string) string {
|
|
// First, normalize to forward slashes for consistent processing
|
|
normalized := strings.ReplaceAll(absPath, "\\", "/")
|
|
|
|
// Build the encoded key character by character
|
|
var result strings.Builder
|
|
for _, r := range normalized {
|
|
if r == '/' || r == ':' || r == '_' || r == ' ' || r == '~' {
|
|
result.WriteRune('-')
|
|
} else if r < 128 { // ASCII range (0-127)
|
|
result.WriteRune(r)
|
|
} else {
|
|
// Non-ASCII characters become hyphens
|
|
result.WriteRune('-')
|
|
}
|
|
}
|
|
return result.String()
|
|
}
|
|
|
|
// findProjectDir locates the Claude Code session directory for a given work dir.
|
|
// Claude Code stores sessions at ~/.claude/projects/{projectKey}/ where projectKey
|
|
// is derived from the absolute path. On Windows, the key format may vary (colon
|
|
// handling, slash direction), so we try multiple key candidates and fall back to
|
|
// scanning the projects directory.
|
|
func findProjectDir(homeDir, absWorkDir string) string {
|
|
projectsBase := filepath.Join(homeDir, ".claude", "projects")
|
|
|
|
// Build candidate keys: different ways Claude Code might encode the path.
|
|
// Primary encoding: Claude Code's actual algorithm (non-ASCII → "-")
|
|
candidates := []string{
|
|
encodeClaudeProjectKey(absWorkDir),
|
|
// Legacy candidates for backward compatibility
|
|
strings.ReplaceAll(absWorkDir, string(filepath.Separator), "-"),
|
|
strings.NewReplacer("/", "-", "\\", "-", ":", "-").Replace(absWorkDir),
|
|
strings.NewReplacer("/", "-", "\\", "-", ":", "-", "_", "-").Replace(absWorkDir),
|
|
}
|
|
// Also try with forward slashes (config might use forward slashes on Windows)
|
|
fwd := strings.ReplaceAll(absWorkDir, "\\", "/")
|
|
candidates = append(candidates, strings.ReplaceAll(fwd, "/", "-"))
|
|
|
|
for _, key := range candidates {
|
|
dir := filepath.Join(projectsBase, key)
|
|
if _, err := os.Stat(dir); err == nil {
|
|
return dir
|
|
}
|
|
}
|
|
|
|
// Fallback: scan the projects directory and find a match by
|
|
// comparing the encoded path (handles variations in encoding).
|
|
entries, err := os.ReadDir(projectsBase)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
|
|
// Use the primary encoding for comparison
|
|
encodedWorkDir := encodeClaudeProjectKey(absWorkDir)
|
|
for _, entry := range entries {
|
|
if !entry.IsDir() {
|
|
continue
|
|
}
|
|
// Direct match with encoded key
|
|
if entry.Name() == encodedWorkDir {
|
|
return filepath.Join(projectsBase, entry.Name())
|
|
}
|
|
// Case-insensitive match for Windows compatibility
|
|
if strings.EqualFold(entry.Name(), encodedWorkDir) {
|
|
return filepath.Join(projectsBase, entry.Name())
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|