Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
- **kimi**: native Kimi Code CLI (Node.js `kimi-code`) dialect support (#1561). The newer CLI removed `--quiet` and `--resume`, moved the session resume hint from a plain-text line to a stdout JSON meta event (`{"role":"meta","type":"session.resume_hint",...}`), and emits assistant/tool `content` as a plain string instead of typed blocks — causing `error: unknown option`, silently dropped replies, and broken multi-turn continuity. The probe now also gates `--quiet` (emulated locally by suppressing thinking/tool events when unsupported), resume uses `-r <id>` on the modern dialect (the command the CLI's own hint prints), the meta resume-hint event restores the session ID, and the stream parser accepts both content shapes regardless of probed flavor. Session listing additionally scans `~/.kimi-code/sessions` with its `state.json` schema (`title`/`workDir`, honoring the workDir filter) and counts messages from `agents/main/wire.jsonl` so `/list` and `/delete` work for both CLI flavors. Legacy kimi-cli behavior is unchanged.
- **core**: queue post-restart notification and dispatch on platform ready (#1383). Previously `/restart` sent the success notification immediately after engine startup, racing the platform's async connect window (Telegram: ~2.6s). On a not-yet-ready platform the send was silently dropped at debug log level. The notify is now queued on the engine and dispatched when the target platform reaches `OnPlatformReady`, with bounded retry (3 attempts, 0/500/1500 ms backoff) on transient send failure. Failed sends log at warn level. A 10s safety timeout drops the notify with a warning if the target platform never reaches ready, so startup is never blocked indefinitely. Also covers Discord / Weixin / Matrix (other AsyncRecoverablePlatform implementations) for free.
- **core**: `SaveFilesToDisk` / `AppendFileRefs` always emit absolute paths (#1459). When a user configured a relative `work_dir` (e.g. `~/project` or `.cc-connect`), `SaveFilesToDisk` joined relative paths into the attachments directory and the resulting paths were passed verbatim into the agent's prompt. The spawned agent process — typically run from a different cwd by the platform adapter — could not resolve them and silently dropped every attachment. `SaveFilesToDisk` now calls `filepath.Abs(workDir)` up front and falls back to the raw value on error, and `AppendFileRefs` defensively absolutizes each entry. Both behaviors are covered by new tests for relative, absolute, and empty workDir; the empty-workDir case falls back to the process cwd so misconfigured deploys still get a writable attachments directory.
- **Plugin skill roots**: Codex and Claude Code static skill discovery now includes plugin-provided `skills` directories while preserving depth-1 registration, so bundled plugin skills are listed without reintroducing nested reference-template leaks.

- **core**: prevent same-name file attachments from overwriting each other. `SaveFilesToDisk` now scopes files by message ID, keeps duplicate names distinct within one message, and uses an atomic no-overwrite fallback for legacy callers without a message ID (#1552).

Expand Down
86 changes: 46 additions & 40 deletions agent/claudecode/claudecode.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"time"
"unicode/utf8"

"github.com/chenhg5/cc-connect/agent/internal/skillroots"
"github.com/chenhg5/cc-connect/core"
)

Expand Down Expand Up @@ -48,9 +49,9 @@ type Agent struct {
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
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
pluginDirs []string // Plugin directories to load via --plugin-dir (repeatable)

appendSystemPrompt string // Custom text appended to the system prompt (keeps Claude's default)
Expand Down Expand Up @@ -83,41 +84,41 @@ type Agent struct {
}

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": {},
"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_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": {},
Expand Down Expand Up @@ -1020,12 +1021,13 @@ func (a *Agent) CommandDirs() []string {
func (a *Agent) SkillDirs() []string {
a.mu.RLock()
workDir := a.workDir
pluginDirs := append([]string(nil), a.pluginDirs...)
a.mu.RUnlock()
absDir, err := filepath.Abs(workDir)
if err != nil {
absDir = workDir
}
return appendProjectClaudeSkillDirs(absDir, claudeConfigHomeDir())
return claudeSkillDirs(absDir, claudeConfigHomeDir(), pluginDirs)
}

// ── ContextCompressor implementation ──────────────────────────
Expand All @@ -1043,13 +1045,17 @@ func claudeConfigHomeDir() string {
return filepath.Join(home, ".claude")
}

func appendProjectClaudeSkillDirs(workDir, configHome string) []string {
func claudeSkillDirs(workDir, configHome string, pluginDirs []string) []string {
home, _ := os.UserHomeDir()
projectDirs := walkUpClaudeSkillDirs(workDir, home)
if configHome == "" {
return projectDirs
dirs := walkUpClaudeSkillDirs(workDir, home)
if configHome != "" {
dirs = append(dirs, filepath.Join(configHome, "skills"))
dirs = append(dirs, skillroots.Find(filepath.Join(configHome, "plugins"))...)
}
return uniqueSkillDirs(append(projectDirs, filepath.Join(configHome, "skills")))
for _, pluginDir := range pluginDirs {
dirs = append(dirs, skillroots.Find(pluginDir)...)
}
return uniqueSkillDirs(dirs)
}

func walkUpClaudeSkillDirs(workDir, home string) []string {
Expand Down
80 changes: 80 additions & 0 deletions agent/claudecode/skilldirs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package claudecode
import (
"os"
"path/filepath"
"runtime"
"testing"
)

Expand Down Expand Up @@ -65,3 +66,82 @@ func TestSkillDirs_FallsBackToHomeClaudeDir(t *testing.T) {
t.Fatalf("last SkillDirs() = %q, want %q\nfull=%v", got[len(got)-1], wantLast, got)
}
}

func TestSkillDirs_IncludesClaudePluginSkillRoots(t *testing.T) {
tmp := t.TempDir()
home := filepath.Join(tmp, "home")
configHome := filepath.Join(tmp, "profile-home")
workDir := filepath.Join(tmp, "workspace")
configPluginSkills := filepath.Join(configHome, "plugins", "cache", "claude-plugins-official", "notion", "0.1.0", "skills")
explicitPluginSkills := filepath.Join(tmp, "external-plugins", "vendor", "plugin-dev", "skills")
explicitSkillsRoot := filepath.Join(tmp, "direct", "skills")
if err := os.MkdirAll(workDir, 0o755); err != nil {
t.Fatalf("mkdir workdir: %v", err)
}
for _, dir := range []string{configPluginSkills, explicitPluginSkills, explicitSkillsRoot} {
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir %s: %v", dir, err)
}
}
if err := os.MkdirAll(filepath.Join(configPluginSkills, "notion", "references", "template", "skills"), 0o755); err != nil {
t.Fatalf("mkdir nested asset skills dir: %v", err)
}
t.Setenv("HOME", home)
t.Setenv("CLAUDE_CONFIG_DIR", configHome)

a := &Agent{
workDir: workDir,
pluginDirs: []string{filepath.Join(tmp, "external-plugins"), explicitSkillsRoot},
}
got := map[string]bool{}
for _, dir := range a.SkillDirs() {
got[dir] = true
}

want := []string{
filepath.Join(configHome, "skills"),
configPluginSkills,
explicitPluginSkills,
explicitSkillsRoot,
}
for _, dir := range want {
if !got[dir] {
t.Fatalf("SkillDirs missing %q, dirs=%v", dir, a.SkillDirs())
}
}
if got[filepath.Join(configPluginSkills, "notion", "references", "template", "skills")] {
t.Fatalf("SkillDirs must not include nested skills directories inside plugin skill roots: %v", a.SkillDirs())
}
}

func TestSkillDirs_FollowsClaudePluginSymlink(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlink creation requires administrator on Windows")
}
tmp := t.TempDir()
home := filepath.Join(tmp, "home")
configHome := filepath.Join(tmp, "profile-home")
workDir := filepath.Join(tmp, "workspace")
mainClaudePlugins := filepath.Join(home, ".claude", "plugins")
pluginSkillsDir := filepath.Join(configHome, "plugins", "cache", "claude-plugins-official", "notion", "0.1.0", "skills")

t.Setenv("HOME", home)
t.Setenv("CLAUDE_CONFIG_DIR", configHome)
for _, dir := range []string{workDir, configHome, filepath.Join(mainClaudePlugins, "cache", "claude-plugins-official", "notion", "0.1.0", "skills")} {
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir %s: %v", dir, err)
}
}
if err := os.Symlink(mainClaudePlugins, filepath.Join(configHome, "plugins")); err != nil {
t.Fatalf("symlink plugins: %v", err)
}

a := &Agent{workDir: workDir}
got := map[string]bool{}
for _, dir := range a.SkillDirs() {
got[dir] = true
}
if !got[pluginSkillsDir] {
t.Fatalf("SkillDirs() missing plugin root through symlink %q, dirs=%v", pluginSkillsDir, a.SkillDirs())
}
}
20 changes: 15 additions & 5 deletions agent/codex/codex.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (

"github.com/BurntSushi/toml"

"github.com/chenhg5/cc-connect/agent/internal/skillroots"
"github.com/chenhg5/cc-connect/core"
)

Expand Down Expand Up @@ -353,7 +354,6 @@ func readCodexCachedModels() []core.ModelOption {
return parseCodexModelsJSON(b)
}


// parseCodexModelsJSON parses a Codex models JSON file (model_catalog.json
// or models_cache.json) into a deduplicated, filtered slice of ModelOption.
// It is shared by readCodexCachedModels and readCodexModelCatalog.
Expand Down Expand Up @@ -399,7 +399,6 @@ func parseCodexModelsJSON(data []byte) []core.ModelOption {
return models
}


// readCodexModelCatalog reads $CODEX_HOME/config.toml to find the
// model_catalog_json setting, then reads and parses that JSON file.
// This is the authoritative source of model metadata for Codex CLI,
Expand Down Expand Up @@ -607,12 +606,21 @@ func codexSkillDirs(workDir, explicitCodexHome string) []string {
}

projectDirs := walkUpCodexProjectSkillDirs(workDir, homeDir)
userDirs := make([]string, 0, 2)
userDirs := make([]string, 0, 4)
if codexHome != "" {
userDirs = append(userDirs, filepath.Join(codexHome, "skills"))
userDirs = append(userDirs,
filepath.Join(codexHome, "skills"),
// Superpowers installs Codex-compatible skills under this layout.
filepath.Join(codexHome, "superpowers", "skills"),
)
userDirs = append(userDirs, skillroots.Find(filepath.Join(codexHome, "plugins"))...)
}
if homeDir != "" {
userDirs = append(userDirs, filepath.Join(homeDir, ".agents", "skills"))
userDirs = append(userDirs,
filepath.Join(homeDir, ".agents", "skills"),
// Codex deliberately shares Claude-format SKILL.md directories.
filepath.Join(homeDir, ".claude", "skills"),
)
}
return uniqueCodexSkillDirs(append(projectDirs, userDirs...))
}
Expand All @@ -630,6 +638,8 @@ func walkUpCodexProjectSkillDirs(workDir, homeDir string) []string {
dirs = append(dirs,
filepath.Join(current, ".agents", "skills"),
filepath.Join(current, ".codex", "skills"),
// Keep project-local Claude-format skills portable to Codex.
filepath.Join(current, ".claude", "skills"),
)
if stopAt != "" && sameCodexPath(current, stopAt) {
break
Expand Down
11 changes: 11 additions & 0 deletions agent/codex/session_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,11 @@ func TestGetModelAndReasoningEffort_FromRuntimeConfigWhenUnset(t *testing.T) {
if err := os.MkdirAll(binDir, 0o755); err != nil {
t.Fatalf("mkdir bin: %v", err)
}
oldTimeout := codexRuntimeConfigTimeout
codexRuntimeConfigTimeout = 5 * time.Second
t.Cleanup(func() {
codexRuntimeConfigTimeout = oldTimeout
})

script := `#!/bin/sh
while IFS= read -r line; do
Expand Down Expand Up @@ -735,11 +740,17 @@ func writeFakeCodexScript(t *testing.T, dir, shellScript, powershellScript strin
func waitForArgsFile(t *testing.T, path string) []string {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
var last string
for time.Now().Before(deadline) {
data, err := os.ReadFile(path)
if err == nil {
text := strings.TrimSpace(string(data))
if text != "" {
if text != last {
last = text
time.Sleep(20 * time.Millisecond)
continue
}
lines := strings.Split(text, "\n")
args := make([]string, 0, len(lines))
for _, line := range lines {
Expand Down
Loading
Loading