From df5cc64ef41c532499db45a12da53ba7100c2617 Mon Sep 17 00:00:00 2001 From: Leigh Stillard Date: Sat, 21 Mar 2026 23:32:48 +1100 Subject: [PATCH] feat: fork session on --continue to prevent context contamination (#244) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When cc-connect starts up and picks up the most recent CLI session via --continue, it now adds --fork-session to create an independent context branch. This prevents history interleaving when the user is concurrently working in the same workspace from both the CLI and a messaging platform. Without this, resuming into an active CLI session corrupts both context windows — the model sees interleaved tool calls and responses from two different conversations, leading to hallucinations about files and state. The fork only applies to --continue (first connection after startup, where we grab "whatever is latest" which may be an active CLI session). Plain --resume of cc-connect's own stored session ID is unaffected, since that session belongs to cc-connect and has no interleaving risk. Co-authored-by: Claude Opus 4.6 (1M context) --- agent/claudecode/session.go | 12 +++++++++++- core/engine.go | 19 +++++++++++++++---- core/message.go | 5 +++++ 3 files changed, 31 insertions(+), 5 deletions(-) diff --git a/agent/claudecode/session.go b/agent/claudecode/session.go index 4199207..a41637a 100644 --- a/agent/claudecode/session.go +++ b/agent/claudecode/session.go @@ -52,7 +52,17 @@ func newClaudeSession(ctx context.Context, workDir, model, sessionID, mode strin if mode != "" && mode != "default" { args = append(args, "--permission-mode", mode) } - if sessionID != "" { + switch sessionID { + case "": + // Truly fresh session — no resume, no continue. + case core.ContinueSession: + // --continue grabs the most recent session in the workspace, which + // may belong to an active CLI terminal. Fork it so the platform + // conversation gets its own independent context branch. + args = append(args, "--continue", "--fork-session") + default: + // Resuming a known session ID — this is cc-connect's own session + // from a previous connection, safe to resume directly. args = append(args, "--resume", sessionID) } if model != "" { diff --git a/core/engine.go b/core/engine.go index 3b92b5b..2d6b89f 100644 --- a/core/engine.go +++ b/core/engine.go @@ -15,6 +15,7 @@ import ( "strconv" "strings" "sync" + "sync/atomic" "time" "unicode/utf8" ) @@ -192,6 +193,8 @@ type Engine struct { quietMu sync.RWMutex quiet bool // when true, suppress thinking and tool progress messages globally + + hasConnectedOnce atomic.Bool // first connection uses --continue to bridge CLI usage } // workspaceInitFlow tracks a channel that is being onboarded to a workspace. @@ -1739,9 +1742,17 @@ func (e *Engine) getOrCreateInteractiveStateWith(sessionKey string, p Platform, return state } - agentSID := session.GetAgentSessionID() + // On first connection after engine startup, use --continue to pick up + // the most recent CLI session (bridges direct CLI and cc-connect usage). + // Subsequent connections respect the stored session ID (or "" for fresh). + startSessionID := session.GetAgentSessionID() + if !e.hasConnectedOnce.Swap(true) { + startSessionID = ContinueSession + } + + isResume := startSessionID != "" startAt := time.Now() - agentSession, err := agent.StartSession(e.ctx, agentSID) + agentSession, err := agent.StartSession(e.ctx, startSessionID) startElapsed := time.Since(startAt) if err != nil { slog.Error("failed to start interactive session", "error", err, "elapsed", startElapsed) @@ -1750,7 +1761,7 @@ func (e *Engine) getOrCreateInteractiveStateWith(sessionKey string, p Platform, return state } if startElapsed >= slowAgentStart { - slog.Warn("slow agent session start", "elapsed", startElapsed, "agent", agent.Name(), "session_id", agentSID) + slog.Warn("slow agent session start", "elapsed", startElapsed, "agent", agent.Name(), "session_id", startSessionID) } if newID := agentSession.CurrentSessionID(); newID != "" { @@ -1767,7 +1778,7 @@ func (e *Engine) getOrCreateInteractiveStateWith(sessionKey string, p Platform, } e.interactiveStates[sessionKey] = state - slog.Info("interactive session started", "session_key", sessionKey, "agent_session", session.GetAgentSessionID(), "elapsed", startElapsed) + slog.Info("session spawned", "session_key", sessionKey, "agent_session", session.GetAgentSessionID(), "is_resume", isResume, "elapsed", startElapsed) return state } diff --git a/core/message.go b/core/message.go index 439c1d8..a741a49 100644 --- a/core/message.go +++ b/core/message.go @@ -155,6 +155,11 @@ const ( EventThinking EventType = "thinking" // thinking/processing status ) +// ContinueSession is a sentinel value passed to Agent.StartSession to indicate +// that the agent should pick up the most recent session in the workspace +// (equivalent to `claude --continue`), rather than resuming a specific session ID. +const ContinueSession = "__continue__" + // UserQuestion represents a structured question from AskUserQuestion. type UserQuestion struct { Question string `json:"question"`