Skip to content
Open
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
72 changes: 65 additions & 7 deletions internal/session/instance.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ const (
codexBootstrapScanInterval = 2 * time.Second
codexRotationScanInterval = 30 * time.Second
opencodeRotationScanInterval = 15 * time.Second
opencodeRotationActivityWindow = 30 * time.Second
opencodeStartupTimeSkew = 5 * time.Second
// codexProbeScanInterval rate-limits process-file probing to avoid
// repeated /proc and lsof scans on every status tick.
codexProbeScanInterval = 2 * time.Second
Expand Down Expand Up @@ -921,6 +923,7 @@ func (i *Instance) watchForOpenCodeSession() {
func (i *Instance) setOpenCodeSession(sessionID string) {
i.OpenCodeSessionID = sessionID
i.OpenCodeDetectedAt = time.Now()
i.OpenCodeStartedAt = 0

if i.tmuxSession != nil {
if err := i.tmuxSession.SetEnvironment("OPENCODE_SESSION_ID", sessionID); err != nil {
Expand All @@ -938,12 +941,23 @@ type openCodeSessionMetadata struct {
}

// findBestOpenCodeSession keeps an existing binding if that session still exists
// for the project. Otherwise it falls back to the most recently updated match.
func findBestOpenCodeSession(sessions []openCodeSessionMetadata, projectPath, currentID string) string {
// for the project. Fresh launches stay unbound until OpenCode persists a session
// created during the current startup, which prevents adopting older same-project
// sessions before the new conversation has an ID. Already-bound sessions only
// rotate to a newer sibling when there was very recent local pane activity,
// which approximates an intentional in-pane `/new` without stealing sessions
// from other tabs in the same project.
func findBestOpenCodeSession(sessions []openCodeSessionMetadata, projectPath, currentID string, startedAt, activityAt int64) string {
normalizedProjectPath := normalizePath(projectPath)

var bestMatch string
var bestMatchTime int64
var currentMatchTime int64
var currentExists bool
var localRotationMatch string
var localRotationTime int64
startupThreshold := startedAt - opencodeStartupTimeSkew.Milliseconds()
activityThreshold := activityAt - opencodeStartupTimeSkew.Milliseconds()

for _, sess := range sessions {
sessDir := sess.Directory
Expand All @@ -957,21 +971,45 @@ func findBestOpenCodeSession(sessions []openCodeSessionMetadata, projectPath, cu

// Multiple OpenCode tabs can share a project path. A newer sibling session
// is not enough evidence to steal this instance's existing binding.
if currentID != "" && sess.ID == currentID {
return currentID
}

updatedAt := sess.Updated
if updatedAt == 0 {
updatedAt = sess.Created
}

if currentID != "" && sess.ID == currentID {
currentExists = true
currentMatchTime = updatedAt
if bestMatch == "" || updatedAt > bestMatchTime {
bestMatch = sess.ID
bestMatchTime = updatedAt
}
continue
}

if currentID == "" && startedAt > 0 && updatedAt < startupThreshold && sess.Created < startupThreshold {
continue
}

if currentID != "" && activityAt > 0 && (updatedAt >= activityThreshold || sess.Created >= activityThreshold) {
if localRotationMatch == "" || updatedAt > localRotationTime {
localRotationMatch = sess.ID
localRotationTime = updatedAt
}
}

if bestMatch == "" || updatedAt > bestMatchTime {
bestMatch = sess.ID
bestMatchTime = updatedAt
}
}

if currentID != "" && currentExists {
if localRotationMatch != "" && localRotationTime > currentMatchTime {
return localRotationMatch
}
return currentID
}

return bestMatch
}

Expand Down Expand Up @@ -1004,7 +1042,15 @@ func (i *Instance) queryOpenCodeSession() string {

sessionLog.Debug("opencode_parsed_sessions", slog.Int("count", len(sessions)))

bestMatch := findBestOpenCodeSession(sessions, i.ProjectPath, i.OpenCodeSessionID)
var activityAt int64
if currentID := i.OpenCodeSessionID; currentID != "" {
lastActivity := i.GetLastActivityTime()
if !lastActivity.IsZero() && time.Since(lastActivity) <= opencodeRotationActivityWindow {
activityAt = lastActivity.UnixMilli()
}
}

bestMatch := findBestOpenCodeSession(sessions, i.ProjectPath, i.OpenCodeSessionID, i.OpenCodeStartedAt, activityAt)
sessionLog.Debug(
"opencode_best_match",
slog.String("session_id", bestMatch),
Expand Down Expand Up @@ -2803,6 +2849,18 @@ func (i *Instance) applyOpenCodeSessionCandidate(candidate string) bool {
return false
}

if i.OpenCodeSessionID != "" {
lastActivity := i.GetLastActivityTime()
if !lastActivity.IsZero() && time.Since(lastActivity) <= opencodeRotationActivityWindow {
sessionLog.Debug(
"opencode_session_rebind_recent_activity",
slog.String("old_id", i.OpenCodeSessionID),
slog.String("new_id", candidate),
slog.Time("last_activity", lastActivity),
)
}
}

sessionLog.Debug(
"opencode_session_rebind",
slog.String("old_id", i.OpenCodeSessionID),
Expand Down
33 changes: 32 additions & 1 deletion internal/session/opencode_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ func TestOpenCodeSessionMatching(t *testing.T) {
name string
projectPath string
currentID string
startedAt int64
activityAt int64
wantID string
wantMatch bool
}{
Expand Down Expand Up @@ -69,6 +71,35 @@ func TestOpenCodeSessionMatching(t *testing.T) {
wantID: "ses_NEW001",
wantMatch: true,
},
{
name: "Fresh startup ignores older same-project sessions until a new one appears",
projectPath: "/Users/ashesh/claude-deck",
startedAt: 1768982300000,
wantID: "",
wantMatch: false,
},
{
name: "Fresh startup binds session created during current launch",
projectPath: "/Users/ashesh/claude-deck",
startedAt: 1768982199000,
wantID: "ses_NEW001",
wantMatch: true,
},
{
name: "Recent local activity allows rebinding to newer sibling session",
projectPath: "/Users/ashesh/claude-deck",
currentID: "ses_OLD001",
activityAt: 1768982199000,
wantID: "ses_NEW001",
wantMatch: true,
},
{
name: "Stale local activity keeps existing session binding",
projectPath: "/Users/ashesh/claude-deck",
currentID: "ses_OLD001",
wantID: "ses_OLD001",
wantMatch: true,
},
{
name: "No match for unknown directory",
projectPath: "/Users/ashesh/nonexistent",
Expand All @@ -87,7 +118,7 @@ func TestOpenCodeSessionMatching(t *testing.T) {
}

// Apply the matching logic (same as queryOpenCodeSession but testable)
gotID := findBestOpenCodeSession(sessions, tt.projectPath, tt.currentID)
gotID := findBestOpenCodeSession(sessions, tt.projectPath, tt.currentID, tt.startedAt, tt.activityAt)

if tt.wantMatch {
if gotID != tt.wantID {
Expand Down
Loading