diff --git a/src/ccgram/hook.py b/src/ccgram/hook.py index 8bf7ec13..dfa964bb 100644 --- a/src/ccgram/hook.py +++ b/src/ccgram/hook.py @@ -56,6 +56,7 @@ def _claude_settings_file() -> Path: # optional so older test mocks keep working with a 3-part stdout. _TMUX_FORMAT_PARTS = 3 _TMUX_FORMAT_PARTS_WITH_TTY = 4 +_TMUX_FORMAT_PARTS_WITH_LINKS = 5 # ps -A output is split into 5 fields: pid, ppid, pgid, stat, command. _PS_SNAPSHOT_FIELDS = 5 @@ -710,7 +711,8 @@ def _resolve_window_id(pane_id: str) -> tuple[str, str, str, str] | None: "-t", pane_id, "-p", - "#{session_name}\t#{window_id}\t#{window_name}\t#{pane_tty}", + "#{session_name}\t#{window_id}\t#{window_name}\t#{pane_tty}" + "\t#{window_linked_sessions}", ], capture_output=True, text=True, @@ -720,7 +722,7 @@ def _resolve_window_id(pane_id: str) -> tuple[str, str, str, str] | None: logger.warning("tmux display-message timed out for pane %s", pane_id) return None raw_output = result.stdout.strip() - parts = raw_output.split("\t", 3) + parts = raw_output.split("\t", 4) if len(parts) < _TMUX_FORMAT_PARTS: logger.warning( "Failed to parse session:window_id:window_name from tmux " @@ -732,10 +734,48 @@ def _resolve_window_id(pane_id: str) -> tuple[str, str, str, str] | None: tmux_session_name, window_id, window_name = parts[0], parts[1], parts[2] pane_tty = parts[3] if len(parts) >= _TMUX_FORMAT_PARTS_WITH_TTY else "" - session_window_key = f"{tmux_session_name}:{window_id}" + linked = parts[4] if len(parts) >= _TMUX_FORMAT_PARTS_WITH_LINKS else "" + key_session = tmux_session_name + if linked not in ("", "0", "1"): + key_session = _session_map_session_for(window_id, tmux_session_name) + session_window_key = f"{key_session}:{window_id}" return session_window_key, window_id, window_name, pane_tty +def _session_map_session_for(window_id: str, pane_session: str) -> str: + """Return the tmux session ``session_map`` should be keyed under. + + Window ids are server-global and a linked window belongs to more than one + session, so the session tmux reports for the firing pane is not necessarily + the one ccgram lists windows from. Readers resolve entries by + ``:`` (``session_map_prefix_for``), so a hook + keyed under the session that happens to own the pane is invisible to every + reader and the binding silently never takes effect. + + Falls back to the pane's own session whenever the window is not linked into + ccgram's session, which is the single-session case and today's behaviour. + """ + # Lazy: config reads the environment at import time; the hook path should + # not pay that cost, nor fail, when the window cannot be resolved at all. + from .config import config + + target = getattr(config, "tmux_session_name", "") + if not target or target == pane_session: + return pane_session + try: + result = subprocess.run( + ["tmux", "list-windows", "-t", target, "-F", "#{window_id}"], + capture_output=True, + text=True, + timeout=5, + ) + except subprocess.TimeoutExpired, OSError: + return pane_session + if result.returncode != 0: + return pane_session + return target if window_id in result.stdout.split() else pane_session + + def _ps_snapshot() -> dict[int, tuple[int, int, str, str]]: """Return ``{pid: (ppid, pgid, stat, command_basename)}`` for all processes. diff --git a/tests/ccgram/test_hook.py b/tests/ccgram/test_hook.py index 43e05fa4..ca51158a 100644 --- a/tests/ccgram/test_hook.py +++ b/tests/ccgram/test_hook.py @@ -15,10 +15,12 @@ _install_hook, _is_nested_session, _provider_from_pane_tty, + _session_map_session_for, _uninstall_hook, get_installed_events, hook_main, ) +from ccgram.config import config from ccgram.providers.base import UUID_RE @@ -684,6 +686,62 @@ def test_respects_env_var(self, monkeypatch, tmp_path) -> None: assert _claude_settings_file() == tmp_path / "custom" / "settings.json" +class TestSessionMapKeyForLinkedWindow: + """A window linked into ccgram's session belongs to more than one tmux + session. The hook must key session_map under the session readers resolve + against (config.tmux_session_name), not whichever session tmux happens to + report for the firing pane, or the binding is written and never found. + """ + + @staticmethod + def _run(stdout: str, returncode: int = 0): + def _fake(*_args, **_kwargs): + return subprocess.CompletedProcess( + args=[], returncode=returncode, stdout=stdout, stderr="" + ) + + return _fake + + def test_linked_window_keys_under_ccgram_session(self, monkeypatch) -> None: + monkeypatch.setattr(config, "tmux_session_name", "ccgram") + monkeypatch.setattr(subprocess, "run", self._run("@12\n@34\n")) + assert _session_map_session_for("@34", "agentdeck_foo_1234") == "ccgram" + + def test_unlinked_window_keeps_pane_session(self, monkeypatch) -> None: + monkeypatch.setattr(config, "tmux_session_name", "ccgram") + monkeypatch.setattr(subprocess, "run", self._run("@12\n")) + assert ( + _session_map_session_for("@99", "agentdeck_foo_1234") + == "agentdeck_foo_1234" + ) + + def test_pane_already_in_ccgram_session_is_unchanged(self, monkeypatch) -> None: + monkeypatch.setattr(config, "tmux_session_name", "ccgram") + monkeypatch.setattr( + subprocess, "run", lambda *a, **k: pytest.fail("no tmux probe needed") + ) + assert _session_map_session_for("@12", "ccgram") == "ccgram" + + def test_tmux_failure_falls_back_to_pane_session(self, monkeypatch) -> None: + monkeypatch.setattr(config, "tmux_session_name", "ccgram") + monkeypatch.setattr(subprocess, "run", self._run("", returncode=1)) + assert ( + _session_map_session_for("@34", "agentdeck_foo_1234") + == "agentdeck_foo_1234" + ) + + def test_tmux_timeout_falls_back_to_pane_session(self, monkeypatch) -> None: + def _boom(*_args, **_kwargs): + raise subprocess.TimeoutExpired(cmd="tmux", timeout=5) + + monkeypatch.setattr(config, "tmux_session_name", "ccgram") + monkeypatch.setattr(subprocess, "run", _boom) + assert ( + _session_map_session_for("@34", "agentdeck_foo_1234") + == "agentdeck_foo_1234" + ) + + class TestNestedSessionDetection: """Hook fired by a nested claude (e.g. claude-mem observer) must not overwrite session_map.json or write events for the bound topic.