From a56ec91384d3a30058b6c830ec3717697a919847 Mon Sep 17 00:00:00 2001 From: Dmitry Verkhoturov Date: Tue, 28 Jul 2026 02:42:31 +0100 Subject: [PATCH] fix(hook): treat a shell-wrapped claude as primary, not nested _is_nested_session decided a claude was primary only when its PID equalled the foreground process group id on the pane's tty. That holds when a shell execs claude directly, but not when a launcher starts the agent as `bash -lc "... && claude ..."`: the shell stays group leader, so the primary claude is its child and its PID never equals the foreground PGID. Every hook such a session fires is then dropped as a nested observer, silently, so session_map.json is never written and Telegram forwarding never starts. Decide nesting by asking whether another claude sits above this one in the process tree instead. That covers both primary shapes and is what the pgid comparison was approximating anyway, since a nested claude inherits the same pgid rather than getting its own (as the original comment noted). The fast path for a group-leading primary is kept, and every fails-open branch is untouched. Verified against a real agent-deck session, which launches claude through a login shell: hooks were dropped before the change and bind correctly after it. --- src/ccgram/hook.py | 23 +++++++++++++++++------ tests/ccgram/test_hook.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 6 deletions(-) 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")