From 3e171ac31e2bf3fd04ca9cbb1456095458c62fd4 Mon Sep 17 00:00:00 2001 From: douglas Date: Sat, 28 Mar 2026 23:28:36 -0400 Subject: [PATCH 1/3] fix: handle grouped tmux sessions in session_map routing Grouped tmux sessions (created via `tmux new-session -t`) share windows, so the SessionStart hook can fire under any session name in the group. The monitor and session manager previously only read entries matching the configured prefix, missing entries written under other session names. - Hook: clean up duplicate session_map entries for the same window_id across different session name prefixes - Monitor/SessionManager: accept entries from any session name prefix (window IDs are globally unique in tmux) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ccbot/hook.py | 17 +++++++++++++++++ src/ccbot/session.py | 30 ++++++++++++++++-------------- src/ccbot/session_monitor.py | 25 ++++++++++++++++--------- 3 files changed, 49 insertions(+), 23 deletions(-) diff --git a/src/ccbot/hook.py b/src/ccbot/hook.py index eaf9f411..07ac2846 100644 --- a/src/ccbot/hook.py +++ b/src/ccbot/hook.py @@ -261,6 +261,23 @@ def hook_main() -> None: del session_map[old_key] logger.info("Removed old-format session_map key: %s", old_key) + # Clean up entries for the same window_id under different + # session name prefixes. Grouped tmux sessions (created via + # `tmux new-session -t`) share windows, so the hook can fire + # under any session name, leaving stale entries under other + # prefixes that the monitor may read. + for key in list(session_map.keys()): + if key != session_window_key: + parts = key.split(":", 1) + if len(parts) == 2 and parts[1] == window_id: + del session_map[key] + logger.info( + "Removed duplicate session_map entry: %s " + "(same window_id %s)", + key, + window_id, + ) + from .utils import atomic_write_json atomic_write_json(map_file, session_map) diff --git a/src/ccbot/session.py b/src/ccbot/session.py index 173293b1..2f7b7950 100644 --- a/src/ccbot/session.py +++ b/src/ccbot/session.py @@ -367,7 +367,8 @@ async def _cleanup_stale_session_map_entries(self, live_ids: set[str]) -> None: When windows are closed externally (outside ccbot), session_map.json retains orphan references. This cleanup removes entries whose window_id - is not in the current set of live tmux windows. + is not in the current set of live tmux windows. Checks all session + name prefixes (grouped tmux sessions share windows). """ if not config.session_map_file.exists(): return @@ -378,14 +379,14 @@ async def _cleanup_stale_session_map_entries(self, live_ids: set[str]) -> None: except (json.JSONDecodeError, OSError): return - prefix = f"{config.tmux_session_name}:" - stale_keys = [ - key - for key in session_map - if key.startswith(prefix) - and self._is_window_id(key[len(prefix) :]) - and key[len(prefix) :] not in live_ids - ] + stale_keys = [] + for key in session_map: + parts = key.split(":", 1) + if len(parts) != 2: + continue + window_id = parts[1] + if self._is_window_id(window_id) and window_id not in live_ids: + stale_keys.append(key) if not stale_keys: return @@ -500,7 +501,8 @@ async def load_session_map(self) -> None: """Read session_map.json and update window_states with new session associations. Keys in session_map are formatted as "tmux_session:window_id" (e.g. "ccbot:@12"). - Only entries matching our tmux_session_name are processed. + Entries from ANY session name prefix are accepted because grouped tmux + sessions share windows, and the hook may write under any session name. Also cleans up window_states entries not in current session_map. Updates window_display_names from the "window_name" field in values. """ @@ -513,15 +515,15 @@ async def load_session_map(self) -> None: except (json.JSONDecodeError, OSError): return - prefix = f"{config.tmux_session_name}:" valid_wids: set[str] = set() changed = False for key, info in session_map.items(): - # Only process entries for our tmux session - if not key.startswith(prefix): + # Extract window_id from "session_name:window_id" + parts = key.split(":", 1) + if len(parts) != 2: continue - window_id = key[len(prefix) :] + window_id = parts[1] if not self._is_window_id(window_id): continue valid_wids.add(window_id) diff --git a/src/ccbot/session_monitor.py b/src/ccbot/session_monitor.py index 0a1b3186..2d8eb7f2 100644 --- a/src/ccbot/session_monitor.py +++ b/src/ccbot/session_monitor.py @@ -373,13 +373,14 @@ async def check_for_updates(self, active_session_ids: set[str]) -> list[NewMessa return new_messages async def _load_current_session_map(self) -> dict[str, str]: - """Load current session_map and return window_key -> session_id mapping. + """Load current session_map and return window_id -> session_id mapping. Keys in session_map are formatted as "tmux_session:window_id" - (e.g. "ccbot:@12"). Old-format keys ("ccbot:window_name") are also - accepted so that sessions running before a code upgrade continue - to be monitored until the hook re-fires with new format. - Only entries matching our tmux_session_name are processed. + (e.g. "ccbot:@12"). Entries from ANY session name prefix are + accepted because grouped tmux sessions (created via + `tmux new-session -t`) share windows, and the hook may write + entries under any of the group's session names. + Window IDs are globally unique in tmux, so there is no ambiguity. """ window_to_session: dict[str, str] = {} if config.session_map_file.exists(): @@ -387,14 +388,20 @@ async def _load_current_session_map(self) -> dict[str, str]: async with aiofiles.open(config.session_map_file, "r") as f: content = await f.read() session_map = json.loads(content) - prefix = f"{config.tmux_session_name}:" for key, info in session_map.items(): - # Only process entries for our tmux session - if not key.startswith(prefix): + parts = key.split(":", 1) + if len(parts) != 2: + continue + window_key = parts[1] + if not ( + window_key.startswith("@") + and len(window_key) > 1 + and window_key[1:].isdigit() + ): continue - window_key = key[len(prefix) :] session_id = info.get("session_id", "") if session_id: + # Last entry per window_id wins (newest hook write) window_to_session[window_key] = session_id except (json.JSONDecodeError, OSError): pass From f53f9024a74a9bb487cfd17a12d474331994c56f Mon Sep 17 00:00:00 2001 From: douglas Date: Tue, 31 Mar 2026 10:22:30 -0400 Subject: [PATCH 2/3] fix: deduplicate session_map entries for same window_id on startup Grouped tmux sessions accumulate multiple entries for the same window_id under different session name prefixes over time. When windows are reassigned (closed and recreated), stale entries can point to the wrong session_id. On startup, for each window_id with multiple entries, keep only the one whose CWD matches the live tmux window's CWD. This ensures the monitor picks up the correct session. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ccbot/session.py | 79 ++++++++++++++++++++++++++++++++++++-------- 1 file changed, 65 insertions(+), 14 deletions(-) diff --git a/src/ccbot/session.py b/src/ccbot/session.py index 2f7b7950..6e311eee 100644 --- a/src/ccbot/session.py +++ b/src/ccbot/session.py @@ -363,12 +363,14 @@ async def _cleanup_old_format_session_map_keys(self) -> None: ) async def _cleanup_stale_session_map_entries(self, live_ids: set[str]) -> None: - """Remove entries for tmux windows that no longer exist. - - When windows are closed externally (outside ccbot), session_map.json - retains orphan references. This cleanup removes entries whose window_id - is not in the current set of live tmux windows. Checks all session - name prefixes (grouped tmux sessions share windows). + """Remove stale and duplicate entries from session_map.json. + + Handles two cases: + 1. Stale windows: entries whose window_id no longer exists in tmux. + 2. Duplicate window_ids: grouped tmux sessions can cause multiple entries + for the same window_id under different session name prefixes. For each + window_id with duplicates, keep the entry whose CWD matches the live + tmux window's CWD (falling back to the most recently modified JSONL). """ if not config.session_map_file.exists(): return @@ -379,25 +381,74 @@ async def _cleanup_stale_session_map_entries(self, live_ids: set[str]) -> None: except (json.JSONDecodeError, OSError): return - stale_keys = [] + # Build live window CWD lookup + windows = await tmux_manager.list_windows() + live_cwds: dict[str, str] = {} # window_id -> resolved cwd + for w in windows: + try: + live_cwds[w.window_id] = str(Path(w.cwd).resolve()) + except (OSError, ValueError): + live_cwds[w.window_id] = w.cwd + + # Collect entries per window_id + per_window: dict[str, list[str]] = {} # window_id -> [keys] + keys_to_remove: list[str] = [] + for key in session_map: parts = key.split(":", 1) if len(parts) != 2: continue window_id = parts[1] - if self._is_window_id(window_id) and window_id not in live_ids: - stale_keys.append(key) - if not stale_keys: + if not self._is_window_id(window_id): + continue + if window_id not in live_ids: + keys_to_remove.append(key) + else: + per_window.setdefault(window_id, []).append(key) + + # Deduplicate: for window_ids with multiple entries, keep the best one + for window_id, keys in per_window.items(): + if len(keys) <= 1: + continue + live_cwd = live_cwds.get(window_id, "") + best_key = keys[0] # fallback: first entry + + # Prefer entry whose CWD matches the live window's CWD + for key in keys: + entry_cwd = session_map[key].get("cwd", "") + try: + norm_cwd = str(Path(entry_cwd).resolve()) + except (OSError, ValueError): + norm_cwd = entry_cwd + if norm_cwd == live_cwd: + best_key = key + break + + for key in keys: + if key != best_key: + keys_to_remove.append(key) + logger.info( + "Removing duplicate session_map entry: %s " + "(keeping %s for window %s)", + key, + best_key, + window_id, + ) + + if not keys_to_remove: return - for key in stale_keys: + for key in keys_to_remove: del session_map[key] - logger.info("Removed stale session_map entry: %s", key) + if key not in per_window.get("", []): + # Only log stale entries not already logged above + if not any(key in keys for keys in per_window.values()): + logger.info("Removed stale session_map entry: %s", key) atomic_write_json(config.session_map_file, session_map) logger.info( - "Cleaned up %d stale session_map entries (windows no longer in tmux)", - len(stale_keys), + "Cleaned up %d session_map entries (stale + duplicates)", + len(keys_to_remove), ) # --- Display name management --- From 84260e7c4ba15536db1a0d1c1e38d5679ba1a646 Mon Sep 17 00:00:00 2001 From: douglas Date: Tue, 31 Mar 2026 10:27:47 -0400 Subject: [PATCH 3/3] fix: use JSONL mtime as tiebreaker when deduplicating session_map When multiple session_map entries for the same window_id all share the same CWD (common when sessions are created in the same directory), CWD matching alone can't determine which entry is current. Use the most recently modified JSONL file as a tiebreaker. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ccbot/session.py | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/src/ccbot/session.py b/src/ccbot/session.py index 6e311eee..d91f96b4 100644 --- a/src/ccbot/session.py +++ b/src/ccbot/session.py @@ -411,9 +411,9 @@ async def _cleanup_stale_session_map_entries(self, live_ids: set[str]) -> None: if len(keys) <= 1: continue live_cwd = live_cwds.get(window_id, "") - best_key = keys[0] # fallback: first entry - # Prefer entry whose CWD matches the live window's CWD + # Filter to entries whose CWD matches the live window + cwd_matches: list[str] = [] for key in keys: entry_cwd = session_map[key].get("cwd", "") try: @@ -421,8 +421,25 @@ async def _cleanup_stale_session_map_entries(self, live_ids: set[str]) -> None: except (OSError, ValueError): norm_cwd = entry_cwd if norm_cwd == live_cwd: - best_key = key - break + cwd_matches.append(key) + + candidates = cwd_matches if cwd_matches else keys + + # Among candidates, pick the one with the newest JSONL file + best_key = candidates[0] + best_mtime = 0.0 + for key in candidates: + sid = session_map[key].get("session_id", "") + cwd = session_map[key].get("cwd", "") + fpath = self._build_session_file_path(sid, cwd) + if fpath and fpath.exists(): + try: + mtime = fpath.stat().st_mtime + if mtime > best_mtime: + best_mtime = mtime + best_key = key + except OSError: + pass for key in keys: if key != best_key: