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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,10 @@ directory browser, you pick the project, and a tmux window with
the selected agent starts there. Subsequent text in the DM is routed to the
**active** session.

Directory rows are ordered by the newest meaningful file change anywhere in
their nested contents. Generated dependency/cache trees are ignored, and the
scan runs off the Telegram event loop with a short cache.

Sessions are named after the directory basename and renamed once after
the first message of ≥ 20 chars by a small separate request: Haiku for
Claude or `CODEX_NAMING_MODEL` for Codex (default `gpt-5.6-luna`). The
Expand Down
3 changes: 3 additions & 0 deletions README_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,9 @@ transcript 表面触手可及。多数用户一旦发现菜单,就再也不打 s
你选择项目,tmux 窗口中启动当前选定的 agent。后续 DM 中的文本路由到**活动**
会话。

目录按嵌套内容中最新的有效文件修改排序。依赖和缓存目录会被忽略,
扫描在 Telegram 事件循环之外运行,并进行短时缓存。

会话最初以目录名命名,在第一条 ≥ 20 字符的消息到达时由一次性
小型独立调用重命名一次:Claude 使用 Haiku,Codex 使用
`CODEX_NAMING_MODEL`。关闭自动命名即可保留目录名并跳过额外调用。
Expand Down
4 changes: 4 additions & 0 deletions README_RU.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,10 @@ cross-agent pickup: ccbot парсит исходный JSONL в огранич
браузер директорий, ты выберешь проект, в tmux-окне стартанёт
выбранный агент. Дальнейший текст в DM роутится в **активную** сессию.

Папки сортируются по самому свежему содержательному изменению файлов во
вложенном дереве. Деревья зависимостей и кэшей игнорируются, обход выполняется
вне Telegram event loop и кратко кэшируется.

Имя сессии сначала берётся из имени каталога, а на первом сообщении
длиной ≥ 20 символов один раз переписывается коротким отдельным
запросом: Haiku для Claude или `CODEX_NAMING_MODEL` для Codex (по
Expand Down
10 changes: 5 additions & 5 deletions src/ccbot/bot/callbacks/dir_browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ async def handle(
context.user_data[BROWSE_PATH_KEY] = new_path_str
context.user_data[BROWSE_PAGE_KEY] = 0

msg_text, keyboard, subdirs = build_directory_browser(
msg_text, keyboard, subdirs = await build_directory_browser(
new_path_str, user_id=user.id
)
if context.user_data is not None:
Expand All @@ -138,7 +138,7 @@ async def handle(
context.user_data[BROWSE_PATH_KEY] = parent_path
context.user_data[BROWSE_PAGE_KEY] = 0

msg_text, keyboard, subdirs = build_directory_browser(
msg_text, keyboard, subdirs = await build_directory_browser(
parent_path, user_id=user.id
)
if context.user_data is not None:
Expand All @@ -162,7 +162,7 @@ async def handle(
if context.user_data is not None:
context.user_data[BROWSE_PAGE_KEY] = pg

msg_text, keyboard, subdirs = build_directory_browser(
msg_text, keyboard, subdirs = await build_directory_browser(
current_path, pg, user_id=user.id
)
if context.user_data is not None:
Expand All @@ -187,7 +187,7 @@ async def handle(
"Directory selection expired — pick again", show_alert=True
)
start_path = str(Path.home())
msg_text, keyboard, subdirs = build_directory_browser(
msg_text, keyboard, subdirs = await build_directory_browser(
start_path, user_id=user.id
)
if context.user_data is not None:
Expand Down Expand Up @@ -292,7 +292,7 @@ async def handle(
context.user_data.pop("_selected_path", None)
context.user_data.pop(SESSIONS_PAGE_KEY, None)

msg_text, keyboard, subdirs = build_directory_browser(
msg_text, keyboard, subdirs = await build_directory_browser(
selected_path, user_id=user.id
)
if context.user_data is not None:
Expand Down
4 changes: 3 additions & 1 deletion src/ccbot/bot/callbacks/more_menu.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,9 @@ async def _emit_new_flow(
clear_window_picker_state(context.user_data)
clear_session_picker_state(context.user_data)
start_path = str(Path.home())
msg_text, keyboard, subdirs = build_directory_browser(start_path, user_id=user.id)
msg_text, keyboard, subdirs = await build_directory_browser(
start_path, user_id=user.id
)
if context.user_data is not None:
context.user_data[STATE_KEY] = STATE_BROWSING_DIRECTORY
context.user_data[BROWSE_PATH_KEY] = start_path
Expand Down
2 changes: 1 addition & 1 deletion src/ccbot/bot/callbacks/switcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ async def _seed_bg_status(old_sess: _Session) -> None:
clear_window_picker_state(context.user_data)
clear_session_picker_state(context.user_data)
start_path = str(Path.home())
msg_text, keyboard, subdirs = build_directory_browser(
msg_text, keyboard, subdirs = await build_directory_browser(
start_path, user_id=user.id
)
if context.user_data is not None:
Expand Down
2 changes: 1 addition & 1 deletion src/ccbot/bot/callbacks/window_picker.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ async def handle(
if data == CB_WIN_NEW:
clear_window_picker_state(context.user_data)
start_path = str(Path.home())
msg_text, keyboard, subdirs = build_directory_browser(
msg_text, keyboard, subdirs = await build_directory_browser(
start_path, user_id=user.id
)
if context.user_data is not None:
Expand Down
4 changes: 3 additions & 1 deletion src/ccbot/bot/commands/lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,9 @@ async def new_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> Non
context.user_data["_pending_session_name"] = name_arg
clear_browse_state(context.user_data)
start_path = str(Path.home())
msg_text, keyboard, subdirs = build_directory_browser(start_path, user_id=user.id)
msg_text, keyboard, subdirs = await build_directory_browser(
start_path, user_id=user.id
)
if context.user_data is not None:
context.user_data[STATE_KEY] = STATE_BROWSING_DIRECTORY
context.user_data[BROWSE_PATH_KEY] = start_path
Expand Down
2 changes: 1 addition & 1 deletion src/ccbot/bot/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -1174,7 +1174,7 @@ async def _resolve_active_window(
# The pending text is held in user_data and forwarded after creation.
logger.info("No active session: showing directory browser (user=%d)", user_id)
start_path = str(Path.home())
msg_text, keyboard, subdirs = build_directory_browser(
msg_text, keyboard, subdirs = await build_directory_browser(
start_path, user_id=user_id
)
if context.user_data is not None:
Expand Down
148 changes: 121 additions & 27 deletions src/ccbot/handlers/directory_browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
- clear_browse_state: Clear browsing state from user_data
"""

import asyncio
import logging
import os
import time
Expand Down Expand Up @@ -39,32 +40,133 @@
)


def _dir_recency(d: Path) -> float:
"""Best-effort "last touched" timestamp for a directory.

A directory's own mtime only changes when an entry is added,
removed, or renamed directly inside it — editing an existing file's
content, or ``git commit`` (which only touches objects/refs under
``.git/``), leaves the project root's own mtime untouched. That
silently buried actively-worked-on git repos under stale scratch
dirs in the picker. Take the max of the directory's own mtime and
its ``.git/HEAD`` / ``.git/index`` mtimes (updated by nearly every
git operation: commit, checkout, add, merge, rebase, reset) without
doing a full recursive tree walk.
_RECENCY_CACHE_TTL_S = 30.0
_RECENCY_MAX_DEPTH = 8
_RECENCY_MAX_ENTRIES = 150_000
_RECENCY_CACHE: dict[str, tuple[float, float]] = {}

# Generated dependencies, caches and platform stores should not make a project
# look recently edited. Pruning them also keeps a home-directory scan bounded.
_RECENCY_PRUNE_DIRS = frozenset(
{
".cache",
".git",
".hg",
".mypy_cache",
".next",
".nox",
".pytest_cache",
".ruff_cache",
".svn",
".tox",
".venv",
"Applications",
"DerivedData",
"Library",
"Pods",
"__pycache__",
"build",
"dist",
"node_modules",
"target",
"venv",
}
)

_GIT_ACTIVITY_MARKERS = (
"HEAD",
"index",
"packed-refs",
"logs/HEAD",
"FETCH_HEAD",
)


def _git_recency(git_dir: Path) -> float:
"""Newest cheap Git activity marker without walking object storage."""
best = 0.0
for marker in _GIT_ACTIVITY_MARKERS:
try:
best = max(best, (git_dir / marker).stat().st_mtime)
except OSError:
continue
return best


def _scan_dir_recency(d: Path) -> float:
"""Return the newest meaningful mtime in ``d``'s nested contents.

Symlinks are never followed. Expensive generated trees are pruned, while
Git activity is represented by a handful of metadata markers. Depth and
entry caps protect the picker from pathological filesystem trees.
"""
try:
best = d.stat().st_mtime
except OSError:
return 0.0
git_dir = d / ".git"
for marker in ("HEAD", "index"):
if d.name in _RECENCY_PRUNE_DIRS:
return best

stack: list[tuple[Path, int]] = [(d, 0)]
scanned = 0
while stack and scanned < _RECENCY_MAX_ENTRIES:
current, depth = stack.pop()
try:
best = max(best, (git_dir / marker).stat().st_mtime)
entries = os.scandir(current)
except OSError:
continue
with entries:
for entry in entries:
scanned += 1
if scanned > _RECENCY_MAX_ENTRIES:
break
try:
is_dir = entry.is_dir(follow_symlinks=False)
except OSError:
continue
if is_dir and entry.name == ".git":
best = max(best, _git_recency(Path(entry.path)))
continue
if is_dir and entry.name in _RECENCY_PRUNE_DIRS:
continue
try:
stat = entry.stat(follow_symlinks=False)
except OSError:
continue
best = max(best, stat.st_mtime)
if not is_dir:
continue
if depth >= _RECENCY_MAX_DEPTH:
continue
stack.append((Path(entry.path), depth + 1))
return best


def _dir_recency(d: Path) -> float:
"""Cached recursive content recency for one directory-picker row."""
key = str(d)
now = time.monotonic()
cached = _RECENCY_CACHE.get(key)
if cached is not None and now - cached[0] < _RECENCY_CACHE_TTL_S:
return cached[1]
recency = _scan_dir_recency(d)
_RECENCY_CACHE[key] = (now, recency)
return recency


def _sorted_subdirs(path: Path) -> list[str]:
"""List visible child directories, newest nested content first."""
candidates: list[tuple[float, str]] = []
for d in path.iterdir():
if not d.is_dir():
continue
if not config.show_hidden_dirs and d.name.startswith("."):
continue
candidates.append((_dir_recency(d), d.name))
candidates.sort(key=lambda item: (-item[0], item[1].lower()))
return [name for _, name in candidates]


# Directories per page in directory browser
DIRS_PER_PAGE = 6

Expand Down Expand Up @@ -166,7 +268,7 @@ def clear_session_picker_state(user_data: dict[str, Any] | None) -> None:
user_data.pop(SESSIONS_KEY, None)


def build_directory_browser(
async def build_directory_browser(
current_path: str, page: int = 0, *, user_id: int
) -> tuple[str, InlineKeyboardMarkup, list[str]]:
"""Build directory browser UI.
Expand All @@ -178,17 +280,9 @@ def build_directory_browser(
path = Path.home()

try:
# Sort by mtime descending — most recently changed directories first.
# Fall back to alphabetical for any directory whose stat fails.
candidates: list[tuple[float, str]] = []
for d in path.iterdir():
if not d.is_dir():
continue
if not config.show_hidden_dirs and d.name.startswith("."):
continue
candidates.append((_dir_recency(d), d.name))
candidates.sort(key=lambda t: (-t[0], t[1].lower()))
subdirs = [name for _, name in candidates]
# Recursive stats can be I/O-heavy, so never block Telegram's event
# loop while calculating the order.
subdirs = await asyncio.to_thread(_sorted_subdirs, path)
except (PermissionError, OSError):
subdirs = []

Expand Down
Loading
Loading