feat: fork session on --continue to prevent context contamination (#244)

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) <noreply@anthropic.com>
This commit is contained in:
Leigh Stillard
2026-03-21 23:32:48 +11:00
committed by GitHub
parent 6ad4d49950
commit df5cc64ef4
3 changed files with 31 additions and 5 deletions

View File

@@ -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 != "" {

View File

@@ -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
}

View File

@@ -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"`