diff --git a/CHANGELOG.md b/CHANGELOG.md index 078d84860..2da73b7f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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.md` is registered; nested SKILL.md files (e.g. inside `/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 `` 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. diff --git a/cmd/cc-connect/main.go b/cmd/cc-connect/main.go index 3001d1380..fba589f1c 100644 --- a/cmd/cc-connect/main.go +++ b/cmd/cc-connect/main.go @@ -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) @@ -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 { diff --git a/config.example.toml b/config.example.toml index b7706d1e0..87cbfbfc0 100644 --- a/config.example.toml +++ b/config.example.toml @@ -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. diff --git a/config/config.go b/config/config.go index 908027db3..b74e5b926 100644 --- a/config/config.go +++ b/config/config.go @@ -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. diff --git a/config/config_test.go b/config/config_test.go index 505067234..5697ea310 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -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, diff --git a/core/engine.go b/core/engine.go index d51fc2799..0c5db9888 100644 --- a/core/engine.go +++ b/core/engine.go @@ -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") @@ -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, @@ -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 } @@ -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) } @@ -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) } @@ -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 diff --git a/core/engine_test.go b/core/engine_test.go index b53f3c639..8ab6de4a6 100644 --- a/core/engine_test.go +++ b/core/engine_test.go @@ -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. diff --git a/core/session.go b/core/session.go index b69943e93..7e7f4d503 100644 --- a/core/session.go +++ b/core/session.go @@ -18,11 +18,12 @@ const ContinueSession = "__continue__" // Session tracks one conversation between a user and the agent. type Session struct { - ID string `json:"id"` - Name string `json:"name"` - AgentSessionID string `json:"agent_session_id"` - AgentType string `json:"agent_type,omitempty"` - PastAgentSessionIDs []string `json:"past_agent_session_ids,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + AgentSessionID string `json:"agent_session_id"` + AgentType string `json:"agent_type,omitempty"` + PastAgentSessionIDs []string `json:"past_agent_session_ids,omitempty"` + Background bool `json:"background,omitempty"` // ActiveProvider is the agent provider name that was active when this // session last took a turn. It is restored before --resume so that a // cc-connect process restart does not silently drop a user's @@ -288,6 +289,9 @@ type SessionManager struct { userMeta map[string]*UserMeta // sessionKey → display info counter int64 storePath string // empty = no persistence + // hideBackgroundSessions controls whether scheduler-created background + // sessions are excluded from user-facing session lists and tracked ID sets. + hideBackgroundSessions bool // legacyData is true when sessions were loaded from a snapshot that // predates PastAgentSessionIDs tracking. In this state, many sessions @@ -298,12 +302,13 @@ type SessionManager struct { func NewSessionManager(storePath string) *SessionManager { sm := &SessionManager{ - sessions: make(map[string]*Session), - activeSession: make(map[string]string), - userSessions: make(map[string][]string), - sessionNames: make(map[string]string), - userMeta: make(map[string]*UserMeta), - storePath: storePath, + sessions: make(map[string]*Session), + activeSession: make(map[string]string), + userSessions: make(map[string][]string), + sessionNames: make(map[string]string), + userMeta: make(map[string]*UserMeta), + storePath: storePath, + hideBackgroundSessions: true, } if storePath != "" { sm.load() @@ -316,6 +321,12 @@ func (sm *SessionManager) StorePath() string { return sm.storePath } +func (sm *SessionManager) SetHideBackgroundSessions(v bool) { + sm.mu.Lock() + defer sm.mu.Unlock() + sm.hideBackgroundSessions = v +} + func (sm *SessionManager) nextID() string { sm.counter++ return fmt.Sprintf("s%d", sm.counter) @@ -347,15 +358,27 @@ func (sm *SessionManager) NewSession(userKey, name string) *Session { // session. Used for isolated one-off runs (e.g. cron with session_mode=new_per_run) // so the user's current chat remains the default target for normal messages. func (sm *SessionManager) NewSideSession(userKey, name string) *Session { + return sm.newSideSession(userKey, name, false) +} + +// NewBackgroundSession registers an isolated background session without making +// it the user's active session. Background sessions are hidden from normal +// /list, /switch, and /delete views after they complete. +func (sm *SessionManager) NewBackgroundSession(userKey, name string) *Session { + return sm.newSideSession(userKey, name, true) +} + +func (sm *SessionManager) newSideSession(userKey, name string, background bool) *Session { sm.mu.Lock() defer sm.mu.Unlock() id := sm.nextID() now := time.Now() s := &Session{ - ID: id, - Name: name, - CreatedAt: now, - UpdatedAt: now, + ID: id, + Name: name, + Background: background, + CreatedAt: now, + UpdatedAt: now, } sm.sessions[id] = s sm.userSessions[userKey] = append(sm.userSessions[userKey], id) @@ -430,6 +453,12 @@ func (sm *SessionManager) ListSessions(userKey string) []*Session { out := make([]*Session, 0, len(ids)) for _, sid := range ids { if s, ok := sm.sessions[sid]; ok { + s.mu.Lock() + background := s.Background + s.mu.Unlock() + if sm.hideBackgroundSessions && background { + continue + } out = append(out, s) } } @@ -522,14 +551,40 @@ func (sm *SessionManager) KnownAgentSessionIDs() map[string]struct{} { if sm.legacyData { return nil } + hideBackground := sm.hideBackgroundSessions ids := make(map[string]struct{}) for _, s := range sm.sessions { s.mu.Lock() - if s.AgentSessionID != "" { + background := s.Background + include := !hideBackground || !background + if include && s.AgentSessionID != "" { ids[s.AgentSessionID] = struct{}{} } - for _, past := range s.PastAgentSessionIDs { - ids[past] = struct{}{} + if include { + for _, past := range s.PastAgentSessionIDs { + ids[past] = struct{}{} + } + } + s.mu.Unlock() + } + return ids +} + +// BackgroundAgentSessionIDs returns agent session IDs created by scheduler +// background runs. These should stay out of normal user-facing session lists. +func (sm *SessionManager) BackgroundAgentSessionIDs() map[string]struct{} { + sm.mu.RLock() + defer sm.mu.RUnlock() + ids := make(map[string]struct{}) + for _, s := range sm.sessions { + s.mu.Lock() + if s.Background { + if s.AgentSessionID != "" { + ids[s.AgentSessionID] = struct{}{} + } + for _, past := range s.PastAgentSessionIDs { + ids[past] = struct{}{} + } } s.mu.Unlock() } @@ -642,6 +697,7 @@ func (sm *SessionManager) saveLocked() { AgentSessionID: agentSID, AgentType: s.AgentType, PastAgentSessionIDs: append([]string(nil), s.PastAgentSessionIDs...), + Background: s.Background, History: append([]HistoryEntry(nil), s.History...), CreatedAt: s.CreatedAt, UpdatedAt: s.UpdatedAt, @@ -828,7 +884,7 @@ func (sm *SessionManager) PruneDuplicateSessions(mergeHistory bool) PruneResult defer sm.mu.Unlock() // Group sessions by baseChat - chatSessions := make(map[string][]*Session) // baseChat -> sessions + chatSessions := make(map[string][]*Session) // baseChat -> sessions sessionToBaseChat := make(map[string]string) // session.ID -> baseChat for userKey, sessionIDs := range sm.userSessions { diff --git a/core/session_test.go b/core/session_test.go index 9c04a4a6e..7f26d4860 100644 --- a/core/session_test.go +++ b/core/session_test.go @@ -60,6 +60,54 @@ func TestSessionManager_NewSideSession(t *testing.T) { } } +func TestSessionManager_NewBackgroundSessionHiddenFromUserList(t *testing.T) { + sm := NewSessionManager("") + main := sm.GetOrCreateActive("user1") + bg := sm.NewBackgroundSession("user1", "cron-job") + bg.SetAgentSessionID("cron-agent-1", "codex") + + if bg.ID == main.ID { + t.Fatal("background session should be a new record") + } + if sm.ActiveSessionID("user1") != main.ID { + t.Errorf("active session should stay main %q, got %q", main.ID, sm.ActiveSessionID("user1")) + } + list := sm.ListSessions("user1") + if len(list) != 1 || list[0].ID != main.ID { + t.Fatalf("ListSessions = %#v, want only main session", list) + } + known := sm.KnownAgentSessionIDs() + if _, ok := known["cron-agent-1"]; ok { + t.Fatal("background agent session should not be in known visible IDs") + } + hidden := sm.BackgroundAgentSessionIDs() + if _, ok := hidden["cron-agent-1"]; !ok { + t.Fatal("background agent session should be in hidden IDs") + } +} + +func TestSessionManager_BackgroundSessionsVisibleWhenDisabled(t *testing.T) { + sm := NewSessionManager("") + sm.SetHideBackgroundSessions(false) + main := sm.GetOrCreateActive("user1") + main.SetAgentSessionID("main-agent-1", "codex") + bg := sm.NewBackgroundSession("user1", "cron-job") + bg.SetAgentSessionID("cron-agent-1", "codex") + + list := sm.ListSessions("user1") + if len(list) != 2 { + t.Fatalf("ListSessions = %#v, want main and background sessions", list) + } + known := sm.KnownAgentSessionIDs() + if _, ok := known["cron-agent-1"]; !ok { + t.Fatalf("known IDs should include background when visible: %#v", known) + } + hidden := sm.BackgroundAgentSessionIDs() + if _, ok := hidden["cron-agent-1"]; !ok { + t.Fatal("background IDs should still be tracked for filtering when enabled again") + } +} + func TestSessionManager_SwitchSession(t *testing.T) { sm := NewSessionManager("") s1 := sm.NewSession("user1", "first") @@ -1051,6 +1099,32 @@ func TestKnownAgentSessionIDs_IncludesPast(t *testing.T) { } } +func TestBackgroundAgentSessionIDs_IncludesPastAndPersists(t *testing.T) { + path := filepath.Join(t.TempDir(), "sessions.json") + sm := NewSessionManager(path) + bg := sm.NewBackgroundSession("user1", "cron-job") + bg.SetAgentSessionID("cron-thread-1", "codex") + bg.SetAgentSessionID("cron-thread-2", "codex") + sm.Save() + + sm2 := NewSessionManager(path) + if got := sm2.ListSessions("user1"); len(got) != 0 { + t.Fatalf("ListSessions after reload = %d, want 0 background-hidden sessions", len(got)) + } + hidden := sm2.BackgroundAgentSessionIDs() + for _, id := range []string{"cron-thread-1", "cron-thread-2"} { + if _, ok := hidden[id]; !ok { + t.Fatalf("hidden IDs missing %q: %#v", id, hidden) + } + } + known := sm2.KnownAgentSessionIDs() + for _, id := range []string{"cron-thread-1", "cron-thread-2"} { + if _, ok := known[id]; ok { + t.Fatalf("known visible IDs should not include background %q: %#v", id, known) + } + } +} + // TestKnownAgentSessionIDs_ReproducesNewCommandBug simulates the exact user // reproduction steps: repeated /new commands progressively clear AgentSessionIDs. // Before the PastAgentSessionIDs fix, only the latest session would remain visible. @@ -1117,4 +1191,3 @@ func TestKnownAgentSessionIDs_ResetAllSessionsBug(t *testing.T) { t.Fatalf("filterOwnedSessions returned %d, want 3", len(filtered)) } } -