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
23 changes: 17 additions & 6 deletions src/ccgram/hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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(
Expand Down
29 changes: 29 additions & 0 deletions tests/ccgram/test_hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down