Skip to content

Commit af880c8

Browse files
committed
fix: preserve manually created tmux windows
1 parent 4d4d859 commit af880c8

21 files changed

Lines changed: 375 additions & 31 deletions

src/ccgram/handlers/directory_callbacks.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from ..session import session_manager
2828
from ..session_map import session_map_sync
2929
from ..user_preferences import user_preferences
30+
from ..window_state_store import CCGRAM_CREATED_WINDOW_ORIGIN
3031
from ..thread_router import thread_router
3132
from ..tmux_manager import send_to_window, tmux_manager
3233
from .callback_data import (
@@ -557,6 +558,7 @@ async def _create_window_and_bind(
557558
return
558559

559560
user_preferences.update_user_mru(user_id, selected_path)
561+
session_manager.set_window_origin(created_wid, CCGRAM_CREATED_WINDOW_ORIGIN)
560562
session_manager.set_window_cwd(created_wid, selected_path)
561563
session_manager.set_window_provider(created_wid, provider_name)
562564
session_manager.set_window_approval_mode(created_wid, approval_mode)

src/ccgram/handlers/msg_spawn.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
spawns_dir,
3333
)
3434
from ..tmux_manager import tmux_manager
35+
from ..window_state_store import CCGRAM_CREATED_WINDOW_ORIGIN
3536
from .callback_registry import register
3637
from .message_sender import rate_limit_send_message
3738

@@ -86,6 +87,7 @@ async def handle_spawn_approval(
8687
spawn_file = spawns_dir() / f"{request_id}.json"
8788
spawn_file.unlink(missing_ok=True)
8889

90+
session_manager.set_window_origin(window_id, CCGRAM_CREATED_WINDOW_ORIGIN)
8991
session_manager.set_window_provider(window_id, req.provider, cwd=req.cwd)
9092

9193
try:

src/ccgram/handlers/recovery_callbacks.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
from ..thread_router import thread_router
2929
from ..tmux_manager import send_to_window, tmux_manager
3030
from ..utils import read_session_metadata_from_jsonl
31+
from ..window_state_store import CCGRAM_CREATED_WINDOW_ORIGIN
3132
from .callback_data import (
3233
CB_RECOVERY_BACK,
3334
CB_RECOVERY_CANCEL,
@@ -382,6 +383,7 @@ async def _create_and_bind_window(
382383
await session_map_sync.wait_for_session_map_entry(created_wid)
383384

384385
# Propagate provider to new window
386+
session_manager.set_window_origin(created_wid, CCGRAM_CREATED_WINDOW_ORIGIN)
385387
session_manager.set_window_provider(created_wid, provider.capabilities.name)
386388
session_manager.set_window_approval_mode(created_wid, approval_mode)
387389

src/ccgram/handlers/restore_command.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from ..session_map import session_map_sync
2222
from ..thread_router import thread_router
2323
from ..tmux_manager import tmux_manager
24+
from ..window_state_store import CCGRAM_CREATED_WINDOW_ORIGIN
2425
from .message_sender import safe_reply
2526
from .polling_strategies import lifecycle_strategy
2627
from .topic_emoji import format_topic_name_for_mode
@@ -83,6 +84,7 @@ async def restore_command(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
8384
if provider.capabilities.supports_hook:
8485
await session_map_sync.wait_for_session_map_entry(wid)
8586

87+
session_manager.set_window_origin(wid, CCGRAM_CREATED_WINDOW_ORIGIN)
8688
session_manager.set_window_provider(wid, provider.capabilities.name)
8789
session_manager.set_window_approval_mode(wid, approval_mode)
8890
thread_router.bind_thread(user_id, thread_id, wid, window_name=wname)

src/ccgram/handlers/resume_command.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
from ..session_map import session_map_sync
3434
from ..thread_router import thread_router
3535
from ..tmux_manager import tmux_manager
36+
from ..window_state_store import CCGRAM_CREATED_WINDOW_ORIGIN
3637
from ..utils import read_session_metadata_from_jsonl
3738
from .callback_data import CB_RESUME_CANCEL, CB_RESUME_PAGE, CB_RESUME_PICK
3839
from .callback_helpers import get_thread_id
@@ -322,6 +323,7 @@ async def _create_resume_window(
322323
if success:
323324
if provider.capabilities.supports_hook:
324325
await session_map_sync.wait_for_session_map_entry(created_wid)
326+
session_manager.set_window_origin(created_wid, CCGRAM_CREATED_WINDOW_ORIGIN)
325327
session_manager.set_window_provider(created_wid, provider.capabilities.name)
326328
session_manager.set_window_approval_mode(created_wid, approval_mode)
327329

src/ccgram/handlers/sync_command.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,9 @@ async def _close_ghost_topics(bot: Bot, issues: list[AuditIssue]) -> int:
211211
user_id = int(match.group(1))
212212
thread_id = int(match.group(2))
213213
window_id = match.group(3)
214+
current_window_id = thread_router.get_window_for_thread(user_id, thread_id)
215+
if current_window_id != window_id:
216+
continue
214217
chat_id = thread_router.resolve_chat_id(user_id, thread_id)
215218
topic_removed = False
216219
if chat_id == user_id:
@@ -345,6 +348,9 @@ async def _recreate_dead_topics(bot: Bot, issues: list[AuditIssue]) -> int:
345348
user_id = int(match.group(1))
346349
thread_id = int(match.group(2))
347350
window_id = match.group(3)
351+
current_window_id = thread_router.get_window_for_thread(user_id, thread_id)
352+
if current_window_id != window_id:
353+
continue
348354

349355
view = window_query.view_window(window_id)
350356
name = (view.window_name if view else "") or thread_router.get_display_name(
@@ -435,10 +441,10 @@ async def handle_sync_fix(query: CallbackQuery) -> None:
435441

436442
await _sync_live_topic_names(bot, live_ids)
437443

438-
# Enforcement: close ghost topics, recreate dead topics, adopt orphans
444+
# Enforcement: adopt orphans first so stale same-name topics can be rebound.
445+
await _adopt_orphaned_windows(bot, pre_audit.issues)
439446
closed_count = await _close_ghost_topics(bot, pre_audit.issues)
440447
recreated_count = await _recreate_dead_topics(bot, pre_audit.issues)
441-
await _adopt_orphaned_windows(bot, pre_audit.issues)
442448

443449
# Re-audit and compute actual fixed count (handles partial failures).
444450
# No skip_threads here: successful recreations use a new thread_id (old

src/ccgram/handlers/topic_lifecycle.py

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from ..tmux_manager import tmux_manager
2424
from ..utils import log_throttled
2525
from ..window_resolver import is_foreign_window
26+
from ..window_state_store import CCGRAM_CREATED_WINDOW_ORIGIN
2627
from .cleanup import clear_topic_state
2728
from .message_sender import is_thread_gone
2829
from .polling_strategies import (
@@ -126,10 +127,15 @@ async def check_unbound_window_ttl(
126127

127128
now = time.monotonic()
128129
for w in live_windows:
129-
if w.window_id not in bound_ids and not is_foreign_window(w.window_id):
130-
ws = terminal_poll_state.get_state(w.window_id)
131-
if ws.unbound_timer is None:
132-
terminal_poll_state.set_unbound_timer(w.window_id, now)
130+
if w.window_id in bound_ids or is_foreign_window(w.window_id):
131+
continue
132+
view = session_manager.view_window(w.window_id)
133+
if view is None or view.origin != CCGRAM_CREATED_WINDOW_ORIGIN:
134+
terminal_poll_state.clear_unbound_timer(w.window_id)
135+
continue
136+
ws = terminal_poll_state.get_state(w.window_id)
137+
if ws.unbound_timer is None:
138+
terminal_poll_state.set_unbound_timer(w.window_id, now)
133139

134140
await _kill_expired_unbound(now, timeout)
135141
_prune_orphaned_poll_state(live_ids, bound_ids)
@@ -188,14 +194,18 @@ async def probe_topic_existence(bot: Bot) -> None:
188194
or "thread not found" in e.message.lower()
189195
):
190196
w = await tmux_manager.find_window_by_id(wid)
191-
if w:
197+
view = session_manager.view_window(wid)
198+
killed = False
199+
if w and view and view.origin == CCGRAM_CREATED_WINDOW_ORIGIN:
192200
await tmux_manager.kill_window(w.window_id)
201+
killed = True
193202
terminal_poll_state.reset_probe_failures(wid)
194203
await clear_topic_state(user_id, thread_id, bot, window_id=wid)
195204
thread_router.unbind_thread(user_id, thread_id)
205+
action = "killed" if killed else "unbound"
196206
logger.info(
197-
"Topic deleted: killed window_id '%s' and "
198-
"unbound thread %d for user %d",
207+
"Topic deleted: %s window_id '%s' and unbound thread %d for user %d",
208+
action,
199209
wid,
200210
thread_id,
201211
user_id,

src/ccgram/handlers/topic_orchestration.py

Lines changed: 86 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
from __future__ import annotations
1313

14+
import contextlib
1415
import time
1516
from pathlib import Path
1617

@@ -28,6 +29,8 @@
2829
from ..session_monitor import NewWindowEvent
2930
from ..thread_router import thread_router
3031
from ..tmux_manager import tmux_manager
32+
from .message_sender import is_thread_gone
33+
from .topic_emoji import strip_emoji_prefix
3134

3235
logger = structlog.get_logger()
3336

@@ -192,11 +195,88 @@ async def create_topic_in_chat(
192195
)
193196

194197

198+
async def _topic_exists(bot: Bot, chat_id: int, thread_id: int) -> bool | None:
199+
"""Probe a Telegram topic. True=exists, False=gone, None=unknown."""
200+
try:
201+
msg = await bot.send_message(
202+
chat_id,
203+
"\u200b",
204+
message_thread_id=thread_id,
205+
disable_notification=True,
206+
)
207+
except TelegramError as exc:
208+
if is_thread_gone(exc):
209+
return False
210+
return None
211+
with contextlib.suppress(TelegramError):
212+
await bot.delete_message(chat_id, msg.message_id)
213+
return True
214+
215+
216+
async def _rebind_existing_topic_by_name(
217+
event: NewWindowEvent, bot: Bot, topic_name: str
218+
) -> bool:
219+
"""Bind a stale same-name topic to a newly discovered manual window."""
220+
clean_topic_name = strip_emoji_prefix(topic_name)
221+
matches: list[tuple[int, int, str, int]] = []
222+
bindings = list(thread_router.iter_thread_bindings())
223+
for user_id, thread_id, old_window_id in bindings:
224+
if old_window_id == event.window_id:
225+
continue
226+
display_name = strip_emoji_prefix(thread_router.get_display_name(old_window_id))
227+
if display_name != clean_topic_name:
228+
continue
229+
if await tmux_manager.find_window_by_id(old_window_id):
230+
continue
231+
chat_id = thread_router.resolve_chat_id(user_id, thread_id)
232+
if chat_id == user_id:
233+
continue
234+
matches.append((user_id, thread_id, old_window_id, chat_id))
235+
236+
if len(matches) != 1:
237+
if len(matches) > 1:
238+
logger.warning(
239+
"Multiple stale same-name topics for window %s (%s); not rebinding",
240+
event.window_id,
241+
clean_topic_name,
242+
)
243+
return False
244+
245+
user_id, thread_id, old_window_id, chat_id = matches[0]
246+
exists = await _topic_exists(bot, chat_id, thread_id)
247+
if exists is False:
248+
thread_router.unbind_thread(user_id, thread_id)
249+
logger.info(
250+
"Dropped dead same-name topic thread %d for stale window %s",
251+
thread_id,
252+
old_window_id,
253+
)
254+
return False
255+
if exists is None:
256+
logger.info(
257+
"Could not probe same-name topic thread %d for stale window %s; not rebinding",
258+
thread_id,
259+
old_window_id,
260+
)
261+
return False
262+
263+
thread_router.bind_thread(user_id, thread_id, event.window_id, window_name=topic_name)
264+
thread_router.set_group_chat_id(user_id, thread_id, chat_id)
265+
logger.info(
266+
"Rebound existing topic thread %d from stale window %s to new window %s (%s)",
267+
thread_id,
268+
old_window_id,
269+
event.window_id,
270+
clean_topic_name,
271+
)
272+
return True
273+
274+
195275
async def handle_new_window(event: NewWindowEvent, bot: Bot) -> None:
196-
"""Create a Telegram forum topic for a newly detected tmux window.
276+
"""Create or bind a Telegram forum topic for a newly detected tmux window.
197277
198-
Skips if the window is already bound to a topic. Creates one topic per
199-
unique group chat, binds all users in that chat.
278+
Skips if the window is already bound. Reuses one stale same-name topic when
279+
it still exists; otherwise creates one topic per unique group chat.
200280
"""
201281
if _is_window_already_bound(event.window_id):
202282
logger.debug(
@@ -207,6 +287,9 @@ async def handle_new_window(event: NewWindowEvent, bot: Bot) -> None:
207287
await _auto_detect_provider(event.window_id)
208288

209289
topic_name = event.window_name or Path(event.cwd).name or event.window_id
290+
if await _rebind_existing_topic_by_name(event, bot, topic_name):
291+
return
292+
210293
seen_chats = collect_target_chats(event.window_id)
211294
if not seen_chats:
212295
return

src/ccgram/session.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -561,6 +561,7 @@ def view_window(self, window_id: str) -> WindowView | None:
561561
window_name=ws.window_name,
562562
session_id=ws.session_id,
563563
external=ws.external,
564+
origin=ws.origin,
564565
)
565566

566567
@property
@@ -611,6 +612,10 @@ def set_window_cwd(self, window_id: str, cwd: str) -> None:
611612
state.cwd = cwd
612613
self._save_state()
613614

615+
def set_window_origin(self, window_id: str, origin: str) -> None:
616+
"""Set the lifecycle origin for a window and persist state."""
617+
window_store.set_window_origin(window_id, origin)
618+
614619
def get_approval_mode(self, window_id: str) -> str:
615620
"""Get approval mode for a window (default: 'normal')."""
616621
state = self.window_states.get(window_id)

src/ccgram/session_lifecycle.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ class ReconcileResult:
3838

3939
sessions_to_remove: set[str] = field(default_factory=set)
4040
new_windows: dict[str, dict[str, Any]] = field(default_factory=dict)
41+
changed_windows: dict[str, dict[str, Any]] = field(default_factory=dict)
4142
current_map: dict[str, dict[str, Any]] = field(default_factory=dict)
4243

4344

@@ -85,6 +86,7 @@ def reconcile(
8586
new_details["session_id"],
8687
)
8788
result.sessions_to_remove.add(old_details["session_id"])
89+
result.changed_windows[window_id] = new_details
8890
idle_tracker.clear_session(old_details["session_id"])
8991
claude_task_state.clear_window(window_id)
9092

0 commit comments

Comments
 (0)