Skip to content
Merged
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
193 changes: 193 additions & 0 deletions internal/tmux/classify_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
package tmux

import (
"reflect"
"sort"
"testing"
)

func TestClassifyClaudeProcs_DirectChildAndOrphan(t *testing.T) {
// Pane shell PID 100 spawned a direct claude (PID 200).
// PID 300 is an orphan (parent isn't a pane and isn't another claude).
procs := []ClaudeProc{
{PID: 200, PPID: 100, Args: "claude --session-id abc"},
{PID: 300, PPID: 1, Args: "claude --session-id def"},
}
panePIDs := map[int]bool{100: true}

direct, orphan := classifyClaudeProcs(procs, panePIDs)
if got, want := direct[100], "claude --session-id abc"; got != want {
t.Fatalf("direct[100] = %q, want %q", got, want)
}
if len(orphan) != 1 || orphan[0].PID != 300 {
t.Fatalf("expected orphan PID 300; got %+v", orphan)
}
}

func TestClassifyClaudeProcs_DropsSubagent(t *testing.T) {
// Pane shell PID 100 -> claude PID 200 -> subagent PID 201 (PPID=200).
// The subagent must NOT appear in either direct or orphan, otherwise the
// fallback would mark an unrelated past session on the same path as LIVE.
procs := []ClaudeProc{
{PID: 200, PPID: 100, Args: "claude --session-id main"},
{PID: 201, PPID: 200, Args: "claude --session-id agent-1"},
{PID: 202, PPID: 200, Args: "claude --session-id agent-2"},
}
panePIDs := map[int]bool{100: true}

direct, orphan := classifyClaudeProcs(procs, panePIDs)
if got, want := direct[100], "claude --session-id main"; got != want {
t.Fatalf("direct[100] = %q, want %q", got, want)
}
if len(orphan) != 0 {
gotPIDs := make([]int, len(orphan))
for i, o := range orphan {
gotPIDs[i] = o.PID
}
sort.Ints(gotPIDs)
t.Fatalf("subagents must be excluded; got orphan PIDs %v", gotPIDs)
}
if len(direct) != 1 {
t.Fatalf("direct map should hold exactly 1 entry, got %d", len(direct))
}
}

func TestClassifyClaudeProcs_NestedSubagent(t *testing.T) {
// Three-level chain: pane -> claude (200) -> subagent (201) -> nested (202).
// Both 201 and 202 must be dropped.
procs := []ClaudeProc{
{PID: 200, PPID: 100, Args: "claude main"},
{PID: 201, PPID: 200, Args: "claude sub-1"},
{PID: 202, PPID: 201, Args: "claude sub-2"},
}
panePIDs := map[int]bool{100: true}

direct, orphan := classifyClaudeProcs(procs, panePIDs)
if len(direct) != 1 || direct[100] != "claude main" {
t.Fatalf("direct = %v, want only main claude", direct)
}
if len(orphan) != 0 {
t.Fatalf("nested subagents must be excluded; got %d orphans", len(orphan))
}
}

func TestClassifyClaudeProcs_StandaloneOrphan(t *testing.T) {
// nohup'd / pane-closed claude with PPID=1 is a legitimate orphan and
// should still be classified as orphan (not subagent).
procs := []ClaudeProc{
{PID: 500, PPID: 1, Args: "claude --session-id standalone"},
}
panePIDs := map[int]bool{100: true, 200: true}

direct, orphan := classifyClaudeProcs(procs, panePIDs)
if len(direct) != 0 {
t.Fatalf("direct should be empty, got %v", direct)
}
wantOrphan := []ClaudeProc{{PID: 500, PPID: 1, Args: "claude --session-id standalone"}}
if !reflect.DeepEqual(orphan, wantOrphan) {
t.Fatalf("orphan = %+v, want %+v", orphan, wantOrphan)
}
}

func TestClassifyClaudeProcsByAncestry_CcproxyWrappedClaude(t *testing.T) {
// pane shell (100) -> ccproxy (150) -> claude (200).
// The ancestry walk must attribute claude to pane shell 100 even though
// the immediate PPID is ccproxy, not the pane.
procs := []ClaudeProc{
{PID: 200, PPID: 150, Args: "claude --settings ..."},
}
panePIDs := map[int]bool{100: true}
ppidOf := map[int]int{
200: 150,
150: 100,
100: 1,
}
direct, orphan := classifyClaudeProcsByAncestry(procs, panePIDs, ppidOf)
if got, want := direct[100], "claude --settings ..."; got != want {
t.Fatalf("direct[100] = %q, want %q", got, want)
}
if len(orphan) != 0 {
t.Fatalf("ccproxy-wrapped claude must not be orphaned; got %+v", orphan)
}
}

func TestClassifyClaudeProcsByAncestry_SubagentUnderWrappedClaude(t *testing.T) {
// pane (100) -> ccproxy (150) -> main claude (200) -> sub claude (201).
// Sub must be dropped because the chain passes through another claude
// before reaching the pane.
procs := []ClaudeProc{
{PID: 200, PPID: 150, Args: "main"},
{PID: 201, PPID: 200, Args: "sub"},
}
panePIDs := map[int]bool{100: true}
ppidOf := map[int]int{
200: 150,
201: 200,
150: 100,
100: 1,
}
direct, orphan := classifyClaudeProcsByAncestry(procs, panePIDs, ppidOf)
if got, want := direct[100], "main"; got != want {
t.Fatalf("direct[100] = %q, want %q", got, want)
}
if len(orphan) != 0 {
t.Fatalf("subagent must be dropped; got %+v", orphan)
}
}

func TestClassifyClaudeProcsByAncestry_TrueOrphan(t *testing.T) {
procs := []ClaudeProc{{PID: 300, PPID: 1, Args: "lingering"}}
panePIDs := map[int]bool{100: true}
ppidOf := map[int]int{300: 1, 100: 1}
direct, orphan := classifyClaudeProcsByAncestry(procs, panePIDs, ppidOf)
if len(direct) != 0 {
t.Fatalf("no pane in chain; direct should be empty, got %v", direct)
}
if len(orphan) != 1 || orphan[0].PID != 300 {
t.Fatalf("expected lingering claude in orphans; got %+v", orphan)
}
}

func TestClassifyClaudeProcsByAncestry_CycleDefense(t *testing.T) {
procs := []ClaudeProc{{PID: 400, PPID: 401, Args: "loop"}}
panePIDs := map[int]bool{100: true}
ppidOf := map[int]int{400: 401, 401: 400}
direct, orphan := classifyClaudeProcsByAncestry(procs, panePIDs, ppidOf)
if len(direct) != 0 {
t.Fatalf("cycle without pane should produce no direct; got %v", direct)
}
if len(orphan) != 1 {
t.Fatalf("cycle without pane should yield 1 orphan; got %+v", orphan)
}
}

func TestClassifyClaudeProcsByAncestry_RehomeScenario(t *testing.T) {
// The user-reported case: pane 1 has ccproxy-wrapped claude; two other
// claude processes (e.g. leftovers from previous pane-1 sessions) are
// orphans whose cwds happen to match current-window paths. Only the
// pane-1-owned claude must show up under directByPaneShell. The orphans
// must NOT silently inherit the current-window pane shell.
procs := []ClaudeProc{
{PID: 200, PPID: 150, Args: "claude main"},
{PID: 300, PPID: 1, Args: "claude orphan-a"},
{PID: 400, PPID: 1, Args: "claude orphan-b"},
}
panePIDs := map[int]bool{100: true}
ppidOf := map[int]int{
200: 150,
150: 100,
100: 1,
300: 1,
400: 1,
}
direct, orphan := classifyClaudeProcsByAncestry(procs, panePIDs, ppidOf)
if got, want := direct[100], "claude main"; got != want {
t.Fatalf("direct[100] = %q, want %q", got, want)
}
if len(direct) != 1 {
t.Fatalf("only pane 1's claude should be direct; got %d entries (%v)", len(direct), direct)
}
if len(orphan) != 2 {
t.Fatalf("two unattributable claudes should be orphans; got %+v", orphan)
}
}
146 changes: 132 additions & 14 deletions internal/tmux/live.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,22 +78,20 @@ func markLiveSessionsTmux(sessions []session.Session) {
return
}

// Separate direct-child procs (ppid matches a pane) from orphaned (ppid=1)
// Walk every claude process up its PPID chain to find which tmux pane
// shell — if any — owns it. This handles claudes that aren't direct
// children of a pane shell (e.g. wrapped by ccproxy/teen/sudo). Subagents
// (claude under another claude) and true orphans (no pane in the ancestor
// chain) are filtered out of pane attribution.
panePIDs := make(map[int]bool, len(panes))
for _, p := range panes {
panePIDs[p.PID] = true
}
directByPPID := make(map[int]string) // ppid → args (for pane-matched procs)
var orphaned []ClaudeProc // ppid=1 or ppid not matching any pane
for _, cp := range allProcs {
if panePIDs[cp.PPID] {
directByPPID[cp.PPID] = cp.Args
} else {
orphaned = append(orphaned, cp)
}
}
ppidOf := batchPPIDMap()
directByPaneShell, orphaned := classifyClaudeProcsByAncestry(allProcs, panePIDs, ppidOf)

// Build pane PID → claude args map (direct children)
// Build pane PID → claude args map. Only panes whose shell owns a real
// claude (per the PPID walk) get a cps entry.
type claudeMatch struct {
args string
windowName string
Expand All @@ -102,7 +100,7 @@ func markLiveSessionsTmux(sessions []session.Session) {
}
var cps []claudeMatch
for _, p := range panes {
if args, ok := directByPPID[p.PID]; ok {
if args, ok := directByPaneShell[p.PID]; ok {
absPath, _ := filepath.Abs(p.Path)
if absPath != "" {
inCur := currentKey != "" && p.Session+"|"+p.Window == currentKey
Expand All @@ -111,11 +109,15 @@ func markLiveSessionsTmux(sessions []session.Session) {
}
}

// For orphaned claude procs, resolve their cwd via lsof and match to sessions
// True orphans (no tmux pane in their ancestry) are still alive but don't
// belong to any visible window. Mark them LIVE via their cwd, but never
// mark them as belonging to the current window — even when their cwd
// matches a pane in this window, the orphan itself isn't in this window.
if len(orphaned) > 0 {
_ = currentWindowPaths // retained for documentation; orphans deliberately ignore it
orphanCwds := resolveOrphanCwds(orphaned)
for _, oc := range orphanCwds {
cps = append(cps, claudeMatch{args: oc.args, path: oc.cwd, currentWindow: currentWindowPaths[oc.cwd]})
cps = append(cps, claudeMatch{args: oc.args, path: oc.cwd, currentWindow: false})
}
}

Expand Down Expand Up @@ -168,6 +170,122 @@ type ClaudeProc struct {
Args string
}

// classifyClaudeProcsByAncestry walks each claude process up its PPID chain
// using ppidOf and classifies it as:
// - direct: the chain reaches a tmux pane shell PID without first passing
// through another claude → return value `directByPaneShell` maps that
// pane shell PID → claude args.
// - subagent: the chain passes through another claude before reaching a
// pane shell → silently dropped (the parent claude already attributes
// the work to its own session).
// - true orphan: the chain ends at init (PPID 0 / 1) or a dead process
// without ever reaching a pane shell → returned in `orphaned`.
//
// The PPID walk is bounded by len(ppidOf) iterations to defend against
// cycles in a corrupt process map. When ppidOf is empty (lookup unavailable)
// the function falls back to the immediate PPID — only direct pane children
// and direct subagents are recognised.
func classifyClaudeProcsByAncestry(procs []ClaudeProc, panePIDs map[int]bool, ppidOf map[int]int) (directByPaneShell map[int]string, orphaned []ClaudeProc) {
claudePIDs := make(map[int]bool, len(procs))
for _, cp := range procs {
claudePIDs[cp.PID] = true
}
directByPaneShell = make(map[int]string)

walkOwningPane := func(startPID int) (paneShellPID int, isSubagent bool) {
cur := startPID
if ppid, ok := ppidOf[cur]; ok {
cur = ppid
} else {
// No process tree available; only direct parent is known via procs.
for _, cp := range procs {
if cp.PID == startPID {
cur = cp.PPID
break
}
}
}
// Bound the walk so a corrupt cycle can't hang us.
steps := len(ppidOf) + len(procs) + 4
for i := 0; i < steps && cur > 1; i++ {
if cur != startPID && claudePIDs[cur] {
return 0, true
}
if panePIDs[cur] {
return cur, false
}
next, ok := ppidOf[cur]
if !ok {
return 0, false
}
cur = next
}
return 0, false
}

for _, cp := range procs {
paneShell, sub := walkOwningPane(cp.PID)
if sub {
continue
}
if paneShell != 0 {
directByPaneShell[paneShell] = cp.Args
continue
}
orphaned = append(orphaned, cp)
}
return directByPaneShell, orphaned
}

// classifyClaudeProcs partitions claude processes by their immediate PPID.
// This is the cheap pre-walk classification kept for backwards compatibility
// with callers that only inspect direct parent relationships. New code should
// prefer classifyClaudeProcsByAncestry.
func classifyClaudeProcs(procs []ClaudeProc, panePIDs map[int]bool) (directByPPID map[int]string, orphaned []ClaudeProc) {
claudePIDs := make(map[int]bool, len(procs))
for _, cp := range procs {
claudePIDs[cp.PID] = true
}
directByPPID = make(map[int]string)
for _, cp := range procs {
if panePIDs[cp.PPID] {
directByPPID[cp.PPID] = cp.Args
continue
}
if claudePIDs[cp.PPID] {
continue // subagent of another claude
}
orphaned = append(orphaned, cp)
}
return directByPPID, orphaned
}

// batchPPIDMap returns a pid → ppid map for every process visible to ps.
// Used by classifyClaudeProcsByAncestry to walk past intermediate wrappers
// such as ccproxy / tee / sudo when looking for the owning pane shell.
// Returns an empty map if the lookup fails — callers should still degrade
// gracefully (treating claudes as direct or orphan based on immediate PPID).
func batchPPIDMap() map[int]int {
out, err := exec.Command("ps", "-e", "-o", "pid=,ppid=").Output()
if err != nil {
return map[int]int{}
}
m := make(map[int]int)
for _, line := range strings.Split(string(out), "\n") {
fields := strings.Fields(strings.TrimSpace(line))
if len(fields) < 2 {
continue
}
pid, err1 := strconv.Atoi(fields[0])
ppid, err2 := strconv.Atoi(fields[1])
if err1 != nil || err2 != nil {
continue
}
m[pid] = ppid
}
return m
}

// BatchFindClaudeProcs finds all claude processes and maps parent PID → args.
// When multiple processes share ppid=1 (orphaned/reparented), they are stored
// in the OrphanedProcs slice instead to avoid map key collisions.
Expand Down
1 change: 1 addition & 0 deletions internal/tui/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -3687,6 +3687,7 @@ func (a *App) doRefresh() tea.Cmd {
oldLive[i] = liveState{a.sessions[i].IsLive, a.sessions[i].IsResponding}
a.sessions[i].IsLive = false
a.sessions[i].IsResponding = false
a.sessions[i].IsCurrentWindow = false
}
tmux.MarkLiveSessions(a.sessions)
for i := range a.sessions {
Expand Down
Loading
Loading