Skip to content
Draft
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
17 changes: 17 additions & 0 deletions src/ccbot/hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
116 changes: 93 additions & 23 deletions src/ccbot/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 ---
Expand Down Expand Up @@ -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.
"""
Expand All @@ -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)
Expand Down
25 changes: 16 additions & 9 deletions src/ccbot/session_monitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -373,28 +373,35 @@ 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():
try:
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
Expand Down
Loading