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
46 changes: 43 additions & 3 deletions src/ccgram/hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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 "
Expand All @@ -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
``<ccgram session>:<window_id>`` (``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.

Expand Down
58 changes: 58 additions & 0 deletions tests/ccgram/test_hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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.
Expand Down