//go:build !windows package infra import ( "strconv" "strings" ) // ResolvePaneIDs crosses the PID of each Claude process with the panes of the // given isolated tmux socket (tmux -L list-panes -a) and returns a // claudePID -> pane_id ("%N") map. // // The pane_id is STABLE for the pane's whole life: it identifies a Claude even // when its window_id (@N) migrates with the focus swap (break-pane + join-pane // move the pane between windows). That is why it is the correct stable handle // for an agent, as opposed to the window_id which changes by design. // // For each claudePID it climbs the process tree (PPID in /proc//stat) until // it finds a PID that is a pane_pid of the socket; that pane is the one hosting // the Claude. Normally the pane_pid IS the claudePID because the pane runs // `exec claude`, but a shell that launched claude as a child is covered by the // ascent. // // Best-effort and crash-free: an empty socket, no PIDs, or a tmux failure // (socket down, tmux absent) all yield an empty map; a Claude with no resolvable // pane is simply omitted from the result (callers degrade it to ""). It reads // /proc, hence the //go:build !windows tag. func ResolvePaneIDs(socket string, claudePIDs []int) map[int]string { if socket == "" || len(claudePIDs) == 0 { return map[int]string{} } out, _, err := runTmux(socket, "list-panes", "-a", "-F", "#{pane_pid} #{pane_id}") if err != nil { return map[int]string{} } return resolvePaneIDsFrom(out, procPPID, claudePIDs) } // resolvePaneIDsFrom is the testable core of ResolvePaneIDs: it parses the // ` ` lines produced by tmux and, for each claudePID, climbs // the process tree via ppidOf until it lands on a pane_pid, returning that // pane's pane_id. ppidOf is injected so the ascent can be tested without real // processes. Lines that do not parse are skipped; a PID with no pane ancestor is // omitted. func resolvePaneIDsFrom(tmuxOut string, ppidOf func(int) int, claudePIDs []int) map[int]string { panePaneID := map[int]string{} for _, line := range strings.Split(strings.TrimSpace(tmuxOut), "\n") { f := strings.Fields(strings.TrimSpace(line)) if len(f) < 2 { continue } pp, e := strconv.Atoi(f[0]) if e != nil { continue } panePaneID[pp] = f[1] } res := make(map[int]string, len(claudePIDs)) for _, pid := range claudePIDs { if paneID, ok := paneAncestor(pid, panePaneID, ppidOf); ok { res[pid] = paneID } } return res } // paneAncestor climbs the process tree from pid until it finds a PID that is a // pane_pid (a key of panePaneID), returning its pane_id. The ascent is bounded // (64 hops, stop at pid<=1) so a malformed /proc or a cycle cannot hang it. // Returns ("", false) when no ancestor is a pane. ppidOf is injected for tests. func paneAncestor(pid int, panePaneID map[int]string, ppidOf func(int) int) (string, bool) { for i := 0; pid > 1 && i < 64; i++ { if paneID, ok := panePaneID[pid]; ok { return paneID, true } pid = ppidOf(pid) } return "", false }