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..d91f96b4 100644 --- a/src/ccbot/session.py +++ b/src/ccbot/session.py @@ -363,11 +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. + """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 @@ -378,25 +381,91 @@ 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 - ] - if not 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 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, "") + + # 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: + norm_cwd = str(Path(entry_cwd).resolve()) + except (OSError, ValueError): + norm_cwd = entry_cwd + if norm_cwd == live_cwd: + 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: + 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 --- @@ -500,7 +569,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 +583,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