Skip to content

Commit 819316c

Browse files
authored
fix(cockpit): recognize CC 2.1.x seats in the tmux liveness path too (#374)
Follow-up to #371 (which fixed the process-scan signal). Philipp verified the parallel blind spot I flagged: on CC 2.1.212, `tmux list-panes -F '#{pane_current_command}'` reports "2.1.212" for every live claude pane (not "claude"), so `_live_tmux_panes`'s {claude,node} membership test silently dropped all 9 of his live seats via the tmux signal. tmux only gives the command NAME (mangled), not argv — so we resolve the pane's process. Fast-path claude/node; for panes whose command matches a version-string regex (the CC-mangle signature, e.g. "2.1.212"), tree-walk the pane_pid's process via psutil and reuse _is_claude_proc (the #371 cmdline[0]-basename match). Only version-named panes are tree-walked — shells / python / other tools cost nothing. +4 tests (regex / mangled-pane-detected / mangled-without-claude-excluded / _pane_hosts_claude via cmdline). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qe4uGwYbVtE5CFZdsBwata Epistemic-AI: empirica Epistemic-Model: claude-haiku-4-5 Epistemic-Persona: implementer Epistemic-Learning-Delta: 0.15 (0.75 → 0.9) Epistemic-Mastery-Delta: 0.10 (0.80 → 0.90) Epistemic-Uncertainty-Delta: -0.10 (0.2 → 0.1) Epistemic-Engagement: 0.9 Epistemic-Completion: 1.0 Epistemic-Session: eab127be-0179-4d77-8e25-1b016f7bde64 Co-authored-by: Empirica System <david@getempirica.com>
1 parent e84247a commit 819316c

2 files changed

Lines changed: 117 additions & 4 deletions

File tree

empirica/core/cockpit/liveness.py

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,34 @@ class LivenessResult:
6060
# 'claude' is the bin name; 'node' covers older installations / dev launches.
6161
_CLAUDE_COMMANDS = frozenset({"claude", "node"})
6262

63+
# Claude Code 2.1.x renames the foreground process to a bare version string, so
64+
# tmux's pane_current_command reports e.g. "2.1.212" instead of "claude". A pane
65+
# whose command matches this is a mangled-CC candidate — resolve its process tree
66+
# to confirm (Philipp verified: every live 2.1.212 pane reports this).
67+
_VERSION_NAME_RE = re.compile(r"^\d+\.\d+")
68+
69+
70+
def _pane_hosts_claude(pane_pid: int) -> bool:
71+
"""True if the tmux pane's process tree contains a Claude Code process.
72+
73+
Used when pane_current_command is a mangled version string (CC 2.1.x) rather
74+
than 'claude'. Resolves the pane's foreground process + descendants and reuses
75+
`_is_claude_proc` (which matches cmdline[0] basename, surviving the rename).
76+
"""
77+
try:
78+
import psutil
79+
80+
proc = psutil.Process(pane_pid)
81+
for p in [proc, *proc.children(recursive=True)]:
82+
try:
83+
if _is_claude_proc(p.name(), p.cmdline()):
84+
return True
85+
except (psutil.NoSuchProcess, psutil.AccessDenied):
86+
continue
87+
except Exception: # noqa: S110 — best-effort; psutil missing / proc gone must never break liveness
88+
pass
89+
return False
90+
6391

6492
def _live_tmux_panes() -> set[str] | None:
6593
"""Return set of pane numbers (e.g. {'1', '2', '3'}) where Claude Code is running.
@@ -76,7 +104,7 @@ def _live_tmux_panes() -> set[str] | None:
76104
return None
77105
try:
78106
result = subprocess.run(
79-
["tmux", "list-panes", "-a", "-F", "#{pane_id} #{pane_current_command}"],
107+
["tmux", "list-panes", "-a", "-F", "#{pane_id} #{pane_pid} #{pane_current_command}"],
80108
capture_output=True,
81109
text=True,
82110
timeout=2,
@@ -88,12 +116,21 @@ def _live_tmux_panes() -> set[str] | None:
88116
return set()
89117
panes = set()
90118
for line in result.stdout.splitlines():
91-
parts = line.strip().split(maxsplit=1)
92-
if len(parts) != 2:
119+
parts = line.strip().split(maxsplit=2)
120+
if len(parts) != 3:
93121
continue
94-
pane_id, cmd = parts
122+
pane_id, pane_pid, cmd = parts
95123
if cmd in _CLAUDE_COMMANDS:
96124
panes.add(pane_id.lstrip("%"))
125+
elif _VERSION_NAME_RE.match(cmd):
126+
# Mangled CC 2.1.x name (e.g. "2.1.212") — confirm via the process
127+
# tree. Only version-string names are tree-walked, so shells/python
128+
# panes cost nothing.
129+
try:
130+
if _pane_hosts_claude(int(pane_pid)):
131+
panes.add(pane_id.lstrip("%"))
132+
except ValueError:
133+
pass
97134
return panes
98135

99136

tests/test_cockpit_liveness.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -480,3 +480,79 @@ def create_time(self):
480480
]
481481
monkeypatch.setattr(psutil, "process_iter", lambda attrs=None: procs)
482482
assert lv.live_claude_pids_by_instance() == {"empirica-vr": (101, 5.0)}
483+
484+
485+
# ── CC 2.1.x tmux-pane mangle (Philipp): pane_current_command='2.1.212' ──────
486+
487+
488+
def test_version_name_regex_matches_mangled_cc():
489+
assert lv._VERSION_NAME_RE.match("2.1.212")
490+
assert lv._VERSION_NAME_RE.match("10.0")
491+
assert not lv._VERSION_NAME_RE.match("claude")
492+
assert not lv._VERSION_NAME_RE.match("zsh")
493+
assert not lv._VERSION_NAME_RE.match("python3.11") # name, not a bare version
494+
495+
496+
def test_live_tmux_panes_detects_mangled_cc_pane(monkeypatch):
497+
# %0 = mangled CC (version-string name) → tree-walk confirms claude
498+
# %1 = zsh → not claude, not a version name → skipped (no tree-walk)
499+
# %2 = claude → fast path
500+
tmux_out = "%0 111 2.1.212\n%1 222 zsh\n%2 333 claude\n"
501+
502+
class _R:
503+
returncode = 0
504+
stdout = tmux_out
505+
506+
monkeypatch.setattr(lv.shutil, "which", lambda _x: "/usr/bin/tmux")
507+
monkeypatch.setattr(lv.subprocess, "run", lambda *a, **k: _R())
508+
# only pane_pid 111 (the mangled CC pane) hosts claude in its tree
509+
monkeypatch.setattr(lv, "_pane_hosts_claude", lambda pid: pid == 111)
510+
511+
assert lv._live_tmux_panes() == {"0", "2"}
512+
513+
514+
def test_live_tmux_panes_mangled_pane_without_claude_is_excluded(monkeypatch):
515+
# A version-named pane whose tree has NO claude (e.g. some other tool) is not counted.
516+
tmux_out = "%0 111 2.1.212\n"
517+
518+
class _R:
519+
returncode = 0
520+
stdout = tmux_out
521+
522+
monkeypatch.setattr(lv.shutil, "which", lambda _x: "/usr/bin/tmux")
523+
monkeypatch.setattr(lv.subprocess, "run", lambda *a, **k: _R())
524+
monkeypatch.setattr(lv, "_pane_hosts_claude", lambda pid: False)
525+
526+
assert lv._live_tmux_panes() == set()
527+
528+
529+
def test_pane_hosts_claude_matches_mangled_name_via_cmdline(monkeypatch):
530+
import sys
531+
import types
532+
533+
class _FakeProc:
534+
def __init__(self, name, cmdline):
535+
self._n, self._c = name, cmdline
536+
537+
def name(self):
538+
return self._n
539+
540+
def cmdline(self):
541+
return self._c
542+
543+
def children(self, recursive=False):
544+
return []
545+
546+
# pane process reports the mangled name but argv[0] is claude
547+
root = _FakeProc("2.1.212", ["claude", "--resume"])
548+
fake_psutil = types.SimpleNamespace(
549+
Process=lambda _pid: root,
550+
NoSuchProcess=type("NoSuchProcess", (Exception,), {}),
551+
AccessDenied=type("AccessDenied", (Exception,), {}),
552+
)
553+
monkeypatch.setitem(sys.modules, "psutil", fake_psutil)
554+
assert lv._pane_hosts_claude(111) is True
555+
556+
root2 = _FakeProc("2.1.212", ["node", "server.js"])
557+
fake_psutil.Process = lambda _pid: root2
558+
assert lv._pane_hosts_claude(111) is False

0 commit comments

Comments
 (0)