diff --git a/src/ccgram/hook.py b/src/ccgram/hook.py index 8bf7ec13..2267b023 100644 --- a/src/ccgram/hook.py +++ b/src/ccgram/hook.py @@ -821,11 +821,17 @@ def _closest_claude_ancestor( def _is_nested_session(pane_tty: str) -> bool: """Return True if the hook was fired by a nested (non-foreground) claude. - The "primary" claude in a tmux pane is launched by the user's shell, so - its PID equals the foreground process group id on the pane's tty. Any - claude spawned beneath that primary (e.g. an MCP-server-launched observer - such as claude-mem) is a *descendant* — its PID differs from the - foreground PGID even though it shares the pgid via inheritance. + A claude whose PID equals the foreground process group id on the pane's + tty is the primary by definition. It is not the only primary shape: a + launcher that starts the agent as ``bash -lc "... && claude ..."`` leaves + the shell leading the group, so the primary claude is a child of the + foreground process and its PID never equals the foreground PGID. + + What actually distinguishes a nested claude (e.g. an MCP-server-launched + observer such as claude-mem) is that another claude sits above it in the + process tree. Testing ancestry rather than group leadership covers both + primary shapes, and is what the pgid comparison was approximating anyway, + since a nested claude inherits the same pgid. Fails open: returns False on any subprocess error or missing data so hook delivery is never made *more* fragile than the status quo. @@ -841,7 +847,12 @@ def _is_nested_session(pane_tty: str) -> bool: owner = _closest_claude_ancestor(snapshot, os.getpid()) if owner is None: return False - return owner != fg_pgid + if owner == fg_pgid: + return False + owner_info = snapshot.get(owner) + if owner_info is None: + return False + return _closest_claude_ancestor(snapshot, owner_info[0]) is not None def _write_event( diff --git a/tests/ccgram/test_hook.py b/tests/ccgram/test_hook.py index 43e05fa4..a52c2cc7 100644 --- a/tests/ccgram/test_hook.py +++ b/tests/ccgram/test_hook.py @@ -725,6 +725,35 @@ def test_observer_claude_is_nested(self, monkeypatch) -> None: monkeypatch.setattr("os.getpid", lambda: 99999) assert _is_nested_session("/dev/ttys005") is True + def test_shell_wrapped_primary_claude_is_not_nested(self, monkeypatch) -> None: + """A launcher running `bash -lc "... && claude ..."` keeps the shell as + group leader, so the primary claude's PID never equals the foreground + PGID. No claude sits above it, so it is still the primary.""" + snapshot = { + 14053: (1, 14053, "Ss+", "bash"), + 14058: (14053, 14053, "S+", "claude"), + 99999: (14058, 14053, "S+", "python"), + } + monkeypatch.setattr("ccgram.hook._ps_snapshot", lambda: snapshot) + monkeypatch.setattr("ccgram.hook._foreground_pgid_on_tty", lambda *_: 14053) + monkeypatch.setattr("os.getpid", lambda: 99999) + assert _is_nested_session("/dev/ttys002") is False + + def test_observer_under_shell_wrapped_primary_is_nested(self, monkeypatch) -> None: + """The nested case must still be caught when the primary is itself a + child of the foreground shell rather than the group leader.""" + snapshot = { + 14053: (1, 14053, "Ss+", "bash"), + 14058: (14053, 14053, "S+", "claude"), + 14100: (14058, 14053, "S+", "bun"), + 14200: (14100, 14053, "S+", "claude"), + 99999: (14200, 14053, "S+", "python"), + } + monkeypatch.setattr("ccgram.hook._ps_snapshot", lambda: snapshot) + monkeypatch.setattr("ccgram.hook._foreground_pgid_on_tty", lambda *_: 14053) + monkeypatch.setattr("os.getpid", lambda: 99999) + assert _is_nested_session("/dev/ttys002") is True + def test_empty_pane_tty_fails_open(self, monkeypatch) -> None: monkeypatch.setattr( "ccgram.hook._ps_snapshot", lambda: pytest.fail("should not be called")