Skip to content
Merged
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
43 changes: 8 additions & 35 deletions src/ccbot/bot/_session_create.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,13 @@

from telegram.ext import ContextTypes

from ..handlers.directory_browser import take_pending_text
from ..handlers.message_sender import safe_edit, safe_send
from ..handlers.message_sender import safe_edit
from ..handlers.notifications import (
detach_paused_cards_at_message,
paint_card_on_carrier,
)
from ..i18n import t
from ..local_terminal import open_terminal_for_window
from ..naming import maybe_auto_name
from ..session import session_manager
from ..tmux_manager import tmux_manager

Expand Down Expand Up @@ -79,8 +77,6 @@ async def create_and_activate_session(
)
if not success:
await safe_edit(query, f"❌ {message}")
if context.user_data is not None:
context.user_data.pop("_pending_text", None)
return

logger.info(
Expand Down Expand Up @@ -128,6 +124,13 @@ async def create_and_activate_session(
session_manager.set_session_claude_id(sess.id, ws.session_id)
session_manager.set_active_session(user.id, sess.id)

# Every inbound captured since the user pressed Start is now owned by
# this window. The drain waits for proven TUI readiness and replays the
# original Telegram updates in order.
from ..startup_queue import bind_startup_queue

bind_startup_queue(user.id, created_wid)

# Transition the carrier from dir-browser to the new session's
# empty live card in place. No separate "Created. Send messages
# here." notice — that was a dead-end stub; the live card itself
Expand Down Expand Up @@ -178,33 +181,3 @@ async def _bind_lifecycle_in_background() -> None:
open_terminal_for_window(created_wid, user_id=user.id),
name=f"local-terminal:{created_wid}",
)

# Forward any pending text held while the picker was up. ``take_pending_text``
# drops a stale stash (older than PENDING_TEXT_TTL_S) so a message typed
# hours ago against a since-gone session can never be injected here — the
# 2026-06-28 "medical insurance" misroute.
pending_text = take_pending_text(context.user_data)
if pending_text:
logger.debug(
"Forwarding pending text to window %s (len=%d)",
created_wname,
len(pending_text),
)
send_ok, send_msg = await session_manager.send_to_window(
created_wid, pending_text
)
if not send_ok:
logger.warning("Failed to forward pending text: %s", send_msg)
await safe_send(
context.bot,
user.id,
f"❌ Failed to send pending message: {send_msg}",
)
elif len(pending_text) >= 20:
# Name the session from its actual first human message (this
# pending text), not from whatever inbound happens to land
# first. ``maybe_auto_name`` is one-shot (re-entrancy guard),
# so a later message can't override it.
asyncio.create_task(
maybe_auto_name(sess.id, pending_text, getattr(user, "id", None))
)
12 changes: 12 additions & 0 deletions src/ccbot/bot/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
filters,
)

from ..startup_queue import capture_startup_message

from ..config import config
from ..handlers.quota_alerts import quota_alerts_loop
from ..handlers.notifications import card_timer_loop
Expand Down Expand Up @@ -516,6 +518,16 @@ def create_bot() -> "Application[Any, Any, Any, Any, Any, Any]":
logger.info("TG proxy enabled: %s", config.tg_proxy_url)
application = builder.build()

# Group -1 runs before commands and content handlers. It is a no-op unless
# a new-session flow is open; while open it captures the update and stops
# it from leaking to the previously-active session.
application.add_handler(
MessageHandler(
filters.ALL & ~filters.StatusUpdate.ALL, capture_startup_message
),
group=-1,
)

# Visible menu commands.
application.add_handler(CommandHandler("history", history_command))
application.add_handler(CommandHandler("screenshot", screenshot_command))
Expand Down
16 changes: 14 additions & 2 deletions src/ccbot/bot/callbacks/dir_browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -214,9 +214,15 @@ async def handle(
return True

if data == CB_DIR_CANCEL:
from ...startup_queue import cancel_startup_queue

unsent = cancel_startup_queue(user.id)
clear_browse_state(context.user_data)
await _close_modal(query, user.id, context)
await query.answer()
await query.answer(
f"Cancelled; {unsent} queued item(s) were not sent" if unsent else None,
show_alert=bool(unsent),
)
return True

if data.startswith(CB_SESSION_SELECT):
Expand Down Expand Up @@ -272,13 +278,19 @@ async def handle(
return True

if data == CB_SESSION_CANCEL:
from ...startup_queue import cancel_startup_queue

unsent = cancel_startup_queue(user.id)
clear_session_picker_state(context.user_data)
if context.user_data is not None:
context.user_data.pop("_selected_path", None)
context.user_data.pop(SESSIONS_PAGE_KEY, None)
clear_browse_state(context.user_data)
await _close_modal(query, user.id, context)
await query.answer()
await query.answer(
f"Cancelled; {unsent} queued item(s) were not sent" if unsent else None,
show_alert=bool(unsent),
)
return True

if data == CB_SESSION_BACK:
Expand Down
3 changes: 3 additions & 0 deletions src/ccbot/bot/callbacks/more_menu.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ async def _emit_new_flow(
query: CallbackQuery, context: ContextTypes.DEFAULT_TYPE, user: Any
) -> None:
"""Open the directory browser from the Menu screen."""
from ...startup_queue import begin_startup_queue

begin_startup_queue(user.id)
from ...handlers.directory_browser import (
BROWSE_DIRS_KEY,
BROWSE_PAGE_KEY,
Expand Down
3 changes: 3 additions & 0 deletions src/ccbot/bot/callbacks/switcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,9 @@ async def _seed_bg_status(old_sess: _Session) -> None:
# Pause the active session first so its events buffer silently
# while the user picks a directory; events catch up when the
# user switches back via the switcher.
from ...startup_queue import begin_startup_queue

begin_startup_queue(user.id)
active = session_manager.get_active_session(user.id)
if active is not None:
pause_card_view(user.id, active.id)
Expand Down
31 changes: 14 additions & 17 deletions src/ccbot/bot/callbacks/window_picker.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,8 @@
UNBOUND_WINDOWS_KEY,
build_directory_browser,
clear_window_picker_state,
take_pending_text,
)
from ...handlers.message_sender import safe_edit, safe_send
from ...handlers.message_sender import safe_edit
from ...session import session_manager
from ...tmux_manager import tmux_manager
from .._common import open_more_in_place
Expand Down Expand Up @@ -78,22 +77,16 @@ async def handle(

await safe_edit(query, f"✅ Bound to window `{display}`")

pending_text = take_pending_text(context.user_data)
if pending_text:
send_ok, send_msg = await session_manager.send_to_window(
selected_wid, pending_text
)
if not send_ok:
logger.warning("Failed to forward pending text: %s", send_msg)
await safe_send(
context.bot,
user.id,
f"❌ Failed to send pending message: {send_msg}",
)
from ...startup_queue import bind_startup_queue

bind_startup_queue(user.id, selected_wid)
await query.answer("Bound")
return True

if data == CB_WIN_NEW:
from ...startup_queue import begin_startup_queue

begin_startup_queue(user.id)
clear_window_picker_state(context.user_data)
start_path = str(Path.home())
msg_text, keyboard, subdirs = await build_directory_browser(
Expand All @@ -109,11 +102,15 @@ async def handle(
return True

if data == CB_WIN_CANCEL:
from ...startup_queue import cancel_startup_queue

unsent = cancel_startup_queue(user.id)
clear_window_picker_state(context.user_data)
if context.user_data is not None:
context.user_data.pop("_pending_text", None)
await open_more_in_place(query, user.id)
await query.answer()
await query.answer(
f"Cancelled; {unsent} queued item(s) were not sent" if unsent else None,
show_alert=bool(unsent),
)
return True

return False
21 changes: 17 additions & 4 deletions src/ccbot/bot/commands/lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
from ...i18n import t
from ...session import Session, session_manager
from ...tmux_manager import tmux_manager
from ...startup_queue import begin_startup_queue, bind_startup_queue
from .._common import (
active_window,
is_user_allowed,
Expand All @@ -60,6 +61,10 @@ async def new_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> Non
if not update.message:
return

# Open capture before authentication, filesystem and Telegram awaits.
# Non-blocking voice handlers can otherwise race into the old session.
begin_startup_queue(user.id)

args = (update.message.text or "").split(maxsplit=2)
name_arg = args[1] if len(args) > 1 else ""
path_arg = args[2] if len(args) > 2 else ""
Expand All @@ -84,10 +89,13 @@ async def new_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> Non
if not success:
await safe_reply(update.message, f"❌ {message}")
return
hook_ok = await session_manager.wait_for_session_map_entry(
created_wid, timeout=5.0
session_manager.mark_window_starting(
created_wid,
backend=session_manager.agent_backend,
resume=False,
bot=context.bot,
user_id=user.id,
)
del hook_ok
sess = session_manager.create_session(
name=name_arg or created_wname or "",
window_id=created_wid,
Expand All @@ -97,10 +105,15 @@ async def new_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> Non
if ws.session_id:
session_manager.set_session_claude_id(sess.id, ws.session_id)
session_manager.set_active_session(user.id, sess.id)
bind_startup_queue(user.id, created_wid)
if session_manager.get_user_settings(user.id).get("local_terminal") == "auto":
from ...local_terminal import open_terminal_for_window
import asyncio

await open_terminal_for_window(created_wid, user_id=user.id)
asyncio.create_task(
open_terminal_for_window(created_wid, user_id=user.id),
name=f"local-terminal:{created_wid}",
)
await safe_reply(
update.message,
f"✅ Session `{sess.name}` ({sess.id}) created at {target_path}",
Expand Down
Loading
Loading