Skip to content
Closed
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 @@ -14,6 +14,7 @@

### Fixed
- **Feishu recall fallback probes**: throttle repeated active-message recall checks so long-running turns do not continuously call platform message APIs.
- **core**: hide cron/timer `new_per_run` background sessions from normal `/list`, `/switch`, and `/delete` views by default, with per-project `hide_scheduler_sessions = false` opt-out for users who want the old visible scheduler history behavior (#1077, #1417).
- **Skill discovery depth-1 only**: skill scanning no longer recurses into subdirectories. Only `<skill_dir>/<name>/SKILL.md` is registered; nested SKILL.md files (e.g. inside `<name>/references/...`) are treated as skill assets and ignored, matching the Claude Code CLI convention. Previously, nested SKILL.md files leaked into platform command menus as phantom slash commands (101 leaked commands from `frontend-design` skill alone) (#1304).
- **Feishu: tighter `@` mention detection in `SendWithStatusFooter` / `buildReplyContent`** — a bare `@` inside an email address, URL, or escaped character no longer false-positives as a mention. Mention detection now checks for the resolved `<at user_id="...">` tag instead of a substring match, so card rendering (and the notation-style status footer) is preserved for content that merely contains `@`. Real `@mentions` still force `MsgTypeText` so Feishu fires the mention event (#1322).
- **feishu**: coalesce consecutive image messages from the same session into a single multi-image dispatch to fix first-image drop on batch sends (#1395). When the Feishu mobile client sends N images in quick succession, each image arrives as a separate `image` event with very close `create_time` values. Dispatching each immediately caused core/engine's `create_time` watermark (PR #1168) to drop the oldest image, so the agent only saw N-1 images. A per-session image buffer with a 150ms quiet window now merges the burst into one `core.Message` carrying all images, in send order. Single-image sends and quoted-image replies are unaffected.
Expand Down
2 changes: 2 additions & 0 deletions cmd/cc-connect/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,7 @@ func main() {
engine.SetReplyFooterEnabled(showFooter)
engine.SetAttachmentSendEnabled(cfg.AttachmentSend != "off")
engine.SetFilterExternalSessions(proj.FilterExternalSessions != nil && *proj.FilterExternalSessions)
engine.SetHideSchedulerSessions(proj.HideSchedulerSessions == nil || *proj.HideSchedulerSessions)
engine.SetBaseWorkDir(workDir)
engine.SetProjectStateStore(projectState)
engine.SetDataDir(cfg.DataDir)
Expand Down Expand Up @@ -1774,6 +1775,7 @@ func reloadConfig(configPath, projName string, engine *core.Engine) (*core.Confi

// Reload filter_external_sessions
engine.SetFilterExternalSessions(proj.FilterExternalSessions != nil && *proj.FilterExternalSessions)
engine.SetHideSchedulerSessions(proj.HideSchedulerSessions == nil || *proj.HideSchedulerSessions)

// Reload providers
if ps, ok := engine.GetAgent().(core.ProviderSwitcher); ok {
Expand Down
8 changes: 8 additions & 0 deletions config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,14 @@ level = "info" # debug, info, warn, error
# [[projects]]
# reset_on_idle_mins = 30 # default when unset / 未设置时的默认值;设为 0 表示禁用

# Hide cron/timer new_per_run background sessions from normal session commands.
# Default: true. Set false to keep scheduler sessions visible in /list, /switch,
# and /delete, matching the pre-#1417 behavior.
# 从普通会话命令中隐藏 cron/timer new_per_run 后台会话。默认 true。
# 设为 false 可让这些后台会话继续出现在 /list、/switch、/delete 中。
# [[projects]]
# hide_scheduler_sessions = true

# Close an idle live agent process after a clean turn while preserving the
# cc-connect session and saved agent session ID. The next message in the same
# chat/thread starts a new agent process and resumes the same conversation.
Expand Down
4 changes: 4 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -548,6 +548,10 @@ type ProjectConfig struct {
// cc-connect, hiding sessions created by direct CLI usage in the same work_dir.
// Default is false (show all sessions).
FilterExternalSessions *bool `toml:"filter_external_sessions,omitempty"`
// HideSchedulerSessions hides cron/timer new_per_run background sessions from
// /list, /switch, and /delete. Default true fixes scheduler session leaks;
// set false to keep the pre-#1417 behavior.
HideSchedulerSessions *bool `toml:"hide_scheduler_sessions,omitempty"`
// Shell overrides the global shell for this project. See Config.Shell.
Shell string `toml:"shell,omitempty"`
// ShellProfile overrides the global shell_profile for this project.
Expand Down
70 changes: 70 additions & 0 deletions config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1914,6 +1914,76 @@ token = "test"
}
}

func TestLoad_HideSchedulerSessionsDefault(t *testing.T) {
configPath := writeConfigFixture(t, attachmentSendConfigFixture)
cfg, err := Load(configPath)
if err != nil {
t.Fatalf("Load: %v", err)
}
proj := cfg.Projects[0]
if proj.HideSchedulerSessions != nil {
t.Fatalf("HideSchedulerSessions should be nil by default, got %v", *proj.HideSchedulerSessions)
}
}

func TestLoad_HideSchedulerSessionsFalse(t *testing.T) {
fixture := `
[[projects]]
name = "delta"
hide_scheduler_sessions = false

[projects.agent]
type = "codex"

[projects.agent.options]
work_dir = "/tmp/delta"

[[projects.platforms]]
type = "telegram"

[projects.platforms.options]
token = "test"
`
configPath := writeConfigFixture(t, fixture)
cfg, err := Load(configPath)
if err != nil {
t.Fatalf("Load: %v", err)
}
proj := cfg.Projects[0]
if proj.HideSchedulerSessions == nil || *proj.HideSchedulerSessions {
t.Fatalf("HideSchedulerSessions should be false, got %v", proj.HideSchedulerSessions)
}
}

func TestLoad_HideSchedulerSessionsTrue(t *testing.T) {
fixture := `
[[projects]]
name = "epsilon"
hide_scheduler_sessions = true

[projects.agent]
type = "codex"

[projects.agent.options]
work_dir = "/tmp/epsilon"

[[projects.platforms]]
type = "telegram"

[projects.platforms.options]
token = "test"
`
configPath := writeConfigFixture(t, fixture)
cfg, err := Load(configPath)
if err != nil {
t.Fatalf("Load: %v", err)
}
proj := cfg.Projects[0]
if proj.HideSchedulerSessions == nil || !*proj.HideSchedulerSessions {
t.Fatalf("HideSchedulerSessions should be true, got %v", proj.HideSchedulerSessions)
}
}

func validProject(name string) ProjectConfig {
return ProjectConfig{
Name: name,
Expand Down
34 changes: 32 additions & 2 deletions core/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,9 @@ type Engine struct {
// hiding sessions created by direct CLI usage in the same work_dir.
// Default false = show all sessions.
filterExternalSessions bool
// When true, scheduler-created background sessions stay out of user-facing
// /list, /switch, and /delete views. Default true.
hideSchedulerSessions bool

// Shell configuration for /shell, cron exec, hooks, webhook exec
shell string // shell binary path (e.g. "sh", "/bin/zsh")
Expand Down Expand Up @@ -736,6 +739,7 @@ func NewEngine(name string, ag Agent, platforms []Platform, sessionStorePath str
maxQueuedMessages: defaultMaxQueuedMessages,
showContextIndicator: true,
showWorkdirIndicator: true,
hideSchedulerSessions: true,
shell: defaultShell(),
shellFlag: defaultShellFlag(),
pendingRestartTimeout: defaultPendingRestartTimeout,
Expand Down Expand Up @@ -970,6 +974,15 @@ func (e *Engine) SetFilterExternalSessions(v bool) {
e.filterExternalSessions = v
}

// SetHideSchedulerSessions controls whether cron/timer background sessions are
// hidden from normal user-facing session commands.
func (e *Engine) SetHideSchedulerSessions(v bool) {
e.hideSchedulerSessions = v
if e.sessions != nil {
e.sessions.SetHideBackgroundSessions(v)
}
}

func (e *Engine) SetWebSetupFunc(fn func() (int, string, bool, error)) { e.webSetupFunc = fn }
func (e *Engine) SetWebStatusFunc(fn func() string) { e.webStatusFunc = fn }

Expand Down Expand Up @@ -1550,7 +1563,7 @@ func (e *Engine) ExecuteCronJob(job *CronJob) error {

if useNewSession {
msg.SessionKey = runSessionKey
session := sessions.NewSideSession(runSessionKey, "cron-"+job.ID)
session := sessions.NewBackgroundSession(runSessionKey, "cron-"+job.ID)
if !session.TryLock() {
return fmt.Errorf("session %q is busy", runSessionKey)
}
Expand Down Expand Up @@ -1751,7 +1764,7 @@ func (e *Engine) ExecuteTimerJob(job *TimerJob) error {

if useNewSession {
msg.SessionKey = runSessionKey
session := sessions.NewSideSession(runSessionKey, "timer-"+job.ID)
session := sessions.NewBackgroundSession(runSessionKey, "timer-"+job.ID)
if !session.TryLock() {
return fmt.Errorf("session %q is busy", runSessionKey)
}
Expand Down Expand Up @@ -6795,12 +6808,29 @@ func (e *Engine) cmdNew(p Platform, msg *Message, args []string) {
// filter_external_sessions config. When disabled (default), all sessions are
// returned. When enabled, only sessions tracked by cc-connect are shown.
func (e *Engine) applySessionFilter(sessions []AgentSessionInfo, sm *SessionManager) []AgentSessionInfo {
if e.hideSchedulerSessions {
sessions = filterHiddenSessions(sessions, sm.BackgroundAgentSessionIDs())
}
if !e.filterExternalSessions {
return sessions
}
return filterOwnedSessions(sessions, sm.KnownAgentSessionIDs())
}

func filterHiddenSessions(sessions []AgentSessionInfo, hidden map[string]struct{}) []AgentSessionInfo {
if len(hidden) == 0 {
return sessions
}
filtered := make([]AgentSessionInfo, 0, len(sessions))
for _, s := range sessions {
if _, ok := hidden[s.ID]; ok {
continue
}
filtered = append(filtered, s)
}
return filtered
}

// filterOwnedSessions removes agent sessions that are not tracked by cc-connect's
// session manager. This prevents external CLI sessions in the same work_dir from
// appearing in /list, /switch, /delete, etc. If the session manager has no tracked
Expand Down
74 changes: 74 additions & 0 deletions core/engine_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14754,6 +14754,80 @@ func TestCmdList_DefaultShowsAllSessions(t *testing.T) {
}
}

func TestCmdList_HidesBackgroundSessionsByDefault(t *testing.T) {
agentSessions := []AgentSessionInfo{
{ID: "tracked-1", Summary: "Tracked session", MessageCount: 5},
{ID: "cron-1", Summary: "Cron background session", MessageCount: 10},
{ID: "external-1", Summary: "External session", MessageCount: 7},
}

agent := &stubListAgent{sessions: agentSessions}
p := &stubPlatformEngine{n: "plain"}
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
userKey := "test:user1"

s := e.sessions.GetOrCreateActive(userKey)
s.SetAgentSessionID("tracked-1", "codex")
bg := e.sessions.NewBackgroundSession(userKey, "cron-job")
bg.SetAgentSessionID("cron-1", "codex")
e.sessions.Save()

msg := &Message{SessionKey: userKey, ReplyCtx: "ctx"}
e.cmdList(p, msg, nil)

if len(p.sent) != 1 {
t.Fatalf("expected 1 reply, got %d", len(p.sent))
}
reply := p.sent[0]
if !strings.Contains(reply, "Tracked session") {
t.Errorf("should show tracked session:\n%s", reply)
}
if !strings.Contains(reply, "External session") {
t.Errorf("default mode should still show external session:\n%s", reply)
}
if strings.Contains(reply, "Cron background session") {
t.Errorf("should hide background cron session:\n%s", reply)
}
}

func TestCmdList_ShowsBackgroundSessionsWhenHideSchedulerSessionsDisabled(t *testing.T) {
agentSessions := []AgentSessionInfo{
{ID: "tracked-1", Summary: "Tracked session", MessageCount: 5},
{ID: "cron-1", Summary: "Cron background session", MessageCount: 10},
{ID: "external-1", Summary: "External session", MessageCount: 7},
}

agent := &stubListAgent{sessions: agentSessions}
p := &stubPlatformEngine{n: "plain"}
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
e.SetHideSchedulerSessions(false)
e.SetFilterExternalSessions(true)
userKey := "test:user1"

s := e.sessions.GetOrCreateActive(userKey)
s.SetAgentSessionID("tracked-1", "codex")
bg := e.sessions.NewBackgroundSession(userKey, "cron-job")
bg.SetAgentSessionID("cron-1", "codex")
e.sessions.Save()

msg := &Message{SessionKey: userKey, ReplyCtx: "ctx"}
e.cmdList(p, msg, nil)

if len(p.sent) != 1 {
t.Fatalf("expected 1 reply, got %d", len(p.sent))
}
reply := p.sent[0]
if !strings.Contains(reply, "Tracked session") {
t.Errorf("should show tracked session:\n%s", reply)
}
if !strings.Contains(reply, "Cron background session") {
t.Errorf("hide_scheduler_sessions=false should show background cron session:\n%s", reply)
}
if strings.Contains(reply, "External session") {
t.Errorf("filter_external_sessions=true should still hide external sessions:\n%s", reply)
}
}

// ---------------------------------------------------------------------------
// filter_external_sessions integration test suite
// Covers /list, /switch, /delete, renderListCard under both modes.
Expand Down
Loading
Loading